How do you handle a query that needs data from 5 different microservices — API composition vs. CQRS with a read model?
Fan out five parallel calls and merge them, or maintain a pre-joined read model built from events — the right choice depends on latency, consistency, and query shape.
LEARN BY DOINGPut your intuition to work3 steps
How do you handle a query that needs data from 5 different microservices — API composition vs. CQRS with a read model?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#The screen that needs everything
An "order details" page is a classic offender: it needs the order itself (order service), the customer's name and loyalty tier (customer service), whether the items are in stock (inventory service), whether payment cleared (payment service), and the shipping carrier and tracking number (shipping service). Five services, one screen. There are two fundamentally different ways to build the endpoint that serves it, and the trade-off between them is one of the most practical distributed-systems decisions you'll make.
#Option one: API composition
A composer — an API gateway, a backend-for-frontend, or a small dedicated aggregation service — calls all five services, typically in parallel, and merges the results into one response. This is the natural first instinct and it's often the right one: there's no new infrastructure, no new data store to keep in sync, and every field you return is read straight from its system of record, so the data is as fresh and consistent as each service can currently provide.
// order-composer.ts — API composition: fan out, then merge.async function getOrderDetails(orderId: string) {const [order, customer, inventory, payment, shipping] = await Promise.all([orderClient.getOrder(orderId),customerClient.getCustomerForOrder(orderId),inventoryClient.getReservation(orderId),paymentClient.getPaymentStatus(orderId),shippingClient.getShipment(orderId),]);return {orderId: order.id,items: order.items,customerName: customer.name,loyaltyTier: customer.loyaltyTier,itemsInStock: inventory.allReserved,paymentStatus: payment.status,carrier: shipping.carrier,trackingNumber: shipping.trackingNumber,};}
The cost shows up under load and under partial failure. The endpoint's latency is bounded by the slowest of the five parallel calls, plus the merge step — so your p99 is effectively the p99 of five dependencies combined, which is always worse than any single one of them. Availability compounds the same way: if each of the five services is 99.9% available independently, the composed endpoint's naive availability is roughly 99.5% (five nines-adjacent numbers multiplied together erode fast) unless you explicitly design for graceful degradation — returning partial data with a placeholder when, say, shipping is unreachable, rather than failing the whole request. And composition falls apart for anything beyond "fetch by ID": a query like "show me all gold-tier customers' orders where an item is out of stock" can't be pushed down across five independent services — you'd have to fetch broad candidate sets from each and join them in application memory, which does not scale.
#Option two: CQRS with a materialized read model
Command Query Responsibility Segregation splits the write side (each service owns its own data and enforces its own invariants, unchanged) from the read side, which gets its own purpose-built store. A projector subscribes to the domain events each of the five services already publishes — OrderCreated, PaymentCaptured, InventoryReserved, ShippingLabelCreated — and folds them into one denormalized row or document per order, built specifically to answer this query in a single read.
// order-summary-projector.ts// Listens to domain events from five services and maintains one// denormalized "order_summary" row per order — purpose-built for the// order-details read, so the query never fans out at request time.import { Pool } from "pg";import { KafkaConsumer } from "./kafkaClient";const pool = new Pool({ connectionString: process.env.READ_DB_URL });interface OrderCreatedEvent {orderId: string;customerId: string;itemCount: number;createdAt: string;}interface PaymentCapturedEvent {orderId: string;paymentStatus: "captured" | "failed";amountCents: number;}interface InventoryReservedEvent {orderId: string;}interface ShippingLabelCreatedEvent {orderId: string;carrier: string;trackingNumber: string;}async function upsertOrderSummary(orderId: string, patch: Record<string, unknown>): Promise<void> {const columns = Object.keys(patch);const values = Object.values(patch);const insertCols = ["order_id"].concat(columns).join(", ");const placeholders = columns.map((_, i) => "$" + (i + 2)).join(", ");const setClause = columns.map((c, i) => c + " = $" + (i + 2)).join(", ");const sql ="INSERT INTO order_summary (" + insertCols + ") " +"VALUES ($1, " + placeholders + ") " +"ON CONFLICT (order_id) DO UPDATE SET " + setClause;const params: unknown[] = [orderId, ...values];await pool.query(sql, params);}const consumer = new KafkaConsumer({groupId: "order-summary-projector",topics: ["order.created", "payment.captured", "inventory.reserved", "shipping.label-created"],});consumer.on("order.created", async (event: OrderCreatedEvent) => {await upsertOrderSummary(event.orderId, {customer_id: event.customerId,item_count: event.itemCount,created_at: event.createdAt,status: "created",});});consumer.on("payment.captured", async (event: PaymentCapturedEvent) => {await upsertOrderSummary(event.orderId, {payment_status: event.paymentStatus,amount_cents: event.amountCents,});});consumer.on("inventory.reserved", async (event: InventoryReservedEvent) => {await upsertOrderSummary(event.orderId, {inventory_status: "reserved",});});consumer.on("shipping.label-created", async (event: ShippingLabelCreatedEvent) => {await upsertOrderSummary(event.orderId, {shipping_carrier: event.carrier,tracking_number: event.trackingNumber,status: "shipped",});});
The query handler now reads from a single table, with a single index lookup, no fan-out at all:
// order-summary-query.tsapp.get("/orders/:orderId/summary", async (req, res) => {const result = await pool.query("SELECT * FROM order_summary WHERE order_id = $1",[req.params.orderId]);if (result.rows.length === 0) {return res.status(404).json({ error: "not found" });}res.json(result.rows[0]);});
This query is now fast, cheap, and — critically — supports arbitrary filtering, sorting, and pagination across all five original data sources, because it's just one indexed table (or a search index like Elasticsearch, if the query patterns need full-text or faceted search). The trade-off is eventual consistency: the read model lags behind the source services by however long it takes an event to be published, consumed, and applied — usually milliseconds to low seconds, but not zero. You've also taken on new infrastructure (the event bus, the projector, the read store) and a new failure mode: a projector bug or a schema change now means backfilling or rebuilding a materialized view, not just fixing a bug in a stateless request handler.
#How to actually choose
Reach for API composition when the query is simple, the number of sources is small, strong consistency matters more than raw read latency, and you'd rather not run another data store. Reach for CQRS with a read model when the query spans many sources with real filtering and pagination needs, read volume is high enough that fan-out latency and five-nines-multiplied availability actually hurt, and the business can tolerate results that are a few seconds stale. Plenty of real systems use both at once: composition for a low-traffic admin screen, a materialized view for the customer-facing page that gets hit on every checkout.
🧠 Check your understanding
0/3 · 0/3 answered1. Why does the availability of an API-composition endpoint tend to be worse than any single service it calls?
2. What must be true of the event source for a materialized read model to be safely rebuildable?
3. Which scenario most strongly favors CQRS with a materialized read model over API composition?