What joining data across microservices means, and why a shared database is the wrong fix

In a microservice architecture, each service owns its data. The orders service holds orders, the customer service holds customer profiles, and the catalog service holds product details. No other service reads those tables directly. Many useful queries still need fields from all three. An "order history" screen, for example, needs order totals, the customer's name, and product titles together. In a monolith that is a single SQL join. Across services there is no shared schema to join against, and that gap is what joining data across microservices means in practice.

The tempting fix is to point every service at one shared database and join across their tables. This brings back the coupling that splitting the system was meant to remove:

  • Schema coupling. If the catalog team renames a column, a query owned by the orders team breaks.
  • Deployment coupling. Migrations have to be coordinated across teams, so independent releases stop being independent.
  • Scaling coupling. One team's heavy reporting query competes for the same connections and I/O as another team's checkout path.

Data ownership also breaks. Once other services read a table directly, its owner can no longer change it safely.

The real decision is where and when the join happens. It can happen at request time, by calling services and combining their responses. It can also happen ahead of time, by maintaining a precomputed view. Write the problem down explicitly, including which fields, which services, and what freshness the query needs, before you pick a pattern. If nearly every query needs a cross-service join, your service boundaries may be drawn in the wrong place.

Who should use these patterns

These patterns are for teams that already run, or are committed to running, services that each own their data store. If you have a monolith with one database, a SQL join is simpler, faster and more correct than either pattern. Do not adopt them in advance of an actual split.

Within a microservice system, the right fit depends on the team and the query:

  • API composition suits teams that need a cross-service view soon, have a small number of well-behaved dependencies, and do not yet run event infrastructure. It is also the default for any query that must reflect the latest state, such as a detail page a user opens right after making a change.
  • Read models suit teams that already publish domain events reliably, or are prepared to build that pipeline, and that serve list, search or reporting screens at volume. The team must be able to operate a broker, consumers and rebuilds on call, and the product must accept a stated staleness window.
  • Neither suits a small team whose "services" are always deployed together and always queried together. That team will usually get more from merging the services than from either pattern.

Platform or architecture teams that set conventions for many product teams should treat this as a per-query decision and provide shared building blocks for both: a composition library with timeouts and circuit breakers, and an outbox-plus-projector template.

How does it work? An overview of both approaches

Both approaches answer the same question, "where does the join run?", with different answers:

  1. API composition joins at read time. A request arrives at a composer. The composer calls each owning service, preferably in parallel and with batch fetch-by-IDs endpoints, waits for the responses, and merges them in memory before responding. Nothing is stored outside the owning services, so data is as fresh as the slowest call. The cost is that every read depends on every service involved being up and fast.
  2. A CQRS read model joins at write time. Each owning service publishes an event whenever its data changes. A projector in the consuming service listens to those events and keeps a denormalized table up to date, with the joined columns already in place. A read is then one local query against that table. The cost is staleness by the event lag, plus the pipeline that must deliver every event reliably.

The sections below describe each mechanism in detail, followed by where each one breaks and how to choose.

How API composition works

In API composition, a composer receives the query, calls each owning service, and joins the results in memory. The composer can be an API gateway, a backend-for-frontend (BFF), or the domain service that owns the query. Each service stays the authority for its own data, and nothing is copied.

Call shape matters more than call count. Independent lookups should run in parallel, so total latency tracks the slowest call rather than the sum of all calls. Dependent lookups, such as fetching orders and then their customers, have to run sequentially. Keep that chain short. Never fetch related records one ID at a time, because that recreates the N+1 problem over the network. Each service should expose a batch fetch-by-IDs endpoint, so one round trip resolves the whole set.

Decide failure semantics before writing code. Give every downstream call its own timeout, set below the composer's overall budget. Put a circuit breaker on each call so a struggling service fails fast instead of tying up connections. For each field, choose up front whether a missing value is acceptable or fatal. For example, a missing shipping estimate might render as "unavailable," while missing pricing should fail the request. If you leave this decision implicit, outages show up as silently wrong responses.

Placement has tradeoffs. Gateway composition tends to collect domain logic until the gateway becomes a god-service that every team must change. Composition inside domain services hides synchronous dependency chains, so one slow service stalls callers several hops away. A BFF scoped to one client is usually the least risky place for it.

// Order-summary composer: one dependent hop, then parallel batched calls.
// In production, wrap getJson in a per-service circuit breaker so a failing
// dependency is rejected immediately instead of waiting out its timeout.

