← Back to the section

Once an application is split into several services, questions come up right away: how does a client find the service it needs? Who checks the token — every service on its own, or one component for all of them? How do you gradually migrate off the old monolith? Structural patterns are ready-made answers to these questions.

Let's go through the ten most common ones — from simple gateways to Service Mesh.

API Gateway

Imagine your city has a dozen restaurants, each at its own address. Instead of memorizing every address, people go to a single shopping mall — and once inside, it's clear where to go. API Gateway is that shopping mall for your services.

Problem. A client (a mobile app, a browser) shouldn't need to know the addresses of every service inside the system. And if each service checks the token, sets request limits, and logs on its own — that's the same code copied across ten places.

Solution. The Gateway accepts all incoming requests and routes each one to the right service. Cross-cutting concerns — authentication, rate limiting, CORS, logging — are handled here once.

diagram

An example of routing in Go — the standard library's net/http and httputil.ReverseProxy:

func newGateway() http.Handler {
	orderService := proxyTo("http://order-service:8080")
	userService := proxyTo("http://user-service:8080")

	orderLimiter := rate.NewLimiter(rate.Limit(10), 20) // 10 rps, burst up to 20

	mux := http.NewServeMux()
	mux.Handle("/api/orders/", rateLimit(orderLimiter, http.StripPrefix("/api", orderService)))
	mux.Handle("/api/users/", http.StripPrefix("/api", userService))
	return mux
}

func proxyTo(rawURL string) http.Handler {
	target, _ := url.Parse(rawURL)
	return httputil.NewSingleHostReverseProxy(target)
}

func rateLimit(limiter *rate.Limiter, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			w.WriteHeader(http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

The Gateway validates the JWT once and passes the user identifier to the services through a header — the services don't have to deal with it themselves (the JWT is parsed with the golang-jwt/jwt library):

type Claims struct {
	jwt.RegisteredClaims
	Roles []string `json:"roles"`
}

func authMiddleware(keyFunc jwt.Keyfunc, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		auth := r.Header.Get("Authorization")
		if !strings.HasPrefix(auth, "Bearer ") {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		claims := &Claims{}
		token, err := jwt.ParseWithClaims(strings.TrimPrefix(auth, "Bearer "), claims, keyFunc)
		if err != nil || !token.Valid {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

		r.Header.Set("X-User-Id", claims.Subject)
		r.Header.Set("X-User-Roles", strings.Join(claims.Roles, ","))
		next.ServeHTTP(w, r)
	})
}

When you need it: many services, a single entry point is required, cross-cutting concerns are duplicated in every service.

When you don't: a monolith or 1–2 services — the Gateway just adds an extra network hop with no upside.

Gateway Routing

Problem. Requests need to be directed to a specific service depending on the URL, HTTP method, or headers. Without explicit routing rules, there's no way to tell where each request ends up.

Solution. Gateway Routing is a set of rules (predicates): if the URL starts with /api/orders/, it goes to the Order Service; if the request carries the header X-API-Version: v2, it goes to the new version of the service.

mux := http.NewServeMux()

// By path
mux.Handle("/api/orders/", orderService)

// By header — API versioning
mux.Handle("/api/users/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if r.Header.Get("X-API-Version") == "v2" {
		userServiceV2.ServeHTTP(w, r)
		return
	}
	userServiceV1.ServeHTTP(w, r)
}))

// Traffic splitting — 20% to the new version
mux.Handle("/api/catalog/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	if rand.IntN(100) < 20 {
		catalogServiceV2.ServeHTTP(w, r)
		return
	}
	catalogServiceV1.ServeHTTP(w, r)
}))

When you need it: different API versions in different services, gradually shifting traffic to a new version (canary).

Gateway Aggregation

Problem. An order page shows data from three services: Order Service, User Service, Delivery Service. If the browser makes three separate requests, that's three round trips over the network. On mobile devices with a slow connection, this is noticeable.

Solution. The Gateway accepts a single request, queries all the required services in parallel, and assembles the response into one object. The client gets its data in a single round trip.

