What API composition is and why a routing gateway is not enough
API composition is the work of turning one client-facing request into several downstream calls, merging the results, and reshaping them into the response the client actually needs. A mobile home screen might need the user's profile, their recent orders, personalized recommendations and an unread-notification count. Those live in four services, each with its own schema, pagination rules and failure modes. Something has to call all four, join them on user and order IDs, drop the fields the screen never renders, and return one payload.
The client can do this itself, but each extra request on a mobile network adds latency, and calls that depend on earlier results have to wait in sequence. The client also becomes coupled to your service topology: split the orders service in two and you have to ship an app release, then wait for users to update. You also have to expose internal APIs to the public internet, along with every field and endpoint they carry.
A plain routing gateway fixes the exposure problem but not the others. It maps paths to services and handles TLS, authentication and rate limiting. It does not merge responses or trim payloads, so the client still makes four calls and still knows how the backend is divided.
The backend for frontend pattern and a GraphQL gateway both move api gateway aggregation off the device. The difference is where the composition cost ends up, and which team owns it.
How the backend for frontend pattern and a GraphQL gateway work
Backend for frontend
The backend for frontend pattern gives each client type its own thin backend: one for web, one for iOS, one for Android, and one for partners. Each BFF calls the downstream services that client needs, merges the results, and returns one response sized for that screen. The BFF also handles client-specific concerns. It exchanges a session cookie or mobile token for service credentials, removes fields the client never renders, and sets payload limits. A mobile BFF can drop heavy fields that the web BFF keeps. The composition logic is ordinary imperative code, so it is easy to read and easy to debug. The cost is duplication: four BFFs can each end up calling the same three services in slightly different ways.
GraphQL gateway
A GraphQL gateway exposes one schema that covers many services. Each field is backed by a resolver that calls a downstream API, and clients choose the fields they want. Response shaping moves from the server to the query.
There are two ways to build that schema:
- Hand-written: one team owns the whole schema and every resolver. This is simple, but that team becomes a bottleneck.
- Federated or stitched: each service contributes its own types, and the gateway merges them. Ownership is spread across teams, but you now need schema composition checks in CI, a schema registry or an equivalent, and tooling that catches breaking changes between subgraphs.
BFF topology GraphQL gateway topology
Web iOS Android Partner Web iOS Android Partner
| | | | \ | | /
[BFF] [BFF] [BFF] [BFF] \ | | /
| \ | \ / | / | [ GraphQL gateway ]
| \ | \ / | / | [ single schema ]
| \ | X | / | / | \
[Users] [Orders] [Catalog] [Users] [Orders] [Catalog]
BFF TOPOLOGY GRAPHQL GATEWAY TOPOLOGY
+-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+
| Web | | iOS | |Andrd| |Prtnr| | Web | | iOS | |Andrd| |Prtnr|
+-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+ +-----+
| | | | | | | |
v v v v +-------+---+---+-------+
+-----+ +-----+ +-----+ +-----+ | query
| BFF | | BFF | | BFF | | BFF | v
+-----+ +-----+ +-----+ +-----+ +-----------------------+
| | | | | GraphQL gateway |
+-------+---+---+-------+ | single schema with |
| each BFF calls | resolvers per field |
| +-----------------------+
| | resolver calls
+-------+-------+ +-------+-------+
v v v v v v
+-----+ +-----+ +-----+ +-----+ +-----+ +-----+
|Svc A| |Svc B| |Svc C| |Svc A| |Svc B| |Svc C|
+-----+ +-----+ +-----+ +-----+ +-----+ +-----+
Left: each client type (web, iOS, Android, partner) has its own BFF that calls the downstream services it needs, so composition logic is repeated across BFFs. Right: all clients send queries to one GraphQL gateway, whose resolvers call the same downstream services.
Worked example: one mobile home screen implemented both ways
The request
The screen needs the user's profile, their five most recent orders with item details, and ten recommendations. Both versions start from the same three services: Users, Orders, and Recommendations. A fourth, Catalog, provides product details.
BFF version
The handler starts three calls at once: profile, recent orders, and recommendations. Each call has its own timeout. The deadlines can differ because recommendations are optional and profile is not. The handler then maps each failure to a response shape. If Recommendations times out, the handler returns an empty list and a degraded flag. If Users fails, the whole request returns an error. Once orders arrive, the handler collects every item ID and makes one batched Catalog call. This code lives in the mobile BFF repository, and the mobile team owns it.
GraphQL version
The schema defines Query.me returning User, with orders(last: 5) and recommendations fields, and an OrderItem.product field. A naive product resolver calls Catalog once per item. A DataLoader-style loader instead collects the IDs requested during one execution tick and sends a single batched Catalog call. Resolvers and loaders live in the gateway, which a platform team usually owns.
Counting calls (illustrative estimate)
These numbers assume 5 orders with 3 items each, and that Orders returns item IDs but not product details.
| Version | Client round trips | Downstream calls |
|---|---|---|
| BFF | 1 | 4 |
GraphQL, naive product resolver |
1 | 18 (3 + 15) |
| GraphQL, batched loader | 1 | 4 |
Keep domain rules out of composition code
Both layers make it tempting to add rules such as "hide cancelled orders" or "apply loyalty pricing." Keep those rules in the owning service. If they move into a handler or resolver, other clients either duplicate them or bypass them.
// Mobile BFF handler for the home screen. Lives in the mobile-bff repo and is owned by the mobile team.
// It composes responses only. Rules like "hide cancelled orders" belong in the Orders service.
const base = { users: "", orders: "",
recs: "", catalog: "" };
async function getJson(url: string, timeoutMs: number): Promise<any> {
// Per-call deadline: the request is aborted if the service is slower than its budget.
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`${url} returned ${res.status}`);
return res.json();
}
export async function homeScreen(userId: string) {
// Start three calls concurrently. Deadlines differ by how much the screen depends on each call.
const [profile, orders, recs] = await Promise.allSettled([
getJson(`${base.users}/users/${userId}`, 300), // required
getJson(`${base.orders}/users/${userId}/orders?limit=5`, 400), // degradable
getJson(`${base.recs}/users/${userId}/recommendations?limit=10`, 250), // optional
]);
// Without a profile, the screen cannot render, so the whole request fails.
if (profile.status === "rejected") return { status: 502, body: { error: "profile_unavailable" } };
const degraded: string[] = [];
const recentOrders = orders.status === "fulfilled" ? orders.value.orders : (degraded.push("orders"), []);
const recommendations = recs.status === "fulfilled" ? recs.value.items : (degraded.push("recommendations"), []);
// Make one batched Catalog call for every item ID instead of one call per item (4 downstream calls total).
const ids = [...new Set(recentOrders.flatMap((o: any) => o.items.map((i: any) => i.productId)))];
let products: Record<string, unknown> = {};
if (ids.length > 0) {
try { products = await getJson(`${base.catalog}/products?ids=${ids.join(",")}`, 300); }
catch { degraded.push("product_details"); } // Render orders without product details instead of failing.
}
return { status: 200, body: { profile: profile.value, recentOrders, products, recommendations,
degraded: degraded.length > 0, degradedParts: degraded } };
}
A mobile BFF handler starts the Users, Orders, and Recommendations calls concurrently with separate timeouts, fails the request only when the required profile is missing, and fetches product details for all order items in one batched Catalog call.
BFF vs GraphQL gateway at runtime: latency, failures, caching, and security
Both approaches add a fan-out hop, so end-to-end latency is roughly the slowest call in each parallel stage, summed across dependent stages. A BFF fixes that call graph in code, so each downstream gets its own timeout and fallback. A GraphQL gateway builds the call graph from each query, which causes two problems. Naive resolvers make one downstream call per list item (the N+1 problem). Batching loaders fix this by coalescing keys within a request. The second problem is that clients can nest fields and multiply fan-out without any server change.
Failure semantics differ. A BFF returns 200, 502 or 504, so load balancers, dashboards and retry policies work unchanged. GraphQL usually returns 200 with an errors array beside partial data. Status-based error rates then show zero while a resolver is failing. Retrying the whole operation also repeats the calls that succeeded. Count errors per field instead.
BFF GET endpoints work with HTTP and CDN caches keyed by URL. GraphQL over POST does not, unless persisted queries turn operations into cacheable GET requests by ID. Otherwise caching moves into resolvers.
Both need trace context propagated to every downstream call. GraphQL also needs per-resolver spans to show which field was slow.
| Concern | BFF | GraphQL gateway |
|---|---|---|
| Fan-out | Fixed per endpoint | Shaped by client query |
| Partial failure | HTTP status codes | errors alongside data, often 200 |
| Caching | HTTP/CDN on GET | Persisted queries or app-level |
| Authorization | Per endpoint | Per field |
| Abuse controls | Rate limits per route | Depth/complexity limits, persisted-query allowlist |
Who should own composition: team topology and development cost
Query flexibility rarely decides this choice. Ownership usually does: who can change the composition layer, deploy it, and get paged when it breaks.
A BFF fits organizations where frontend-aligned teams can own and run a backend service. The mobile team changes its home screen payload, merges, and ships without asking anyone else. That saves the most time when screens change often. The cost is that every BFF repeats the same work: fan-out logic, input validation, and mapping downstream errors to client errors. Over time BFFs drift apart. Two clients show the same order status differently because each BFF interprets it in its own way. Or one BFF picks up business rules and becomes a general backend that other services start calling. Each new client also adds a deployment, dashboards, alerts, and an on-call rotation.
A GraphQL gateway needs a platform or schema-governance team and agreed rules for schema changes, such as naming, nullability, and deprecation. It pays off when a new client can reuse the existing schema and needs no new backend work. The cost is that the shared schema becomes a cross-team review queue. With only a handful of services, federation adds subgraph contracts, composition checks, and gateway operations, and that overhead can outweigh what it saves.
| Factor | BFF | GraphQL gateway |
|---|---|---|
| Required owner | Frontend-aligned team running a service | Platform or schema-governance team |
| Saves time when | Screens change often | New clients reuse the schema |
| Duplicated work | Aggregation, validation, error mapping per client | Resolvers written once |
| Coordination cost | Low per change, drift across clients | Schema review for every change |
| Operating cost | Deploy, monitor, on-call per client | One gateway plus federation tooling |
When to use each, when to avoid each, and hybrid alternatives
When the backend for frontend pattern fits
A BFF works best when you have two or three clients that differ in real ways, and each has a frontend team willing to own a service. Because each BFF returns a fixed response shape over plain HTTP, you can set cache headers per endpoint and serve read-heavy screens from a CDN. Avoid it once client types multiply. Every new client then needs another deployable service, on-call rotation and copy of the aggregation logic. Avoid it too if no frontend team can run production services, because the BFF will end up with no clear owner.
When a GraphQL gateway fits
Choose a GraphQL gateway when many clients change their data needs faster than backend teams can ship endpoints, and you have staff to review schema changes. Without that review, the schema fills up with duplicated types and resolvers that are expensive to run. Skip it if you have only a handful of services, if most traffic is cacheable reads that a CDN could absorb, or if no platform team exists to own query cost limits and the gateway's availability.
Hybrids
- BFFs calling a GraphQL gateway. Each BFF handles client-specific auth, caching and response shaping, and fetches its data through one shared graph. This fits when the graph already exists but clients still need edge caching.
- GraphQL as the BFF for one client. A single team gets flexible queries without a cross-team governance process. This fits when one client dominates.
- Routing gateway with downstream composition. A domain service aggregates its own neighbors. This fits when composition follows one domain boundary rather than one screen.
Decision checklist for BFF vs GraphQL gateway
Answer these questions before you write the first resolver or BFF endpoint. Each answer pushes you toward one option, and the combination matters more than any single answer.
- Distinct clients: Count clients whose data shapes actually differ, not just platforms. Two apps with the same screens count as one.
- Rate of change: If client data needs change weekly, a schema lets clients adapt without backend deploys. If they change quarterly, that flexibility buys little.
- Ownership: Name the team that will be paged when composition breaks. If no team can be named, neither option is ready.
- Caching and CDN: Plain GET responses cache at the edge with no extra work. GraphQL POSTs need persisted queries or response caching built in.
- Errors and monitoring: Can your tooling alert on partial failures inside an HTTP 200, or does it only alert on status codes?
- Security model: Decide whether authorization lives per endpoint or per field.
- Schema governance: Someone must review schema changes, handle deprecations, and set query cost limits. That needs staffed time, not a volunteer.
| Typical answers | Lean toward |
|---|---|
| 1–3 clients, stable needs, client teams own backends, heavy CDN use | BFF |
| Many clients, fast-changing needs, a funded platform team, field-level auth | GraphQL gateway |
| One dominant client plus several partner or internal consumers | Hybrid: a BFF for the main client, a schema for the rest |
Warning signs of drift:
- With BFFs, the same aggregation or auth code gets copy-pasted across several BFFs.
- With BFFs, one BFF starts serving clients it was not built for.
- With a gateway, the schema review queue grows longer than a sprint.
- With a gateway, resolvers start to contain business rules.
- With a gateway, clients ask for "just one custom field" endpoints.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to backend for frontend pattern, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.