// Base addresses come from deployment configuration.
const ORDERS_URL = process.env.ORDERS_URL!;
const PRICING_URL = process.env.PRICING_URL!;
const SHIPPING_URL = process.env.SHIPPING_URL!;

async function getJson<T>(url: string, timeoutMs: number): Promise<T> {
  // Per-call timeout, set below the composer's overall latency budget.
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
  if (!res.ok) throw new Error(`${url} responded ${res.status}`);
  return (await res.json()) as T;
}

type Order = { id: string; customerId: string };
type ById<T> = Record<string, T>;

export async function orderSummaries(userId: string) {
  // Dependent lookup: the order IDs are needed before anything else can run.
  const orders = await getJson<Order[]>(
    `${ORDERS_URL}/orders?userId=${encodeURIComponent(userId)}`, 300);
  if (orders.length === 0) return [];
  const ids = encodeURIComponent(orders.map((o) => o.id).join(","));

  // Independent lookups run in parallel. Each one is a single batch
  // fetch-by-IDs call, which avoids N+1 requests over the network.
  const [pricing, shipping] = await Promise.allSettled([
    getJson<ById<number>>(`${PRICING_URL}/prices?orderIds=${ids}`, 250),
    getJson<ById<string>>(`${SHIPPING_URL}/estimates?orderIds=${ids}`, 250),
  ]);

  // Partial-response policy, decided up front:
  // pricing is required (fail the request), shipping is optional (degrade).
  if (pricing.status === "rejected") {
    throw new Error("pricing unavailable", { cause: pricing.reason });
  }
  const estimates = shipping.status === "fulfilled" ? shipping.value : null;

  return orders.map((o) => ({
    id: o.id,
    price: pricing.value[o.id],
    shippingEstimate: estimates?.[o.id] ?? "unavailable",
    degraded: estimates === null, // tell the client the response is partial
  }));
}

A TypeScript composer makes one sequential call to get order IDs, then runs parallel batch fetch-by-IDs calls with per-call timeouts, and applies an explicit policy: missing pricing fails the request, while missing shipping estimates return a degraded response.

Why latency and availability compound in composition

Even a well-scoped composer is bound by its dependencies. If calls run in parallel, the response is as slow as the slowest one. If they run sequentially because one call needs another's output, the latencies add up. Tail latency gets worse with fan-out. Illustrative estimate: if each of 10 services independently exceeds its p99 on 1% of requests, then 1 − 0.99¹⁰ ≈ 9.6% of composed responses wait on at least one slow call.

Availability compounds the same way. Each dependency is a separate way for the request to fail.

Illustrative estimate: combined availability

This assumes every service is 99.9% available, failures are independent, and there are no fallbacks or cached partial responses. The month is 30 days (43,200 minutes).

Services in fan-out Combined availability Expected failed-request time per month
1 99.9% ~43 min
3 ~99.7% ~2.2 h
5 ~99.5% ~3.6 h
10 ~99.0% ~7.2 h

Timeouts, fallbacks, and partial responses reduce this effect. They also add design work for every endpoint.

Where composition breaks: cross-service queries

Take a query that filters on order status, owned by the Orders service, and sorts by customer name, owned by the Customers service. Neither service can return the correct page 3. The composer has to fetch every matching order and resolve every customer. It then sorts in memory and slices out the page. The cost grows with the size of the full result set instead of the page size. The pages can also shift between requests as the underlying data changes. When product asks for that screen, teams usually decide to build a read model.

How CQRS read models work

The alternative is to stop joining at request time and build the joined view ahead of time. That is the read-model side of CQRS (Command Query Responsibility Segregation).

The service that serves the screen subscribes to domain events published by the services that own the data, such as OrderPlaced, CustomerRenamed and ShipmentDelivered. A projector consumes each event and updates a denormalized table in the consuming service's own store. That table has one row per thing the screen lists, with the columns already joined, so filtering, sorting and pagination run as ordinary local queries.

Request-time composition:
  client ──► API ──► Orders ─┐
                 ├─► Customers ├─► merge in memory ──► response
                 └─► Shipping ─┘

Event-fed projection:
  Orders ────┐
  Customers ─┼─► events ──► projector ──► read model ──► API ──► client
  Shipping ──┘          (async, ahead of time)        (one local query)

The read model is shaped by the query, not by the owners' write schemas. When Customers refactors its tables, nothing changes for consumers as long as the event contract holds.

This is deliberate data duplication, and it needs discipline to stay safe:

  • Copy only the fields the query needs. Every extra column widens the event contract you depend on and adds more data that can go stale.
  • Keep one owner. Customers remains the only service that can change a customer's name. The projection never accepts writes to that field, and it never serves as the basis for another service's decisions.
  • Treat it as a cache with a stated staleness bound. If events normally reach the projection within a few seconds, document that limit. Then design the UI and any downstream logic to tolerate data that is a few seconds old.
