Core gateway responsibilities, the Backend-for-Frontend pattern, request aggregation/fan-out, Spring Cloud Gateway's route predicates and filters, and why the gateway itself needs to be highly available.
Published September 23, 2026
A single entry point sitting in front of a microservices architecture, handling cross-cutting concerns once instead of duplicating them in every service: routing (directing each request to the correct backend service), authentication (verifying identity before requests reach any service — see Security Filter Chain Architecture for the same chain-of-responsibility shape applied at the gateway level), rate limiting (see the Rate Limiter case for the algorithms, enforced here as one of the three enforcement-location options), and request/response transformation (adapting between what clients send and what backend services expect).
[Mobile App] → [Mobile BFF] → aggregates/trims responses for small screens, limited bandwidth
[Web App] → [Web BFF] → richer responses, more fields, different pagination defaults
↓
[Same underlying microservices]
Rather than one gateway serving every client type identically, BFF tailors the gateway layer itself per client — a mobile client typically wants smaller, more aggregated responses (fewer round trips over a potentially slow connection) while a web client might prefer more granular endpoints matching a richer UI's data needs. Each BFF is itself a thin service, not a full reimplementation — it composes and reshapes calls to the same underlying microservices differently per client type.
@RestController
class ProductDetailBffController {
@GetMapping("/bff/products/{id}")
Mono<ProductDetailResponse> getProductDetail(@PathVariable String id) {
Mono<Product> product = productServiceClient.getProduct(id);
Mono<List<Review>> reviews = reviewServiceClient.getReviews(id);
Mono<Inventory> inventory = inventoryServiceClient.getInventory(id);
return Mono.zip(product, reviews, inventory)
.map(tuple -> new ProductDetailResponse(tuple.getT1(), tuple.getT2(), tuple.getT3()));
// fans out to 3 services in parallel, composes one response — see Inter-Service Communication Choices for Mono/Flux
}
}
Without gateway-level aggregation, a mobile client would need to make three separate round-trips (to product, review, and inventory services individually) — aggregation moves that fan-out to the gateway (or a dedicated BFF), where it can run in parallel server-side (typically much lower latency, same datacenter) rather than sequentially from a client over a potentially slow network.
@Bean
RouteLocator customRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route("order-service", r -> r.path("/api/orders/**") // route predicate: match by path
.filters(f -> f.stripPrefix(1).addRequestHeader("X-Gateway", "true")) // filters: transform the request
.uri("lb://order-service")) // lb:// — resolved via service discovery, see Service Discovery
.route("payment-service", r -> r.path("/api/payments/**")
.filters(f -> f.circuitBreaker(c -> c.setName("paymentCB"))) // per-route resilience
.uri("lb://payment-service"))
.build();
}
Route predicates decide which requests a route matches (by path, header, method, and more). Filters modify the request/response as it passes through a matched route — stripping a path prefix, adding headers, applying a circuit breaker. Global filters apply across every route (e.g. a single authentication check applied to all traffic, rather than repeated per-route configuration) — the mechanism that makes "enforce auth once, for everything" practical rather than error-prone to configure per-route individually.
Every single request to every backend service flows through the gateway — if it goes down, the entire system becomes unreachable, regardless of how healthy the individual microservices behind it are. This makes the gateway's own high availability non-negotiable: multiple gateway instances behind a load balancer, health checks, and genuinely stateless gateway instances (so any instance can handle any request, enabling simple horizontal scaling and failover) are standard requirements, not optional hardening — a single-instance gateway defeats a large part of the resilience microservices decomposition was meant to buy in the first place.
Authentication is a good gateway-level fit (reject unauthenticated requests before they cost any backend service compute). But authorization for fine-grained, resource-specific permissions ("can this user edit this specific document" — see Role-Based & Method-Level Authorization's custom permission evaluators) often needs data only the owning service has, making it a service-level concern. Rate limiting frequently belongs in both: a coarse global limit at the gateway (protecting the whole system from being overwhelmed) plus finer per-endpoint limits at individual services (protecting one specific expensive operation) — the same layered-defense idea covered in the Rate Limiter case study's enforcement-location tradeoffs.
Q: How does the gateway know which service instances are healthy and available for routing? A: Via the service discovery mechanism (see Service Discovery) — Spring Cloud Gateway's lb:// URI scheme resolves through a service registry (Eureka, Consul, or Kubernetes' own service resolution), which is exactly why the gateway itself doesn't hard-code backend service addresses.
Q: What happens to gateway-level rate limiting during a gateway instance restart/deploy? A: If rate-limit state is stored per-instance in memory, a restart resets that instance's counters — for correctness under a multi-instance, frequently-redeployed gateway, rate-limit state needs to live in a shared store (Redis, the same approach as the Rate Limiter case's distributed implementation), not in each gateway instance's local memory.
Q: Why use a circuit breaker filter on the payment-service route specifically? A: Payment is a common example of a downstream dependency where cascading failure is especially costly — if the payment service is slow or down, a circuit breaker at the gateway can fail fast (returning an error immediately) rather than letting requests queue up and exhaust gateway resources waiting on a downstream service that isn't going to respond in time.
Q: Could a BFF and the main API Gateway be the same service? A: For a simple system, yes — but as client-specific tailoring grows more complex, separating them lets the core gateway stay focused on routing/cross-cutting concerns (auth, rate limiting) while each BFF independently evolves its client-specific aggregation logic, avoiding one increasingly complex service trying to serve both roles at once.