Skip to content

API Composition

Your services each own a private database, so the order detail page is now a problem. It needs the order itself from the Order service, the buyer’s name and tier from the Customer service, and the delivery status from the Shipping service. In a monolith this was a three-table join. Now those tables live in three databases that no single query can reach.

The page still has to render. You need to gather data from several services and present it as one combined result, but you have no shared database to join across, and you would rather not invent a whole new storage layer just to answer one query.

How do you implement a query whose data is scattered across services, without breaking the Database per Service boundary, and without over-engineering?

Introduce an API Composer — a small component (often inside an API gateway or a backend-for-frontend) whose only job is to answer this query. It calls each service that owns part of the answer, collects the responses, and joins them together in memory before returning the combined result.

The composer holds no data of its own. It is pure coordination: fan out the requests (ideally in parallel, since they are independent), then merge. Because the three calls don’t depend on each other, issuing them concurrently keeps latency close to that of the slowest single call rather than the sum of all three.

flowchart LR
  Client[Client] --> Composer[API Composer]
  Composer -->|getOrder| OrderSvc[Order Service]
  Composer -->|getCustomer| CustomerSvc[Customer Service]
  Composer -->|getShipment| ShippingSvc[Shipping Service]
  OrderSvc --> ODB[(Orders DB)]
  CustomerSvc --> CDB[(Customers DB)]
  ShippingSvc --> SDB[(Shipping DB)]
  Composer -->|merged result| Client
The composer fans out to each owning service and joins the responses in memory

The composer below fetches the order, customer, and shipment in parallel, then stitches them into a single view object. The merge is an in-memory join keyed by the IDs the order already carries.

async function getOrderDetail(orderId: string) {
const order = await orderSvc.getOrder(orderId);
// The order tells us which customer and shipment to fetch.
const [customer, shipment] = await Promise.all([
customerSvc.getCustomer(order.customerId),
shippingSvc.getShipment(order.id),
]);
// In-memory join.
return {
orderId: order.id,
total: order.total,
customerName: customer.name,
customerTier: customer.tier,
deliveryStatus: shipment.status,
eta: shipment.eta,
};
}

What you gain:

  • Simplicity. No new storage, no event pipeline. If you can call the services, you can compose them. It is the obvious first choice for cross-service queries.
  • Always current. Because it reads live from each owner at request time, the result reflects the latest state — no replication lag.

What it costs you:

  • In-memory joins are inefficient. Joining large result sets in application memory is far slower than a database doing it with indexes. A query that filters or sorts across services (for example, “the 20 highest-value orders for gold-tier customers”) may force the composer to pull huge amounts of data from each service and discard most of it.
  • Availability is the product of its parts. The composer needs every called service to respond. If one is down or slow, the whole query suffers — unless you add timeouts and partial-result handling.
  • Reduced consistency. The pieces are read at slightly different moments, so the combined view can mix states that never existed together at one instant.

When the in-memory join becomes the bottleneck — large data, complex filtering, or strict latency targets — graduate to a maintained read model with CQRS, which pre-joins the data ahead of time.

  • Database per Service — the boundary that turns a join into a composition.
  • CQRS — the heavier alternative for queries too expensive to compose at request time.
BenefitTrade-off
No service changes needed — compose at the application layerEventual consistency — data from different services can be out of sync
Cross-service queries without a database joinAn error in one service can break the composite response
Each service keeps full control of its own dataAn in-memory join is slow if the dataset is large
Can be implemented at an API Gateway or a BFFPartial failure needs handling — some services answer, some don’t

Unbounded composition — composing too many services in one request. Symptoms:

  • One request fans out to 10+ services in parallel.
  • Total latency is high because the slowest service sets the pace.
  • Error rate climbs because the chance of failure grows with the service count.

Composition via a god orchestrator — an orchestrator service that knows every service’s business logic. Symptoms:

  • The orchestrator becomes a god service that knows too much.
  • Every new feature requires changing the orchestrator.

💡 Real-world examples

microservices.io (Chris Richardson): the canonical pattern documentation describes the API Composer as something that fans out to several services in parallel, then performs an in-memory join of the results (microservices.io: API Composition). A common real-world instance is an e-commerce product detail page that composes data from pricing, inventory, reviews, and recommendation services at the BFF or gateway layer, falling back to a cached value when one of them answers slowly.

What does an API Composer do?
Why should the composer issue its calls in parallel when they are independent?
What is the main drawback of API Composition for large or heavily filtered queries?