diagram
func (h *OrderDetailsHandler) GetOrderDetails(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	orderID := r.PathValue("orderId")

	var (
		wg       sync.WaitGroup
		order    *OrderDto
		user     *UserDto
		delivery *DeliveryDto
	)

	wg.Add(3)
	go func() {
		defer wg.Done()
		order, _ = fetchJSON[OrderDto](ctx, h.client,
			"http://order-service/orders/"+orderID)
	}()
	go func() {
		defer wg.Done()
		user, _ = fetchJSON[UserDto](ctx, h.client,
			"http://user-service/users/"+h.userIDFromOrder(ctx, orderID))
	}()
	go func() {
		defer wg.Done()
		delivery, _ = fetchJSON[DeliveryDto](ctx, h.client,
			"http://delivery-service/deliveries?orderId="+orderID)
	}()
	wg.Wait() // if any call fails, the matching field just stays empty

	json.NewEncoder(w).Encode(OrderDetailsResponse{
		Order:    order,
		User:     user,
		Delivery: delivery,
	})
}

func fetchJSON[T any](ctx context.Context, client *http.Client, url string) (*T, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var result T
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}

When you need it: the client needs data from 2–3 services, and the aggregation is simple, without business logic.

When you don't: you need complex transformation or filtering of the data — that's a job for a BFF.

Backend for Frontend (BFF)

Problem. You have a mobile app, a web admin panel, and a public website. Each needs its own set of data: mobile wants the bare minimum (to save bandwidth), the admin panel wants the full set with an action log, and the public site wants only the open data. A single shared API can't serve all of them well.

Solution. For each type of client you create a separate BFF service. It talks to the domain services itself and returns only what that particular client needs. There's no business logic in a BFF — only selecting and formatting data.

diagram

The Mobile BFF returns only the fields that are needed:

// Mobile BFF — minimal data
func (h *MobileOrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	id := r.PathValue("id")

	order, err := h.orderClient.GetOrder(ctx, id)
	if err != nil {
		http.Error(w, "order service unavailable", http.StatusBadGateway)
		return
	}
	user, err := h.userClient.GetUser(ctx, order.UserID)
	if err != nil {
		http.Error(w, "user service unavailable", http.StatusBadGateway)
		return
	}

	json.NewEncoder(w).Encode(MobileOrderResponse{
		ID:          order.ID,
		Status:      order.Status,
		TotalAmount: order.TotalAmount,
		FirstName:   user.FirstName,
	})
}

The Web BFF for the administrator pulls data from more sources and adds fields that aren't present in the mobile version.

How a BFF differs from an API Gateway: the Gateway is an infrastructure component that routes requests. A BFF is an application service that knows about its client's needs and adapts the data. They aren't competitors: the Gateway sits in front of the BFF.

When you need it: more than one type of client with different data needs.

When you don't: a single type of client — an API Gateway is enough.

Gateway Offloading

Problem. Every service configures SSL on its own, checks the token, sets security headers, and compresses responses. That's identical work in every service.

Solution. All of it is moved onto the Gateway. Internal services run over plain HTTP without SSL and without token checking — they receive the user identifier ready-made from the X-User-Id header.

gateway := securityHeaders(          // security headers
	rateLimit(ipLimiter,             // 100 rps, burst up to 200
		authMiddleware(keyFunc,      // JWT signature verification
			mux)))

func securityHeaders(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("X-Frame-Options", "DENY")
		w.Header().Set("Strict-Transport-Security", "max-age=31536000")
		next.ServeHTTP(w, r)
	})
}

An internal service simply reads the header — it doesn't deal with JWT:

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	userID := r.Header.Get("X-User-Id")

	order, err := h.orders.GetOrder(r.Context(), id, userID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	json.NewEncoder(w).Encode(order)
}

What to offload to the Gateway: SSL termination, JWT signature verification, rate limiting, CORS, security headers.

What to keep in the service: authorization (checking permissions on a specific resource) — the Gateway doesn't know the business logic; request body validation — it depends on the domain model.

Sidecar

Problem. Collecting metrics, encrypting traffic, retrying on errors — every service needs these. If the services are written in different languages, the same logic has to be written in each of them.

Solution. Alongside each service, a helper process (a sidecar) runs. It takes on the infrastructure concerns, while the main service focuses only on business logic. In Kubernetes, the sidecar and the main container live in the same Pod and communicate over localhost.

