When a client needs data that lives in multiple services, something has to do the joining. In a monolith it is a SQL JOIN. In a microservices system, the join has moved to the network — and the question becomes where to put it.
The API composition pattern is the most common answer: an aggregator service (or an API gateway) calls each backend service and joins the results in application code. It is the simplest pattern for cross-service queries, and the one that almost every distributed system reaches for first.
The Problem
A product detail page shows: product attributes, current inventory, recent reviews, and a recommendation list. Each of these lives in a different service. Without composition, the client makes four round trips.
Browser → /products/123 (Product service)
→ /inventory/123 (Inventory service)
→ /reviews?product=123 (Reviews service)
→ /recommendations/123 (Recommendations service)
Four round trips on every page load is bad for latency and worse for mobile clients on a flaky connection. It also leaks internal service topology to the client.
The Composition Pattern
A composer endpoint accepts the client's single request, fans out to the backend services in parallel, and joins the responses.
class ProductDetailController
{
public function show(string $productId): JsonResponse
{
$responses = Http::pool(fn ($pool) => [
$pool->get("http://products/products/{$productId}"),
$pool->get("http://inventory/items/{$productId}"),
$pool->get("http://reviews/products/{$productId}/reviews?limit=10"),
$pool->get("http://recommendations/products/{$productId}?limit=4"),
]);
return response()->json([
'product' => $responses[0]->json(),
'inventory' => $responses[1]->json(),
'reviews' => $responses[2]->json(),
'recommendations' => $responses[3]->json(),
]);
}
}
The client makes one request. The composer absorbs the four downstream calls. Because the calls happen in parallel, the total time is bounded by the slowest service, not the sum of all four.
Where the Composer Lives
Three common placements, each with tradeoffs.
In the API gateway. Kong, Envoy, or a custom Laravel/Express gateway handles routing and adds composition for endpoints that need it. Centralizes the pattern, but the gateway accumulates business logic over time.
In a Backend for Frontend (BFF). A dedicated composition layer per client type — one BFF for mobile, one for web, one for partner integrations. Each BFF is tailored to its client's needs. More code, but no client gets a worse shape than it needs.
Inside a specific service. A service responsible for "product detail" composes the others. Convenient when one service is clearly the entry point, awkward when there is no obvious owner.
The BFF placement is the most common in mature systems. It avoids cramming business logic into the gateway and lets each client team own its composition layer.
Joining Strategies
The simplest composition is "fetch everything, return everything." But there are richer joining strategies.
Lookup-and-merge. Fetch a primary entity, then fetch related entities by the IDs it contains.
$order = Http::get("http://orders/{$id}")->json();
$customer = Http::get("http://customers/{$order['customer_id']}")->json();
$items = Http::pool(function ($pool) use ($order) {
return collect($order['items'])->map(
fn ($item) => $pool->get("http://products/{$item['product_id']}")
)->all();
});
return $this->compose($order, $customer, $items);
Filtering by post-fetch. Fetch broadly, then filter in the composer. Simple but wasteful on bandwidth.
Fan-out then filter at source. Push the filter down to each service. Often requires the services to support batch endpoints.
For a "products with low inventory" query, fan-out then filter at source is right — you do not want to fetch all products and then check each inventory level. For a "user profile dashboard," lookup-and-merge is fine.
Failure Handling
Composing across services means accepting that any subset of the downstream calls might fail. The composer has to decide: hard fail, partial success, or fallback.
Hard fail. If any required service fails, the composer fails. Simple, brittle.
Partial success. Return what you can, indicate what failed. Better user experience, requires careful schema design.
{
"product": { "...": "..." },
"inventory": { "...": "..." },
"reviews": { "error": "service_unavailable" },
"recommendations": { "...": "..." }
}
Fallback. Substitute a cached or stale response for the failed call. Best for non-critical components like recommendations.
Decide per service, per endpoint. The product detail can probably ship without reviews and recommendations. It cannot ship without inventory.
Performance Considerations
The latency of a composed endpoint is the latency of the slowest downstream call. This means tail latency dominates composed performance.
- Use timeouts aggressively. A 30-second timeout on each downstream call means a 30-second timeout on your endpoint, even if three of four services responded in 50 ms.
- Cap parallelism. Fanning out 100 calls in parallel can DoS your downstream services.
- Cache where possible. Recommendations and reviews are good cache candidates; inventory is not.
- Monitor each downstream call separately. The composer's latency is a derived metric; the individual call latencies tell you where the real problem is.
When Composition Stops Scaling
The pattern breaks down when:
- The number of downstream calls per composed endpoint grows beyond a handful. At 10+ services per request, the chance of any one being slow approaches 100%.
- Cross-service queries need filtering or aggregation that cannot be pushed to the source services. You end up fetching huge sets and filtering in the composer.
- The composer accumulates so much business logic it becomes a de facto service of its own — at which point it should be promoted to one.
For high-cardinality queries, materialized views or read-side projections work better. For complex multi-service queries, GraphQL with federation can move some of the join logic into a more sophisticated layer. Composition remains the right pattern for the common case of "join three or four services into one response."
GraphQL Federation as a Variant
GraphQL federation is API composition with declarative schemas. Each service contributes a piece of the schema; the federation gateway parses the client query, fans out to the services that own each field, and composes the response.
The advantages over hand-coded composition: clients ask for exactly what they need, the gateway optimizes the fan-out, and new fields can be added by services without touching the gateway. The cost: an additional layer to operate and the standard GraphQL caveats around N+1 and query complexity.
Anti-Patterns
- Synchronous chains. A → B → C → D, each waiting on the next. Always synchronously. This is composition's bad cousin.
- Composing inside the request thread of a low-level service. The product service should not be calling out to four other services on every request. Push composition up to the BFF or gateway.
- No timeouts. A composer with no timeouts is at the mercy of its slowest downstream.
- Composition for writes. This pattern works for reads. Writes that span services need sagas, not composition.
Designing how clients fetch data that spans multiple services? We help teams pick the right composition layer for the request shape and the team. scopeforged.com