A. REQUEST-TIME COMPOSITION: join runs on every read (sync)
                                                  ┌───────────┐
                                        ┌────────►│ Orders    │
                                        │ sync    └───────────┘
 ┌────────┐      ┌────────────────────┐ │         ┌───────────┐
 │ Client │◄────►│ Screen API         │─┼────────►│ Customers │
 └────────┘      │ fans out, waits,   │ │         └───────────┘
                 │ merges in memory   │ │         ┌───────────┐
                 └────────────────────┘ └────────►│ Shipping  │
                                                  └───────────┘

B. EVENT-FED PROJECTION: join is built ahead of time (async)
 ┌───────────┐                    ┌───────────┐
 │ Orders    │─OrderPlaced───────►│ Projector │
 └───────────┘                    │ upserts   │   ┌────────────────────┐
 ┌───────────┐                    │ only the  │   │ Read model         │
 │ Customers │─CustomerRenamed───►│ fields    │──►│ (own store)        │
 └───────────┘                    │ needed    │   │ pre-joined rows    │
 ┌───────────┐                    │           │   └─────────┬──────────┘
 │ Shipping  │─ShipmentDelivered─►│           │             │ local query
 └───────────┘                    └───────────┘             ▼
 ┌────────┐                                       ┌────────────────────┐
 │ Client │◄──────────── no fan-out ─────────────►│ Screen API         │
 └────────┘                                       └────────────────────┘

Owners keep all writes; the read model is a cache, stale by the event lag.

Request-time composition fans out to every owning service on each read, while the event-fed projection builds the joined rows ahead of time so the screen is served by one local query, at the cost of a bounded staleness window.

Implementing read models correctly: outbox, idempotency, ordering and rebuilds

A projection is only as trustworthy as the pipeline feeding it, and most read-model bugs come from four gaps.

Writes and events must not diverge. If a service commits to its database and then publishes to a broker, a crash between the two steps loses the event, and the read model silently misses a change. The transactional outbox closes that gap. Insert the event into an outbox table in the same transaction as the domain write. A separate relay reads committed outbox rows and publishes them. Either both the change and the event persist or neither does.

Assume at-least-once delivery. The relay will sometimes republish, and consumers will sometimes reprocess after a restart. Handlers must be idempotent. The usual approach is to carry a per-entity version or sequence number in each event and store the last applied version alongside the projected row. The handler then applies an event only if its version is newer, which discards both duplicates and stale, out-of-order arrivals. If gaps matter, require exactly the next version and park anything further ahead.

Plan for rebuilds from day one. A new projection, or a change to projection logic, needs historical data. That means either a retained event log you can replay from the start, or a consistent snapshot plus the events after its position. Build into a fresh table, then switch reads over atomically.

Detect drift. Run periodic reconciliation jobs that compare projected counts or checksums against the source, and alert on mismatches.

