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