diagram
spec:
  containers:
    - name: order-service
      image: order-service:1.0
      ports:
        - containerPort: 8080

    - name: envoy-sidecar
      image: envoyproxy/envoy:v1.28
      ports:
        - containerPort: 9901
        - containerPort: 15001
        - containerPort: 15006

When you need it: services in different languages, and you need uniform traffic encryption or network-level retries.

When you don't: all services in one language — a library is simpler (sony/gobreaker for Go, tenacity for Python).

Service Mesh

Problem. A sidecar solves the problem for a single service. But when there are dozens of services — who configures all those proxies? How do you control which service is allowed to talk to which? How do you turn on encryption for the whole system at once?

Solution. Service Mesh is an infrastructure layer that manages the entire network between services. It consists of two parts:

  • Data Plane — proxies (Envoy) in each Pod, through which all traffic flows.
  • Control Plane — the managing component (Istio, Linkerd) that distributes configuration to all the proxies.
diagram

Example: Istio shifts 20% of traffic to a new version of the service and configures retries:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: order-service
spec:
  hosts:
    - order-service
  http:
    - route:
        - destination:
            host: order-service
            subset: v1
          weight: 80
        - destination:
            host: order-service
            subset: v2
          weight: 20
      retries:
        attempts: 3
        perTryTimeout: 2s
        retryOn: 5xx,reset,connect-failure

API Gateway vs Service Mesh: the Gateway handles traffic "from the outside in" (client → system). Service Mesh manages traffic inside the system (service → service).

When you need it: dozens of services, strict security requirements (encryption between all services), fine-grained traffic control.

When you don't: fewer than 10 services — the management complexity outweighs the benefit; libraries like sony/gobreaker are enough.

Strangler Fig

Problem. You have a working monolith that needs to move to microservices. Rewriting everything at once is risky and takes a year. Freezing new features during the migration is not an option.

Solution. New functionality is built in microservices. The old functionality is switched over gradually: traffic leaves the monolith for the new services as they become ready. The monolith keeps running for everything that hasn't moved yet.

The name comes from the strangler fig plant: it wraps around a tree and, over time, replaces it.

// Gateway during the migration phase
mux := http.NewServeMux()
mux.Handle("/api/orders/", orderService) // new Order Service
mux.Handle("/api/users/", userService)   // new User Service

// The old monolith — everything else: ServeMux gives priority
// to the longest prefix, /api/ catches the rest
mux.Handle("/api/", monolith)

Switching via a flag lets you roll back if something goes wrong:

type OrderFacade struct {
	legacy        *LegacyOrderClient
	newService    *NewOrderClient
	useNewService bool // use-new-order-service config, false by default
}

func (f *OrderFacade) GetOrder(ctx context.Context, id int64) (OrderDto, error) {
	if f.useNewService {
		return f.newService.GetOrder(ctx, id)
	}
	return f.legacy.GetOrder(ctx, id)
}

A typical migration order: pick the least coupled module → build a microservice with the same API → switch traffic through the Gateway → compare responses → remove the code from the monolith.

When you need it: migrating off a monolith without pausing development.

When you don't: a new project — start with the architecture you need from the outset; a monolith that works well and isn't in the way — leave it alone.

Anti-Corruption Layer (ACL)

Problem. Your service integrates with an external system that has its own data model: fields are named txn_id, amount_cents, sts, and statuses come as codes "S"/"F"/"P". If you use these models directly in your business logic, all your code starts to depend on someone else's conventions. When the external system is replaced, you'll have to rewrite half the service.

Solution. Between your service and the external system you place a translator layer. It takes in the "foreign" models and converts them into clear domain objects — and back again when needed.

diagram
// The external system's model
type ExternalPaymentResponse struct {
	TxnID       string `json:"txn_id"`
	AmountCents int    `json:"amount_cents"`
	Ccy         string `json:"ccy"`
	Sts         string `json:"sts"` // "S" = success, "F" = failed, "P" = pending
	CreatedTs   int64  `json:"created_ts"`
}

// Our domain model — clear names
type Payment struct {
	TransactionID uuid.UUID
	Amount        Money
	Status        PaymentStatus
	CreatedAt     time.Time
}