-- PostgreSQL. Write side: domain change and outbox row commit together or not at all.
CREATE TABLE outbox (
  id           bigserial PRIMARY KEY,
  aggregate_id text        NOT NULL,
  event_type   text        NOT NULL,
  version      bigint      NOT NULL,  -- per-entity version consumers use for ordering
  payload      jsonb       NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

BEGIN;
-- Bump the entity version and record the event in the same transaction.
-- A separate relay later reads committed outbox rows and publishes them.
WITH updated AS (
  UPDATE orders
     SET status = 'shipped', version = version + 1
   WHERE id = 'ord_8f2c1'
  RETURNING id, status, version
)
INSERT INTO outbox (aggregate_id, event_type, version, payload)
SELECT id, 'OrderShipped', version,
       jsonb_build_object('order_id', id, 'status', status, 'version', version)
  FROM updated;
COMMIT;

-- Read side: projection handler, run once per delivered event.
-- $1 = order_id, $2 = status, $3 = event version (bound by the consumer).
CREATE TABLE order_summary (
  order_id     text   PRIMARY KEY,
  status       text   NOT NULL,
  last_version bigint NOT NULL  -- last applied version, stored with the row
);

INSERT INTO order_summary (order_id, status, last_version)
VALUES ($1, $2, $3)
ON CONFLICT (order_id) DO UPDATE
   SET status = EXCLUDED.status,
       last_version = EXCLUDED.last_version
 -- Duplicates and stale, out-of-order events match no row and become no-ops.
 WHERE order_summary.last_version < EXCLUDED.last_version;

A PostgreSQL write that commits the domain update and its outbox event atomically, plus an idempotent projection upsert that ignores duplicate or stale events by comparing per-entity version numbers.

Choosing per query: when to use each approach and when to avoid it

Make the choice per query, not per system. Most systems end up using both patterns.

Dimension API composition CQRS read model
Freshness Current at request time Lags by pipeline delay
Latency Slowest downstream call plus merge time Single local query
Availability coupling Fails or degrades with any dependency Survives source outages, serving stale data
Query flexibility Poor for cross-service filter, sort, paging Indexed for exactly the query you need
Consistency Each part current, but parts are read at slightly different moments Eventually consistent
Operational surface Timeouts, retries, partial-response handling Broker, outbox, consumers, rebuilds, reconciliation

Use composition when

The query is low-volume, returns one entity, or must reflect the latest state. Order detail pages and admin lookups fit this. With a bounded set of IDs, fan-out stays small.

Avoid it when fan-out goes deep. Each extra call adds to tail latency and multiplies failure probability. Also avoid it when the endpoint needs higher availability than its least reliable dependency can provide.

Use read models when

Queries list, search, filter, or sort across service boundaries at volume. Composition cannot paginate a join without fetching too much data, because no single service holds the sort key.

Avoid them when users must see their own writes immediately and the UI cannot hide the lag. Also avoid them when the team is too small to run the pipeline on call.

Consider the boundary itself

If two services are always joined, always deployed together, and owned by one team, that boundary may be wrong. Merging them removes the join entirely. That is often cheaper than maintaining either pattern.

What are the alternatives?

API composition and read models are the two main patterns, but they are not the only options. Each alternative below fits a narrower set of queries:

  • Merge the services. As noted above, if a join is needed on almost every request and both sides change together, the cleanest fix is often to move both datasets into one service. The join becomes a local SQL join again, with full transactional consistency.
  • Shared database, read-only views. Some teams keep separate databases but grant one service read access to a narrow, explicitly published view in another. This is cheaper than an event pipeline, but it is still schema coupling: the view becomes a contract that its owner cannot change freely. Treat it as a transitional step, not a target.
  • Client-side composition. The frontend calls each service itself and assembles the screen. This removes the composer but moves fan-out, failure handling and latency onto the client and its network, and it exposes more internal APIs. It can work for independent panels on a page that load and fail separately.
  • A query layer that federates schemas. A GraphQL layer that stitches or federates schemas from several services is a form of API composition with a declarative query language. It improves developer ergonomics and batching, but the latency, availability and cross-service sorting limits described above still apply.
  • Change data capture into a shared read store. Instead of publishing domain events from application code, the owners' database change logs are streamed into a separate store, such as a search index or analytical database, where joins and filters run. This is a variant of the read-model approach. It avoids writing outbox code, but it couples consumers to the owners' internal table layout rather than to a deliberate event contract.
  • Data warehouse for reporting. Cross-service reports that tolerate hours of lag, such as finance or analytics dashboards, usually belong in a warehouse fed by batch or streaming pipelines. They should not drive the design of the operational read path.
  • Embed reference data in events or entities. When a service needs a small, slowly changing field from another, such as a product title at the moment of purchase, it can store that value with its own record when the record is created. This is often correct as a business rule (an order should show the title as it was when bought) and removes the join without any ongoing synchronization.

Impact on development cost and engineering time

Neither pattern is free. The costs just show up in different places.

What each approach costs to run

Composition keeps the infrastructure small, but you pay in resilience work: timeouts, retries with budgets, circuit breakers, and fallbacks for every downstream call. You also pay in coordination. When a source service changes a response shape, every composer that depends on it has to change in step, so releases get planned across teams.

Read models move that cost into infrastructure you now own: a broker, consumers, lag monitoring, dead-letter queues and someone who triages them, storage for each projection, and reconciliation jobs that detect drift. Event schemas also become a public contract. Consumers you never coordinated with depend on field names and meanings, so schemas need versioning and deprecation windows. That brings back some of the coupling the events were meant to remove.

Duplication also adds a compliance cost. Every projection holding personal data has to apply corrections and honor erasure requests. Each new read model is one more store to find, update and audit.

Why choosing per query saves time

Deciding per query keeps you from building event infrastructure for screens that two fast calls would serve. It also lets you spot composition paths that will become too slow before they ship, rather than reworking them under load. Most importantly, it makes bad boundaries visible. Neither pattern fixes a split that should not exist. Both just make it more expensive to keep.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to joining data across microservices, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.