Resourcescompositionaggregationfan-outpartial datacoupling

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
When one endpoint aggregates several internal services, what does it promise about latency, freshness and behavior when a dependency is down?
Consumers
Clients that want one round trip for a multi-source view — a dashboard, an order-with-everything page, a search-results-with-details response — and that will build UI on whatever completeness guarantee the composed endpoint makes.
The promise
The composed endpoint states its latency budget, its freshness per data source, and its partial-failure behavior — so a missing section renders as "temporarily unavailable" by design instead of as a mystery.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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.

1 RTTrequiredoptional · 100ms timeoutoptional · 100ms timeoutoptional · cached fallbackClientGET /orders/42/full — composerOrders svc · 12ms p50Users svc · 8ms p50Catalog svc · 15ms p50Shipping svc · 40ms p50
UserLLMAgentToolDataDecisionHumanGuardrail

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

Partial failure by accident: null means three things
1GET /orders/42/full (shipping service down)
2200 OK
3{
4 "order": { … },
5 "customer": { … },
6 "shipping": null # down? none exists? not shipped yet?
7}
8# clients learn the difference from incident reports
Partial failure by design: degradation is data
1GET /orders/42/full (shipping service down)
2200 OK
3{
4 "order": { … }, # required: 5xx if missing
5 "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.

  1. 1
    Team → endpoint: adds GET /orders/{id}/full awaiting four services with no timeouts — every call succeeded in staging.
  2. 2
    Traffic → endpoint: p99 quadruples relative to any dependency; nobody can say which straggler is responsible.
  3. 3
    Shipping service → outage: the composed endpoint 500s; the whole order page is down because an enrichment failed.
  4. 4
    Team → hotfix: wraps shipping in a try/catch returning null; clients cannot tell outage from "not shipped"; support tickets ask why shipping "disappeared".
  5. 5
    Org → endpoint: three more consumers adopt /full for one field each; the heaviest endpoint is now load-bearing for everything and owned by no one.
What breaks
  • 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.

Design the contract
  • • 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]]).
Observe in production
  • • 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.
Evolve without breaking
  • • 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]]).
What it costs
  • • 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.

Misconceptions

Claim
“Parallel fan-out means the composed endpoint is as fast as the slowest mean latency.”
Reality
It is as slow as the slowest *sample*. Awaiting four dependencies samples four p99s per request, so the composite tail is far worse than any single dependency's — tail latency amplification is the defining cost of fan-out.
Claim
“Returning null for a failed section is graceful degradation.”
Reality
It is silent degradation. Null already means "absent" in most schemas; overloading it with "unavailable" makes outage behavior indistinguishable from normal data, for clients and for monitoring alike. Graceful means labeled.
Claim
“The gateway should do the aggregation — it already sits in front of everything.”
Reality
Gateways are shared policy infrastructure; putting business assembly there makes the most critical shared component contain everyone's domain logic with no owning team. Compose in a BFF or domain service; keep the gateway to policy. See The Gateway as Policy Boundary.

Apply it