Essential 01

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.

Level Intermediate Stack Java 17 · Spring Cloud Gateway · Vue 3 Status Prescriptive, not yet built

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.

Browser application about 20 stores each reads its own base-URL variable the client is the integration layer 20 × base-URL var 20 × Authorization 0 × correlation id eleven services · eleven origins · no single door URL·auth·CORS URL·auth·CORS URL·auth·CORS URL·auth·CORS URL·auth·CORS URL·auth·CORS identity area mission telemetry alerts + 6 more
Before. The tag under each arrow is identical on purpose: every edge re-implements the same three concerns. Six are drawn; there were eleven. Adding a twelfth service means editing every environment file, adding a CORS origin and finding somewhere new to attach the token, because there is no somewhere that is shared.
The starting condition: structurally verified, and still unsolved

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/.../events

What 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.

Belongs in the gateway
  • 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.
Must never move into it
  • 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.

James Lewis & Martin Fowler martinfowler.com/articles/microservices.html

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.

trust boundary: nothing here is reachable from a browser Mobile app Web console API Gateway: one entry point TLS · authn · rate limit · correlation-id · CORS · routing Path=/mobile/** Path=/web/** Mobile BFF one screen · one call Web BFF one screen · one call fan-out ×3 identity area mission telemetry
After. One door, then one backend per experience. The gateway routes by path and knows nothing about phones; each BFF knows about exactly one screen and is allowed to. The domain services are unchanged and unaware of any client. Note the crossings between the two fans, and that is the shared-service overlap, and it is where the duplication argument below begins.

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.

DimensionAPI GatewayBackend 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:

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.

The tension worth understanding

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

  1. 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.

  2. 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.

  3. 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.

  4. 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 is lb://, 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.

vueviews/AreaDashboard.vue
<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>
A BFF that fans out sequentially is not a BFF

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".

Canonical sources