Composed APIs: Aggregating Other Services
An endpoint that answers by calling four other services inherits four latencies, four failure modes and four teams' release schedules. Composition buys consumers one call instead of N — and the contract must say what happens when one of the N fails.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The composed read, and its inherited physics
A composed endpoint is a fan-out: GET /orders/42/full calls the orders service, then users, catalog and shipping in parallel. The client saves round trips — that is the whole point, and on mobile networks it is a large point (see API Granularity and the Chatty API). The provider inherits the physics: the composed response is as slow as its slowest dependency, and its availability is, naively, the *product* of theirs. Four dependencies at 99.9% make a naive composition 99.6% — from under nine hours of downtime a year to almost thirty-five.
Latency composes by tails, not means. If each dependency answers in 20ms at p50 but 400ms at p99, an endpoint awaiting all four hits a 400ms straggler in roughly 4% of requests — the fan-out samples every dependency's tail on every call. The composition contract therefore needs per-dependency timeouts and an overall budget ("answer within 300ms with whatever has arrived"), not just an aspiration.
None of this argues against composition; it prices it. The alternative — every client fanning out itself — pays the same physics plus N× the RTTs from a worse network position, and scatters the failure handling across every consumer. Composition centralizes the problem where it can actually be solved. The question the contract must answer is *how* it is solved.
Partial failure is the contract's hardest clause
Shipping is down. What does GET /orders/42/full return? The four candidate answers are all defensible and mutually exclusive: fail the whole request (honest, brittle — one optional section kills the page); return the field as null (indistinguishable from "no shipping info exists"); omit the field (breaks clients that require it); or return the section with explicit degradation metadata. The only wrong move is not choosing — because then the behavior is whatever the composer's error handling happens to do, and clients discover it during the dependency's next outage.
The durable pattern splits dependencies into required (the order itself — without it there is no response, fail with 503 and honest retry guidance) and optional (enrichments — degrade explicitly). Explicit degradation means the response says which sections are missing and why, so the UI renders "shipping status temporarily unavailable" instead of a blank, and monitoring can count degraded responses instead of confusing them with healthy ones. Stale-as-fallback (serve the last cached shipping status, labeled stale) is often better than absent — a freshness statement per section makes that honest (see Consistency as a Contract Clause).
1GET /orders/42/full (shipping service down)2→ 200 OK3{4 "order": { … },5 "customer": { … },6 "shipping": null # down? none exists? not shipped yet?7}8# clients learn the difference from incident reports1GET /orders/42/full (shipping service down)2→ 200 OK3{4 "order": { … }, # required: 5xx if missing5 "customer": { … },6 "shipping": { "status": "unavailable",7 "retry_after_s": 30,8 "last_known": { "state": "in_transit",9 "as_of": "2026-08-25T09:41:00Z" } },10 "degraded": ["shipping"]11}The good shape costs a wrapper object and pays for itself at the first dependency outage: UIs degrade gracefully by design, monitoring counts degraded[] responses, and "null" gets its meaning back. The bad shape works identically in the demo — that is why it ships.
Coupling, ownership, and when not to compose
A composed endpoint couples its owner to every dependency's contract, capacity and release schedule. Each dependency's breaking change is now the composer's incident; each dependency's p99 regression is the composer's SLO burn. That coupling needs an owner — a team accountable for the composed contract, with tracked-consumer relationships to each dependency (API Ownership and the Catalog applies one level down). Composition without ownership produces the org's most-blamed, least-staffed endpoint.
Where to compose is an architectural choice with three usual homes: a Backend for Frontend when the aggregate is experience-shaped and one client team owns it; a domain service when the aggregate is a real domain concept ("order summary") used by many consumers; a gateway-level composition only for thin, mechanical joins (see The Gateway as Policy Boundary — gateways should apply policy, not business assembly). And sometimes the answer is not to compose: two calls the client can make in parallel cost one RTT extra and zero coupling; a composition that saves nothing but an await is pure liability.
Watch the depth. Composers calling composers stack tail latencies and multiply blast radius; a three-deep chain gives the leaf service the power to take down every aggregate above it. Keep composition shallow — composers call capability services, not other composers — and let dependency-count growth trigger a design review rather than another Promise.all entry.
Key points
- Composition trades client round trips for provider-side coupling: the composed endpoint inherits every dependency's tail latency and failure rate.
- Fan-out samples every dependency's p99 on every request; per-dependency timeouts and an overall budget are contract clauses, not tuning.
- Classify dependencies as required (fail honestly) or optional (degrade explicitly); make degradation visible in the response, never as ambiguous null.
- Stale-with-timestamp often beats absent — per-section freshness statements make it honest.
- Compose in an owned home (BFF or domain service), keep composition shallow, and skip it when parallel client calls would cost nothing.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Team → endpoint: adds
GET /orders/{id}/fullawaiting four services with no timeouts — every call succeeded in staging. - 2Traffic → endpoint: p99 quadruples relative to any dependency; nobody can say which straggler is responsible.
- 3Shipping service → outage: the composed endpoint 500s; the whole order page is down because an enrichment failed.
- 4Team → hotfix: wraps shipping in a try/catch returning null; clients cannot tell outage from "not shipped"; support tickets ask why shipping "disappeared".
- 5Org → endpoint: three more consumers adopt
/fullfor one field each; the heaviest endpoint is now load-bearing for everything and owned by no one.
- One optional dependency's outage takes down every composed view above it — blast radius inverts the service decomposition.
- Ambiguous nulls make client behavior during outages undefined: cached wrong states, blank UI sections, misfiled bugs.
- The composer team burns SLO and on-call time for regressions in services they do not operate.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Declare each dependency required or optional in the endpoint's contract; fail required loudly, degrade optional explicitly with a `degraded[]` indicator.
- • Set per-dependency timeouts and an overall latency budget; return what has arrived when the budget expires.
- • Serve labeled stale data as fallback where the domain tolerates it, with per-section `as_of` freshness.
- • Give the composed endpoint an owner, registered consumers, and a dependency list reviewed when it grows (see [[api-ownership]]).
- • Emit per-dependency latency and error contributions from the composer — "which straggler" must be a dashboard, not a debugging session (see [[request-ids]]).
- • Count degraded responses per section as a first-class metric; a rising `degraded: ["shipping"]` rate is the outage signal.
- • Track dependency fan-out per composed request over time; quiet growth in the dependency list is architecture drift.
- • New sections join the composition as optional-by-default, so their instability cannot degrade the established contract.
- • A dependency being split or replaced is absorbed inside the composer — the composed contract is the seam consumers keep.
- • If a composition's consumers diverge (each reads a different subset), split it into narrower aggregates or push field selection to the client before it becomes a kitchen sink (see [[over-under-fetching]]).
- • Degradation machinery (timeouts, fallbacks, staleness labels) is real engineering that a naive `Promise.all` skips — until the first dependency outage.
- • Explicit partial-data contracts push handling complexity to clients: every consumer must render the degraded case.
- • The composer is a coupling point and a potential bottleneck; its capacity now gates every consumer of every underlying service it fronts.