Skip to content
Unverified — AI-generated content. Help verify this page

API Composition Pattern

In a monolith, fetching a user's order history with product details and shipping status is a single SQL JOIN across three tables. In microservices, that same query requires calling the Order Service, the Product Catalog Service, and the Shipping Service — then merging the results. This is the API Composition pattern: a component that invokes multiple services and combines their responses into a single, unified result for the client.

The pattern is simple in concept and treacherous in practice. Partial failures, inconsistent data, N+1 query patterns, and latency amplification can turn a straightforward aggregation into a production nightmare.

Related: API Gateway Pattern | Communication Patterns | Service Mesh


The Problem

Problems with the naive approach (client calls each service directly):

ProblemImpact
Multiple round tripsClient makes 3+ HTTP calls; mobile on 3G suffers badly
Client-side logicEvery client (web, iOS, Android) must implement the same merging logic
Over-fetchingEach service returns its full response; client uses 20% of the fields
CouplingClient knows about internal service topology
Auth complexityEach service must validate tokens independently

The API Composition pattern solves this by placing an intermediary that handles aggregation.


Composition Approaches

1. API Gateway Composition

The API gateway handles aggregation as part of its routing logic.

typescript
class OrderComposer {
  async getOrderDetails(orderId: string): Promise<ComposedOrderResponse> {
    // 1. Fetch order (must come first — we need product IDs)
    const order = await this.orderService.getOrder(orderId);

    // 2. Fetch product details and shipping status in parallel
    const [products, shipping] = await Promise.all([
      this.productService.getProducts(order.productIds),
      this.shippingService.getShipment(order.shipmentId),
    ]);

    // 3. Compose the response
    return {
      orderId: order.id,
      status: order.status,
      createdAt: order.createdAt,
      items: order.items.map(item => ({
        ...item,
        product: products.find(p => p.id === item.productId),
      })),
      shipping: {
        carrier: shipping.carrier,
        trackingNumber: shipping.trackingNumber,
        estimatedDelivery: shipping.eta,
        currentStatus: shipping.status,
      },
      total: order.total,
    };
  }
}

2. Backend for Frontend (BFF)

A dedicated backend for each client type (web, mobile, TV). Each BFF tailors the aggregated response to its client's needs.

typescript
// Web BFF: returns full detail including review counts
class WebOrderBFF {
  async getOrderPage(orderId: string): Promise<WebOrderResponse> {
    const [order, products, reviews] = await Promise.all([
      this.orderService.getOrder(orderId),
      this.productService.getProducts(/* ids */),
      this.reviewService.getReviewCounts(/* ids */),
    ]);

    return {
      ...this.composeOrder(order, products),
      reviews: reviews, // Web shows review counts
      relatedProducts: await this.recService.getRelated(/* ids */),
    };
  }
}

// Mobile BFF: returns minimal data for bandwidth
class MobileOrderBFF {
  async getOrderSummary(orderId: string): Promise<MobileOrderResponse> {
    const order = await this.orderService.getOrder(orderId);

    return {
      orderId: order.id,
      status: order.status,
      itemCount: order.items.length,
      total: order.total,
      // No reviews, no related products — mobile doesn't show these
    };
  }
}

When to Use BFF vs. Shared Gateway

  • BFF: When different clients need significantly different data shapes, response sizes, or aggregation patterns
  • Shared gateway: When clients are similar and a single composed response works for all
  • Rule of thumb: If your mobile response is < 50% of the fields in your web response, use a BFF

3. GraphQL Composition

GraphQL naturally solves API composition: the client specifies exactly what it needs, and the GraphQL server resolves each field from the appropriate service.

graphql
# Client query — ask for exactly what you need
query OrderDetails($orderId: ID!) {
  order(id: $orderId) {
    id
    status
    createdAt
    items {
      quantity
      product {
        name
        price
        imageUrl
      }
    }
    shipping {
      carrier
      trackingNumber
      estimatedDelivery
    }
  }
}
typescript
// GraphQL resolvers — each field can call a different service
const resolvers = {
  Query: {
    order: async (_, { id }) => {
      return orderService.getOrder(id);
    },
  },
  Order: {
    items: async (order) => {
      return orderService.getOrderItems(order.id);
    },
    shipping: async (order) => {
      if (!order.shipmentId) return null;
      return shippingService.getShipment(order.shipmentId);
    },
  },
  OrderItem: {
    product: async (item) => {
      return productService.getProduct(item.productId);
    },
  },
};

GraphQL Federation (Apollo Federation)

For large organizations, each team owns their service's GraphQL subgraph. A gateway composes them automatically.

graphql
# Orders subgraph — extends Product type from Products subgraph
type Order @key(fields: "id") {
  id: ID!
  items: [OrderItem!]!
}

type OrderItem {
  productId: ID!
  product: Product! # Resolved by Products subgraph
  quantity: Int!
}

extend type Product @key(fields: "id") {
  id: ID! @external
}

Error Handling

Partial failures are the defining challenge of API composition. If the Product Service is down, should the entire order page fail?

Strategies