// The translator — isolates the external model from the domain
func ToDomain(external ExternalPaymentResponse) (Payment, error) {
	transactionID, err := uuid.Parse(external.TxnID)
	if err != nil {
		return Payment{}, err
	}
	currency, err := CurrencyFromCode(external.Ccy)
	if err != nil {
		return Payment{}, err
	}
	status, err := mapStatus(external.Sts)
	if err != nil {
		return Payment{}, err
	}
	return Payment{
		TransactionID: transactionID,
		Amount:        Money{Cents: external.AmountCents, Currency: currency},
		Status:        status,
		CreatedAt:     time.Unix(external.CreatedTs, 0).UTC(),
	}, nil
}

func mapStatus(externalStatus string) (PaymentStatus, error) {
	switch externalStatus {
	case "S":
		return StatusSuccess, nil
	case "F":
		return StatusFailed, nil
	case "P":
		return StatusPending, nil
	default:
		return "", fmt.Errorf("unknown payment status: %q", externalStatus)
	}
}

When you need it: integrating with an external API or a legacy system that has a foreign data model; when a replacement of the external system is planned.

When you don't: the external API fully matches your data model and no replacement is planned.

Service Registry and Service Discovery

Problem. Services scale dynamically: today there are 3 instances of the Order Service, tomorrow 10. Instances come and go with deployments, autoscaling, and restarts. Hardcoding the addresses by hand is impossible.

Solution. A Service Registry is a directory where each service registers itself on startup. Other services look up addresses through this registry. There are two approaches:

  • Client-Side Discovery — the client asks the registry itself (Consul, etcd) and picks an instance.
  • Server-Side Discovery — the request goes through a load balancer that knows about the registry (Kubernetes Services).
diagram

With Consul, the client looks up a service's address by name — the registry itself returns the healthy instances (hashicorp/consul/api):

func resolvePaymentService(consul *api.Client) (string, error) {
	services, _, err := consul.Health().Service("payment-service", "", true, nil)
	if err != nil {
		return "", err
	}
	if len(services) == 0 {
		return "", errors.New("no healthy payment-service instances")
	}
	svc := services[rand.IntN(len(services))].Service
	return fmt.Sprintf("http://%s:%d", svc.Address, svc.Port), nil
}

In Kubernetes, service discovery is built in: each Service gets a DNS name, and a call to http://payment-service:8080 automatically reaches one of the Pods.

apiVersion: v1
kind: Service
metadata:
  name: payment-service
spec:
  selector:
    app: payment-service
  ports:
    - port: 8080

Which to choose when: Consul/etcd — if you don't use Kubernetes or need extra registry features. Kubernetes DNS — if you're on Kubernetes, it's built in and there's nothing to configure.

Where to start

If you're building a system out of several services:

  1. API Gateway + Gateway Routing — a single entry point, path-based routing.
  2. Service Discovery — Kubernetes DNS if you're on K8s; otherwise Consul or etcd.
  3. Gateway Offloading — move SSL and token checking onto the Gateway.

As you grow:

  • BFF — when a second type of client appears.
  • Gateway Aggregation — when clients need data from several services in a single request.
  • Anti-Corruption Layer — when integrating with a legacy system that has a foreign data model.

For large systems:

  • Service Mesh — when managing traffic between dozens of services becomes complex.
  • Strangler Fig — when you need to migrate off a monolith gradually.

In short

  • API Gateway — a single entry point that takes on auth, rate limiting, and logging.
  • Gateway Routing — routing rules: which request goes to which service.
  • Gateway Aggregation — one client request → several parallel calls → one response.
  • BFF — a dedicated adapter service for each client type (mobile, web, public).
  • Gateway Offloading — cross-cutting concerns (SSL, tokens, headers) handled once on the Gateway instead of in every service.
  • Sidecar — a helper process next to a service that takes on infrastructure concerns regardless of language.
  • Service Mesh — managing the entire inter-service network through a Control Plane and Envoy proxies.
  • Strangler Fig — a gradual migration off a monolith: traffic is switched piece by piece, and rollback is always possible.
  • Anti-Corruption Layer — a translator layer between your domain model and a foreign API.
  • Service Registry & Discovery — a registry of live service instances; in Kubernetes it's built in.

Further reading

  • Resilience patterns — Circuit Breaker, Retry, Bulkhead.
  • Distributed patterns — Saga, Outbox, Event Sourcing.
  • Apache Kafka — asynchronous communication between services.