API Composition Pattern: Aggregate Data Across Microservices
API Composition Pattern#
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
282 articles on system design at codelit.io/blog.
Try it on Codelit
Chaos Mode
Simulate node failures and watch cascading impact across your architecture
Related articles
AI Agent Tool Use Architecture: Function Calling, ReAct Loops & Structured Outputs
6 min read
AI searchAI-Powered Search Architecture: Semantic Search, Hybrid Search, and RAG
8 min read
AI safetyAI Safety Guardrails Architecture: Input Validation, Output Filtering, and Human-in-the-Loop
8 min read
Try these templates
OpenAI API Request Pipeline
7-stage pipeline from API call to token generation, handling millions of requests per minute.
8 componentsScalable SaaS Application
Modern SaaS with microservices, event-driven processing, and multi-tenant architecture.
10 componentsNetflix Video Streaming Architecture
Global video streaming platform with adaptive bitrate, CDN distribution, and recommendation engine.
10 componentsBuild this architecture
Generate an interactive architecture for API Composition Pattern in seconds.
Try it in Codelit →
Comments