API Gateway & Backends-For-Frontends
Twenty stores in a browser application, each reading its own base-URL environment variable, is not an integration architecture. It is the absence of one, relocated into the client. This page is about the two patterns that take it back out again, which of the two you actually need, and the bill each one presents.
The claim under test
"The frontend just calls the services directly. It's simpler, there's one less thing to run, one less thing to deploy, one less place for a bug to hide." Every word of that is true about the gateway, and it quietly ignores what the arrangement does to the client on the other end.
A browser that talks to eleven services is not talking to eleven services. It is performing service discovery, badly, at build time, through environment variables, with no health awareness and no ability to re-route. The integration layer did not disappear when the gateway was declined. It moved into the part of the system you control least and can change most slowly.
In an eleven-service estate there was no gateway and no service discovery. These three facts were established by reading the source tree, not inferred from it:
- The browser application held roughly twenty separate stores, each reading its own base-URL environment variable. The client therefore knew the topology of the entire backend, and every topology change became a frontend release.
- Service-to-service calls used declarative HTTP clients whose target URLs were hardcoded per call site, 16 such edges, with a per-call URL override that defeats discovery entirely, even if discovery were added later.
- A shared wire-contract package did exist, for events. So the estate had already proved it could centralise a contract when it chose to, and yet there was no single place to apply authentication, correlation IDs, rate limiting or CORS policy. Each service and each store did its own.
No gateway was subsequently introduced. The third bullet is the sharp one: this was not a team that could not share a contract. It was a team that had already shared one for the asynchronous path and never built the equivalent seam for the synchronous path.
frontend/src/stores/*.ts .../client/*Client.java shared-contracts/.../eventsWhat a gateway centralises
The pattern itself is one sentence, and Chris Richardson's phrasing is the one worth memorising because the operative word is single:
Implement an API gateway that is the single entry point for all clients.
A single entry point is valuable precisely because it is the only place a cross-cutting concern can be applied exactly once. That also makes it the most tempting place in the system to put things, which is why the second list below matters more than the first.
- TLS termination: one certificate to rotate, not eleven.
- Authentication: verify the token at the edge and reject anonymous traffic before it reaches a service.
- Rate limiting: a per-principal budget is meaningless if it is counted in eleven separate places.
- Routing: path or host to upstream, resolved through discovery.
- Request/response logging: one access log covering every edge request.
- Correlation-ID injection: generate it once at the door so you can join every downstream log line to it.
- CORS: one allowed origin, in one configuration block.
- Business logic: the moment a rule lives at the edge, the service that owns the rule can no longer be reasoned about on its own.
- Domain decisions: "is this area still active", "may this crew be assigned" are questions with an owner, and the owner is not the router.
- Per-client response shaping: trimming fields for mobile is a real need and a real pattern, but it is the BFF's job. A gateway that knows a mobile app exists has stopped being general-purpose.
The reason the right-hand list is a hard boundary rather than a style preference is the argument Lewis and Fowler make under the heading smart endpoints and dumb pipes:
Applications built from microservices aim to be as decoupled and as cohesive as possible - they own their own domain logic and act more as filters in the classical Unix sense — receiving a request, applying logic as appropriate and producing a response.
Their target was the enterprise service bus: infrastructure that accumulated routing, transformation and business rules until the pipe knew more about the domain than the services did. An API gateway is architecturally the same shape of component and has exactly the same failure mode available to it. The rule that keeps it safe is the one above, that a service owns its domain logic, and a practical test of it is that everything the gateway does should still be correct if you replaced every service behind it with a different one.
One gateway, or one backend per experience
This is the distinction most write-ups blur, and it is not "a gateway per team". Newman's formulation is specific about what the unit of division is:
One solution to this problem that I have seen in use at both REA and SoundCloud is that rather than have a general-purpose API backend, instead you have one backend per user experience.
… The BFF is tightly coupled to a specific user experience, and will typically be maintained by the same team as the user interface.
User experience, not team, not client library, not platform in the abstract. A phone on a cellular link rendering a summary card and a desktop console rendering a dense operations table have genuinely different data needs: different fields, different volumes, different tolerance for round trips. A single general-purpose API can serve both, and it serves both badly: it is either too chatty for the phone or too heavy for the desktop, and usually both at once. Phil Calçado, who named the pattern at SoundCloud, described the symptom precisely:
This results in very fine-grained endpoints, which then require a large number of HTTP requests to multiple different endpoints to render even the simplest experiences.
The mechanism a BFF adds on top of the gateway is aggregation: it makes the N calls the client would otherwise have made, concurrently, inside the datacentre where a round trip costs a millisecond rather than a hundred, and returns one screen-shaped payload. The waterfall does not get faster. It gets moved somewhere it is cheap.
Gateway vs BFF, dimension by dimension
Richardson treats the BFF as a variation of the gateway rather than a rival: "It defines a separate API gateway for each kind of client", which is accurate about the mechanism and understates how differently the two are owned and operated.
| Dimension | API Gateway | Backend For Frontend |
|---|---|---|
| Number of instances | One per estate, replicated for availability. A second one means you have two front doors and a routing problem. | One per user experience: mobile, web console, partner API. Three experiences, three BFFs. |
| Ownership | Platform or infrastructure. Changes are policy changes and are reviewed as such. | The team that owns that client, released alongside it. Newman: maintained by the same team as the user interface. |
| What varies in it | Routes and policy only. Nothing client-specific. | Payload shape, field selection, aggregation, pagination defaults, all of it client-specific. |
| Coupling to the client | None, deliberately. It must not know a mobile app exists. | Tight, deliberately. That coupling is the feature: it is what lets the screen change without a cross-team negotiation. |
| Failure blast radius | Everything. Every client, every service, one process. | One experience. The web console keeps working while the mobile BFF is down. |
What it costs
None of this is free, and the honest version of the pitch leads with the bill. Richardson lists two costs plainly, and both are unavoidable rather than fixable:
Increased complexity - the API gateway is yet another moving part that must be developed, deployed and managed
Increased response time due to the additional network hop through the API gateway — however, for most applications the cost of an extra roundtrip is insignificant.
Add to those the ones you only discover in operation:
- A new single point of failure. Before the gateway, a broken service degraded one screen. After it, a broken gateway is a total outage. That is not an argument against the pattern. It is an argument that the gateway needs the same redundancy, health checking and rollout discipline as anything else on the critical path, and usually more.
- A deployment bottleneck, if you let it become one. Newman is blunt about what happens when every client change queues behind one deployable: "The single API backend can become a bottleneck when rolling out new delivery, as so many changes are trying to be made to the same deployable artifact." That sentence is the entire reason the BFF exists. A gateway carrying only routes and policy stays small enough to avoid it; a gateway that starts shaping responses will not.
- An extra hop, twice, if you run both. Client to gateway to BFF to service is three hops where there was one. Inside a datacentre the added latency is small and the removed client-side waterfall is large, so the trade usually wins, but it wins by arithmetic, not by faith, and the arithmetic is worth doing before you commit.
- Duplication between BFFs. Two BFFs rendering two versions of the same profile page will both fetch and merge the same things. Calçado hit this at SoundCloud directly: "Given every single application had an equivalent of a user profile page, there was a lot of duplicated code across all BFFs fetching and merging data for them."
That last cost is the one teams argue about, and Newman's answer is not the one most engineers expect. He does not propose extracting the shared code:
I am fairly relaxed about duplicated code across services. Which is to say that while in a single process boundary I will typically do whatever I can to refactor out duplication into suitable abstractions, I don't have the same reaction when confronted by duplication across services.
This is mostly as I am often more worried about the potential for extracting shared code to lead to tight coupling between services.
Calçado, facing that duplication, extracted a shared service to absorb it. Newman would rather keep the duplication than take the coupling. Both are right within their own constraints, and the deciding question is not "how much code is repeated" but "what happens when one BFF needs the shared thing to change and the other does not". If the answer is a cross-team release train, the duplication was cheaper. And if the shared thing turns out to be a genuine domain capability rather than a screen convenience, it was never BFF code at all. It belongs in a domain service, behind the same boundary as the rules it enforces.
Four ways this goes wrong
-
Business logic creeps into the gateway
It starts with something innocuous: a status field derived from two headers, a default applied when a query parameter is missing. Six months later the gateway is where you go to find out what the system does, and it is owned by a team that owns none of the domains it now encodes. This is the ESB failure mode wearing a newer name.
-
One "shared" BFF
Two clients, one BFF, because two deployables felt wasteful. It now has to satisfy both, so it grows optional fields, query flags and conditional shaping, and it has quietly become the general-purpose API backend the pattern was invented to replace, with an extra hop attached. If you cannot name the single user experience a BFF serves, it is not a BFF.
-
A front door on a distributed monolith
A gateway makes an estate look like one API. If the services behind it must all be released together, the gateway has not fixed that. It has hidden the evidence, and the next person to reason about the system will do so from a diagram that is no longer true. Fix the release coupling first. A gateway is a presentation of your architecture, not a correction to it.
-
A gateway with no service discovery behind it
Routing to a hardcoded host moves the sixteen hardcoded edges one hop inward and calls it progress; the gateway's routing table simply becomes the new place URLs go stale. Discovery is a prerequisite for the gateway, not an enhancement to it, which is why every
uri:in the configuration below islb://, resolved through a registry, and never a host and port.
Doing it properly
Three tabs, in the order the change actually happens. The first is what gets deleted; the second is the door; the third is the aggregation that makes the door worth walking through.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { authHeader } from '@/lib/auth'
import type { Area, Crew, Mission, Telemetry } from '@/types'
const props = defineProps<{ areaId: string }>()
// One base URL per service. There are roughly twenty of these across the
// application, one per store. Every environment file has to list them all,
// which means the browser knows the topology of the entire backend.
const AREA_API = import.meta.env.VITE_AREA_API_URL
const MISSION_API = import.meta.env.VITE_MISSION_API_URL
const TELEMETRY_API = import.meta.env.VITE_TELEMETRY_API_URL
const ROSTER_API = import.meta.env.VITE_ROSTER_API_URL
const area = ref<Area | null>(null)
const missions = ref<Mission[]>([])
const telemetry = ref<Telemetry[]>([])
const roster = ref<Crew[]>([])
async function get<T>(url: string): Promise<T> {
// Auth applied here, and independently in every other store.
const res = await fetch(url, { headers: authHeader() })
if (!res.ok) throw new Error(`${res.status} ${url}`)
return res.json() as Promise<T>
}
onMounted(async () => {
// Four origins, four CORS preflights, four Authorization headers, and,
// because each call feeds the next, four sequential round trips before
// the screen can paint. On a 180 ms mobile link that is most of a second.
area.value = await get<Area>(`${AREA_API}/areas/${props.areaId}`)
missions.value = await get<Mission[]>(`${MISSION_API}/missions?area=${props.areaId}`)
telemetry.value = await get<Telemetry[]>(`${TELEMETRY_API}/telemetry?area=${props.areaId}`)
roster.value = await get<Crew[]>(`${ROSTER_API}/crew?mission=${missions.value[0]?.id}`)
})
</script>
# The only process on the estate a browser is permitted to address.
spring:
security:
oauth2:
resourceserver:
jwt:
# The gateway verifies the token once, at the edge. Downstream
# services verify it again: the edge is a filter, not a promise.
issuer-uri: ${OIDC_ISSUER_URI}
cloud:
gateway:
server:
webflux:
# Applied to every route. This block is the whole point of the
# gateway: one place for the concerns that were previously
# re-implemented per service and per store.
default-filters:
- RemoveRequestHeader=Cookie
# CorrelationId is a CUSTOM GatewayFilterFactory in this codebase
# (class CorrelationIdGatewayFilterFactory); Spring resolves the
# short name by convention. There is no built-in equivalent.
- CorrelationId
- name: RequestRateLimiter
args:
# key-resolver is a real arg. Spring Cloud Gateway DOES ship a
# default KeyResolver - PrincipalNameKeyResolver, which keys on
# Principal.getName() - so naming a bean here is a choice, not a
# requirement. We name our own only to make the key explicit.
# Either way: if no key resolves, the filter denies the request
# (tunable via spring.cloud.gateway.filter
# .request-rate-limiter.deny-empty-key).
key-resolver: "#{@principalKeyResolver}"
redis-rate-limiter.replenishRate: 20
redis-rate-limiter.burstCapacity: 40
redis-rate-limiter.requestedTokens: 1
# CORS declared once, for the single origin the browser now uses,
# instead of eleven times across eleven services.
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: ${CONSOLE_ORIGIN}
allowedMethods: [GET, POST, PUT, PATCH, DELETE]
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600
routes:
# One route per user experience, not one route per team.
- id: mobile-bff
uri: lb://mobile-bff
predicates:
- Path=/mobile/**
filters:
- StripPrefix=1
- id: web-bff
uri: lb://web-bff
predicates:
- Path=/web/**
filters:
- StripPrefix=1
# Domain services stay reachable through the gateway for
# server-to-server callers. Every uri is lb:// and therefore
# needs a registered DiscoveryClient. A gateway without
# discovery just moves the hardcoded URLs one hop inward.
- id: area-service
uri: lb://area-service
predicates:
- Path=/api/areas/**
filters:
- name: CircuitBreaker
args:
name: areaCircuitBreaker
fallbackUri: forward:/fallback/areas
package bff.web.dashboard;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Web BFF. Owned by the team that owns the web console, deployed with it,
* and free to change shape the moment that screen changes shape. It knows
* about exactly one experience and is allowed to; a gateway is not.
*/
@RestController
@RequestMapping("/dashboard")
class AreaDashboardController {
private final AreaClient areas;
private final MissionClient missions;
private final TelemetryClient telemetry;
private final RosterClient roster;
/** Bounded pool. Never the common ForkJoinPool: blocking I/O starves it. */
private final Executor io;
AreaDashboardController(AreaClient areas, MissionClient missions,
TelemetryClient telemetry, RosterClient roster,
Executor io) {
this.areas = areas;
this.missions = missions;
this.telemetry = telemetry;
this.roster = roster;
this.io = io;
}
/**
* One screen, one call. The four upstream calls are issued together, so
* the wall clock is the slowest of them rather than the sum of all four.
* Doing this sequentially would rebuild the waterfall one hop further in.
*/
@GetMapping("/{areaId}")
AreaDashboard dashboard(@PathVariable String areaId) {
var areaF = CompletableFuture.supplyAsync(() -> areas.byId(areaId), io);
var missionsF = CompletableFuture.supplyAsync(() -> missions.byArea(areaId), io);
var telemetryF = CompletableFuture.supplyAsync(() -> telemetry.latestFor(areaId), io);
var rosterF = CompletableFuture.supplyAsync(() -> roster.onDutyIn(areaId), io);
// Join once, with a deadline. Without orTimeout the BFF inherits the
// worst latency present anywhere behind it, which is how one slow
// dependency turns into a slow product.
CompletableFuture.allOf(areaF, missionsF, telemetryF, rosterF)
.orTimeout(2, TimeUnit.SECONDS)
.join();
return new AreaDashboard(
areaF.join(),
missionsF.join(),
telemetryF.join(),
rosterF.join());
}
/**
* Screen-shaped, not domain-shaped. This record exists because one view
* renders it. When the view changes this changes, and nothing upstream
* has to be consulted, negotiated with, or released.
*/
record AreaDashboard(Area area,
List<Mission> missions,
List<TelemetrySample> telemetry,
List<CrewMember> onDuty) {}
}
Four awaits in a row inside the BFF is the same waterfall, relocated. The only
reason moving it inward helps at all is that the calls now run concurrently on a
fast network. If the aggregation is sequential you have paid for a deployment unit and an
extra hop and bought nothing. Concurrency is the feature; the endpoint is just where it
happens to live.
When you actually need one
A gateway is infrastructure, and infrastructure you do not need is a permanent tax. The threshold is not "we have microservices". It is "we are implementing the same concern in more than one place, and the copies have started to drift".
- You have more than one kind of client with genuinely different data needs, such as a phone and a dense desktop console, or a partner API. That, and only that, is what justifies a BFF.
- A cross-cutting concern is currently implemented N times: authentication, correlation IDs, rate limiting or CORS, repeated per service and per store.
- A single screen costs the client three or more sequential round trips, and that client is on a high-latency link.
- There are services you do not want addressable from a browser at all, and today there is no boundary at which to stop that.
- You have three services, one web client and one origin, where a reverse-proxy rule is the entire answer, and a gateway is a process you now have to run, patch and be paged about.
- What you actually want is service discovery. That is a different pattern, and a gateway without it moves the hardcoded URLs one hop rather than removing them.
- You are hoping it will make a distributed monolith easier to reason about. It will make it easier to draw, which is not the same thing.
Canonical sources
- Sam Newman: Backends For Frontends · the canonical BFF write-up: one backend per user experience, the bottleneck argument and the position on duplication. Not a Fowler pattern: Newman credits Phil Calçado with the name.
- Phil Calçado: The Back-end for Front-end Pattern (BFF) · the origin account from SoundCloud, including the duplication problem and how they chose to absorb it.
- Chris Richardson: API Gateway pattern · the single-entry-point definition, the edge functions, and the two costs stated without hedging.
- James Lewis & Martin Fowler: Microservices · smart endpoints and dumb pipes: why domain logic stays in the service and out of the pipe. Cited here for that principle, not for BFF.
- Spring Cloud Gateway reference · the property names used above, including
server.webflux.routes,default-filters,globalcorsand theRequestRateLimiterarguments.