StrategyBehaviorUse Case
Fail fastReturn error if any service failsCritical data (payment info)
Partial responseReturn available data, mark failuresNon-critical enrichment
FallbackReturn cached/default data on failureProduct images, recommendations
Circuit breakerStop calling failed serviceProtect against cascading failure
typescript
class ResilientComposer {
  async getOrderDetails(orderId: string): Promise<ComposedResponse> {
    const order = await this.orderService.getOrder(orderId);
    // Order is critical — if it fails, fail the whole request
    if (!order) throw new Error('Order not found');

    // Product and shipping are enrichment — use partial response
    const results = await Promise.allSettled([
      this.productService.getProducts(order.productIds),
      this.shippingService.getShipment(order.shipmentId),
    ]);

    const products = results[0].status === 'fulfilled'
      ? results[0].value
      : []; // Empty products — client shows "Product details unavailable"

    const shipping = results[1].status === 'fulfilled'
      ? results[1].value
      : null; // Null shipping — client shows "Tracking unavailable"

    return {
      order,
      products,
      shipping,
      _partial: results.some(r => r.status === 'rejected'),
      _errors: results
        .filter(r => r.status === 'rejected')
        .map(r => (r as PromiseRejectedResult).reason.message),
    };
  }
}

The Partial Response Contract

If you return partial responses, your API contract must declare it. Clients need to know which fields might be missing and how to render a degraded experience. Use a _partial flag or typed error fields — never silently omit data.


Performance Optimization

The N+1 Problem

typescript
// BAD: N+1 — fetches each product individually
async function getOrderWithProducts(orderId: string) {
  const order = await orderService.getOrder(orderId);
  const items = await Promise.all(
    order.items.map(item =>
      productService.getProduct(item.productId) // N calls!
    )
  );
  return { ...order, items };
}

// GOOD: Batch — single call with all product IDs
async function getOrderWithProducts(orderId: string) {
  const order = await orderService.getOrder(orderId);
  const productIds = order.items.map(i => i.productId);
  const products = await productService.getProducts(productIds); // 1 call
  return {
    ...order,
    items: order.items.map(item => ({
      ...item,
      product: products.find(p => p.id === item.productId),
    })),
  };
}

DataLoader Pattern

For GraphQL resolvers, use DataLoader to batch and deduplicate requests within a single request lifecycle.

typescript
import DataLoader from 'dataloader';

// Create per-request DataLoader instances
function createLoaders() {
  return {
    product: new DataLoader<string, Product>(async (ids) => {
      // Single batch call for all requested product IDs
      const products = await productService.getProducts([...ids]);
      // Return in same order as input IDs
      return ids.map(id => products.find(p => p.id === id) ?? null);
    }),
  };
}

// In resolver:
const resolvers = {
  OrderItem: {
    product: (item, _, { loaders }) => {
      return loaders.product.load(item.productId);
      // DataLoader batches all .load() calls in the same tick
    },
  },
};

Parallel vs. Sequential Calls

typescript
// SLOW: Sequential (total time = sum of all calls)
const order = await orderService.getOrder(id);       // 50ms
const products = await productService.getProducts(ids); // 80ms
const shipping = await shippingService.getShipment(sid); // 60ms
// Total: 190ms

// FAST: Parallel where possible (total time = max of parallel calls)
const order = await orderService.getOrder(id);            // 50ms
const [products, shipping] = await Promise.all([           // max(80, 60) = 80ms
  productService.getProducts(order.productIds),
  shippingService.getShipment(order.shipmentId),
]);
// Total: 130ms (32% faster)

Caching Composed Responses

typescript
class CachedComposer {
  private cache: Redis;
  private readonly CACHE_TTL = 60; // seconds

  async getOrderDetails(orderId: string): Promise<ComposedResponse> {
    const cacheKey = `composed:order:${orderId}`;
    const cached = await this.cache.get(cacheKey);
    if (cached) return JSON.parse(cached);

    const result = await this.compose(orderId);

    // Only cache if not partial (don't cache degraded responses)
    if (!result._partial) {
      await this.cache.setex(cacheKey, this.CACHE_TTL, JSON.stringify(result));
    }

    return result;
  }
}

When to Use Which Approach

ScenarioRecommended Approach
Simple aggregation, few servicesAPI Gateway composition
Different clients need different dataBFF per client
Complex, nested data graphsGraphQL with DataLoader
Team autonomy at scaleGraphQL Federation
Performance-critical, high volumePre-composed views (CQRS)
Occasional ad-hoc queriesDirect service calls with client-side merge

CQRS as an Alternative

For read-heavy pages that aggregate many services, consider maintaining a pre-composed read model via events (CQRS pattern). Instead of querying 5 services on every request, each service publishes events, and a denormalized view is built and stored. The read is a single database query. See CQRS Deep Dive.


Anti-Patterns

Anti-PatternProblemSolution
Chatty composition50+ downstream calls for one requestBatch endpoints, DataLoader
Synchronous waterfallSequential calls that could be parallelPromise.all for independent calls
No timeoutOne slow service blocks entire responsePer-service timeouts + circuit breaker
God composerSingle composer knows about all servicesSeparate composers per domain
Missing partial responseEntire page fails if reviews service is downPromise.allSettled + fallbacks
Cache everythingStale data, inconsistent viewsCache only non-critical enrichment

Summary

AspectDetail
PatternAggregate data from multiple microservices into a single response
ApproachesGateway composition, BFF, GraphQL, Federation
Key challengePartial failures and latency amplification
Error handlingPromise.allSettled + fallback for non-critical data
PerformanceParallel calls, batch endpoints, DataLoader, caching
N+1 preventionBatch APIs (getProducts([id1, id2, ...]))
AlternativeCQRS — pre-composed read models via events

Related: API Gateway Pattern | CQRS Deep Dive | Communication Patterns

"What I cannot create, I do not understand." — Richard Feynman