API Composition Pattern: Aggregate Data Across Microservices
In a monolith, joining data is a SQL query. In microservices, it's an architecture problem. The API composition pattern solves it by having a composer service aggregate data from multiple downstream services into a single response.
The Problem#
A client needs data that lives across multiple services:
"Show me order #123 with customer name, product details, and shipping status"
Order Service → { orderId: 123, customerId: 456, productId: 789 }
Customer Service → { id: 456, name: "Alice" }
Product Service → { id: 789, name: "Widget", price: 29.99 }
Shipping Service → { orderId: 123, status: "in transit", eta: "Mar 30" }
Without composition, the client makes 4 API calls, handles failures, and merges data itself. That logic gets duplicated across every client — web, mobile, CLI.
How Composition Works#
A composer sits between clients and services:
Client → Composer → Order Service
→ Customer Service
→ Product Service
→ Shipping Service
← merged response
The composer:
- Receives the client request
- Fans out to downstream services
- Aggregates responses
- Returns a unified response
API Gateway Composition#
The most common approach: the API gateway acts as the composer.
Client → API Gateway (composer)
├── GET /orders/123
├── GET /customers/456
├── GET /products/789
└── GET /shipping?orderId=123
← { order, customer, product, shipping }
Advantages:
- Single entry point — clients see one API
- No new service — gateway already exists
- Cross-cutting concerns — auth, rate limiting, logging in one place
Risks:
- Gateway bloat — too much business logic in the gateway
- Single point of failure — gateway down means everything is down
- Coupling — gateway knows about every service's API
Keep composition logic thin in the gateway. If it needs business rules, extract it into a dedicated service.
GraphQL as Composer#
GraphQL is a natural fit for the composition pattern. The schema defines the unified view, and resolvers fetch from individual services.
type Order {
id: ID!
customer: Customer! # resolved from Customer Service
products: [Product!]! # resolved from Product Service
shipping: ShippingStatus # resolved from Shipping Service
}
const resolvers = {
Order: {
customer: (order) => customerService.getById(order.customerId),
products: (order) => productService.getByIds(order.productIds),
shipping: (order) => shippingService.getByOrderId(order.id),
},
};
GraphQL advantages for composition:
- Client specifies fields — no over-fetching
- Automatic parallelization — independent resolvers run concurrently
- DataLoader batching — N+1 queries solved at the framework level
- Schema as contract — typed, self-documenting API
Watch for the N+1 problem: a list of 50 orders each resolving a customer = 50 customer API calls. Use DataLoader to batch them into one.
Backend for Frontend (BFF) Composition#
Each client type gets its own composer optimized for its needs:
Mobile App → Mobile BFF → Services (minimal payload, fewer fields)
Web App → Web BFF → Services (full payload, rich data)
Admin Panel → Admin BFF → Services (extra fields, bulk operations)
Why BFF over a single gateway:
- Mobile needs less data — save bandwidth
- Web needs different formats — HTML-friendly structures
- Admin needs extra fields — audit logs, internal IDs
- Teams move independently — mobile team owns mobile BFF
The tradeoff is more services to maintain. Use BFF when client needs diverge significantly.
Parallel vs Sequential Calls#
Parallel (Independent Data)#
When downstream calls don't depend on each other, run them in parallel:
const [customer, products, shipping] = await Promise.all([
customerService.get(customerId),
productService.getByIds(productIds),
shippingService.getByOrder(orderId),
]);
Total latency = max(individual latencies), not sum.
Sequential (Dependent Data)#
When one call's result feeds into the next, you must go sequential:
const order = await orderService.get(orderId);
// Need order.customerId for next call
const customer = await customerService.get(order.customerId);
// Need order.productIds for next call
const products = await productService.getByIds(order.productIds);
Total latency = sum of all call latencies.
Hybrid#
Maximize parallelism by mapping the dependency graph:
const order = await orderService.get(orderId);
// These two don't depend on each other, only on order
const [customer, products, shipping] = await Promise.all([
customerService.get(order.customerId),
productService.getByIds(order.productIds),
shippingService.getByOrder(order.id),
]);
Total latency = orderService + max(customer, products, shipping).
Timeout Handling#
Every downstream call needs a timeout. Without one, a single slow service blocks the entire response.
async function fetchWithTimeout(promise, timeoutMs) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), timeoutMs)
);
return Promise.race([promise, timeout]);
}
const customer = await fetchWithTimeout(
customerService.get(customerId),
2000 // 2 second timeout
);
Timeout Strategy#
| Service | Timeout | Reasoning |
|---|---|---|
| Critical (order) | 5s | Must have — fail entire request |
| Important (customer) | 3s | Should have — degrade gracefully |
| Optional (recommendations) | 1s | Nice to have — skip if slow |
Set the overall composition timeout shorter than the sum of individual timeouts. If overall is 5 seconds, you can't give 5 seconds to each of 4 services.
Partial Failure#
In a composed response, some services will fail while others succeed. You have three strategies:
Strategy 1: Fail Entire Request#
If any downstream call fails, return an error. Simple but brittle.
// All or nothing
const [order, customer, products] = await Promise.all([...]);
// If any fails, Promise.all rejects
Use for: checkout, payment, critical workflows.
Strategy 2: Graceful Degradation#
Return what you can, mark missing parts as unavailable:
const results = await Promise.allSettled([
orderService.get(orderId),
customerService.get(customerId),
shippingService.getByOrder(orderId),
]);
return {
order: results[0].status === 'fulfilled' ? results[0].value : null,
customer: results[1].status === 'fulfilled' ? results[1].value : null,
shipping: results[2].status === 'fulfilled' ? results[2].value : null,
_errors: results
.filter(r => r.status === 'rejected')
.map(r => r.reason.message),
};
Use for: product pages, dashboards, non-critical views.
Strategy 3: Fallback Data#
Use cached or default data when a service fails:
let customer;
try {
customer = await customerService.get(customerId);
cache.set(`customer:${customerId}`, customer);
} catch {
customer = cache.get(`customer:${customerId}`) || { name: 'Unknown' };
}
Use for: display data, recommendations, non-essential enrichment.
Anti-Patterns#
Composing too many services — if a single endpoint calls 10+ services, your decomposition is wrong. Reconsider service boundaries.
Business logic in the composer — the composer should aggregate, not transform. Business rules belong in domain services.
No caching — if customer data changes hourly, don't fetch it on every request. Cache aggressively for read-heavy compositions.
Synchronous chains — A calls B calls C calls D. This isn't composition, it's a distributed monolith. Each service should own its data.
Key Takeaways#
- API composition solves the cross-service data aggregation problem
- API gateway for simple cases, BFF when client needs diverge
- GraphQL is a natural composer with built-in field selection and batching
- Parallelize independent calls, sequence only dependent ones
- Set timeouts on every downstream call — one slow service shouldn't block everything
- Use Promise.allSettled for graceful degradation over all-or-nothing failure
Explore more system design guides at codelit.io/blog.
Try these templates
Small SaaS Launch Architecture
A single application with private file storage, tenant-scoped records, and idempotent billing events. Start small; scale from measured demand.
5 componentsFeedback to Roadmap Architecture
Preserve original evidence separately from model suggestions and require review before publishing roadmap changes.
5 componentsWebsite Monitoring Architecture
Isolate browsing, store dated evidence, and separate scheduled checks from approved report delivery.
5 componentsContinue learning
Go deeper on system design
As an Amazon Associate I earn from qualifying purchases. Codelit may receive a commission at no extra cost to you.
Building Microservices
Sam Newman · 2021
600 pages on splitting a monolith: service boundaries, data decomposition, migration order.
Release It!
Michael T. Nygard · 2018
Post-mortems of real outages, then the stability patterns (circuit breaker, bulkhead) that stop them.
4.6 (501)#4 in Client-Server Networking SystemsA Philosophy of Software Design
John Ousterhout · 2021
Short book on module depth and complexity, with before/after code showing why an interface is too wide.
RESTful Web API Patterns and Practices Cookbook
Mike Amundsen · 2022
Recipes for hypermedia, retries and service orchestration you can look up mid-implementation.
3.7 (4)