API Gateway Patterns

Routing, request aggregation, and the Backend for Frontend (BFF) pattern for serving different clients.

The problem a gateway solves

Once a system is split into a dozen services, a client (a mobile app, a browser SPA, a third-party integrator) shouldn't need to know that orders-service lives at one host and inventory-service at another, or have to make five separate round trips to assemble one screen's worth of data. An API gateway sits in front of every service as the single entry point clients actually talk to — it's the one thing outside the system that has a stable, public address, no matter how many internal services exist behind it or how often they move.

Text
                          ┌──────────────────┐
                          │   API Gateway     │
Client ── HTTPS ─────────>│  (single address) │
                          └──────┬────────────┘
                    ┌────────────┼────────────┐
                    v            v            v
             Orders Service  Inventory Service  Payment Service

Three responsibilities show up in almost every gateway, in roughly increasing order of how much logic they put in the gateway itself: routing, request aggregation, and — for larger systems — a dedicated gateway per client type (BFF).

Routing

At its simplest, the gateway inspects each incoming request and forwards it to whichever backend service owns that path, rewriting the URL as needed:

YAML
# A simplified routing config, in the style of Kong / Traefik / AWS API Gateway
routes:
  - path: /orders/**
    service: orders-service
    strip_prefix: false

  - path: /inventory/**
    service: inventory-service

  - path: /payments/**
    service: payment-service

The client only ever addresses https://api.example.com/orders/482 — it has no idea, and no need to know, that this maps to orders-service running on some internal address that changes with every deploy. This is also the natural place to put cross-cutting concerns every request needs regardless of which backend service handles it: TLS termination, authentication (verifying a bearer token once, at the edge, instead of in every service), rate limiting (see Rate Limiting & Throttling in this app's REST API track), and request logging with the correlation ID that Observability for Microservices covers propagating onward.

Routing can go beyond a plain path match — header-based routing (X-API-Version: 2 routes to a newer backend version) and canary routing (send 5% of traffic to a new version, the rest to the stable one) are both the same mechanism: the gateway deciding where a request goes based on more than just its path.

Request aggregation

A mobile app's "order details" screen might genuinely need data from three services: the order itself, the customer's profile, and a shipping estimate. Without a gateway, that's three separate round trips from the client — expensive on a slow mobile connection, and it leaks internal architecture (which services exist, and which one owns what) straight into client code.

Request aggregation has the gateway make those calls itself, in parallel, and hand the client back one combined response:

Javascript
// Inside the gateway — one client request becomes three backend calls, run in parallel
app.get('/order-details/:orderId', async (req, res) => {
  const { orderId } = req.params;

  const [order, customer, shippingEstimate] = await Promise.all([
    fetch(`http://orders-service/orders/${orderId}`).then(r => r.json()),
    fetch(`http://orders-service/orders/${orderId}/customer`).then(r => r.json()),
    fetch(`http://shipping-service/estimates?orderId=${orderId}`).then(r => r.json()),
  ]);

  res.json({
    order,
    customer: { name: customer.name, email: customer.email },
    shippingEstimate: shippingEstimate.estimatedDays,
  });
});

The client makes one request and gets exactly the shape of data its screen needs; the three internal calls, and which services they hit, are the gateway's problem, not the client's. This is also where the gateway can quietly shrink the payload — the full customer record might have a dozen fields, and the client only actually needs two of them.

The BFF pattern: a gateway per client type

A single shared gateway works fine until a mobile app and a web dashboard genuinely need different shapes of the same data — the mobile app wants a stripped-down payload to save bandwidth, the dashboard wants a rich one with fields the mobile UI never displays. Cramming both needs into one generic gateway endpoint (optional fields, query parameters that toggle what's included) tends to grow into an unmaintainable mess serving nobody well.

Backend for Frontend (BFF) solves this by giving each client type its own dedicated gateway, tailored exactly to what that client needs, with all of them still calling the same underlying services:

Text
Mobile App  ──> Mobile BFF   ──┐
                                 ├──> Orders Service, Inventory Service, Payment Service
Web Dashboard ──> Web BFF     ──┘
Javascript
// Mobile BFF — deliberately minimal, optimized for a small screen and a slow connection
app.get('/orders/:id', async (req, res) => {
  const order = await fetchOrder(req.params.id);
  res.json({ id: order.id, status: order.status, total: order.total }); // just enough for a list row
});
Javascript
// Web BFF — same underlying order, a much richer shape for a dashboard table
app.get('/orders/:id', async (req, res) => {
  const [order, history, customer] = await Promise.all([
    fetchOrder(req.params.id),
    fetchOrderHistory(req.params.id),
    fetchCustomer(req.params.id),
  ]);
  res.json({ order, history, customer });
});

Each BFF is owned by (or built closely with) the team building that specific client, so it can change shape freely without needing sign-off from every other client's team — the trade-off is simply more gateway code to maintain than one shared gateway, which is exactly why BFF is worth adopting once client needs have genuinely diverged, not before.

Comparing the three patterns

Plain routing Request aggregation BFF
Problem solved Single entry point, cross-cutting concerns Fewer round trips, hides internal service topology Different clients need genuinely different response shapes
Where logic lives Gateway just forwards Gateway calls multiple services and combines results A separate gateway per client type, each doing its own aggregation
Added complexity Low Medium — gateway now has real logic, not just routing Higher — multiple gateways to build, deploy, and maintain
Adopt when Always — the baseline A screen needs data from more than one service Clients' needs have genuinely diverged, not just "might someday"

Implementation in Spring Boot, ASP.NET Core and Go

The same job — route /orders/** to orders-service and /inventory/** to inventory-service, with an authentication check applied to every request — built as a real gateway in each stack.

Spring Boot — Spring Cloud Gateway

YAML
# application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: orders-route
          uri: lb://orders-service
          predicates:
            - Path=/orders/**
        - id: inventory-route
          uri: lb://inventory-service
          predicates:
            - Path=/inventory/**

uri: lb://orders-service routes through the gateway's client-side load balancer using the service's logical name from service discovery (see Service Discovery & Configuration), rather than a hardcoded host. A custom GlobalFilter runs on every route and is the natural place for the auth check every request needs, regardless of which backend it's headed to:

Java
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String token = exchange.getRequest().getHeaders().getFirst("Authorization");

        if (token == null || !jwtValidator.isValid(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return -1; // run before the routing filters that actually forward the request
    }
}

ASP.NET Core — YARP (Yet Another Reverse Proxy)

JSON
// appsettings.json
{
  "ReverseProxy": {
    "Routes": {
      "orders-route": {
        "ClusterId": "orders-cluster",
        "Match": { "Path": "/orders/{**catch-all}" }
      },
      "inventory-route": {
        "ClusterId": "inventory-cluster",
        "Match": { "Path": "/inventory/{**catch-all}" }
      }
    },
    "Clusters": {
      "orders-cluster": {
        "Destinations": {
          "destination1": { "Address": "http://orders-service/" }
        }
      },
      "inventory-cluster": {
        "Destinations": {
          "destination1": { "Address": "http://inventory-service/" }
        }
      }
    }
  }
}
C#
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapReverseProxy().RequireAuthorization(); // every proxied route now requires a valid bearer token first

AddReverseProxy().LoadFromConfig(...) reads the Routes/Clusters shape above directly; MapReverseProxy().RequireAuthorization() is what applies the authentication/authorization pipeline to every route YARP forwards, without repeating an auth check per route.

Go — net/http/httputil.ReverseProxy with a custom Director

Go
func newGatewayProxy() http.Handler {
    ordersTarget, _ := url.Parse("http://orders-service")
    inventoryTarget, _ := url.Parse("http://inventory-service")

    proxy := &httputil.ReverseProxy{
        Director: func(req *http.Request) {
            switch {
            case strings.HasPrefix(req.URL.Path, "/orders"):
                req.URL.Scheme, req.URL.Host = ordersTarget.Scheme, ordersTarget.Host
            case strings.HasPrefix(req.URL.Path, "/inventory"):
                req.URL.Scheme, req.URL.Host = inventoryTarget.Scheme, inventoryTarget.Host
            }
            req.Host = req.URL.Host
        },
    }
    return proxy
}

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !isValidToken(r.Header.Get("Authorization")) {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.Handle("/", newGatewayProxy())

    log.Fatal(http.ListenAndServe(":8080", authMiddleware(mux)))
}

The Director function is where path-based routing decisions live — it rewrites each incoming request's scheme and host based on its path prefix before ReverseProxy forwards it on, and authMiddleware wraps the whole proxy exactly like it would wrap any other http.Handler, applying the same auth check regardless of which backend a request is ultimately routed to.

Common mistakes

  • Letting the gateway grow real business logic (validation rules, workflow decisions) instead of pure routing/aggregation — that logic belongs in the owning service, or the gateway becomes an undocumented, hard-to-test second home for business rules.
  • Making the aggregation calls in the example above sequentially (await one at a time) instead of with Promise.all — this turns three independent, parallelizable calls into a slower chain with no correctness benefit.
  • Building a BFF per client "just in case" before any client's needs have actually diverged — until then, one shared gateway is simpler to operate and keeps API behavior consistent across clients.
  • Treating the gateway as a single point of failure with no redundancy — since every request now flows through it, it needs to be deployed with the same seriousness (multiple instances, health checks, no-downtime deploys) as any of the services behind it.
  • Wiring up routing but forgetting the cross-cutting concerns routing was supposed to centralize in the first place — a gateway that forwards requests but applies no consistent auth check (as in the GlobalFilter/RequireAuthorization()/authMiddleware examples above) across all of its routes has quietly pushed that responsibility back onto every individual backend service.