API Composition: Aggregating Microservice Data

Microservices distribute data across service boundaries by design. Your order service knows about orders, your user service knows about users, and your inventory service knows about stock levels....

Key Insights

  • API composition centralizes the complexity of aggregating data from multiple microservices, but you must design for partial failures and implement aggressive timeout management to prevent cascade failures.
  • Parallel execution with dependency graphs outperforms naive sequential calls, but requires careful orchestration—use DataLoader patterns to batch requests and eliminate N+1 problems across service boundaries.
  • GraphQL provides natural composition semantics, but REST-based composers remain simpler to operate and debug when you have straightforward aggregation needs without deep nesting.

The Data Aggregation Challenge

Microservices distribute data across service boundaries by design. Your order service knows about orders, your user service knows about users, and your inventory service knows about stock levels. When a client needs to display an order with user details and current stock status, someone has to combine that data.

The naive approach—having clients call each service directly—creates several problems. Mobile clients make multiple round trips over high-latency connections. Every client duplicates aggregation logic. Clients couple directly to internal service topology, making refactoring painful. You expose internal services to the internet, expanding your attack surface.

API composition solves this by placing an intermediary that handles the aggregation. But it’s not always the right choice. Consider alternatives first:

CQRS with materialized views works better when you need the same aggregated data repeatedly with low latency. Pre-compute and store the combined view.

Event-driven data replication fits when services need local copies of data they frequently join. Accept eventual consistency for query performance.

API composition shines when aggregation patterns vary, data freshness matters, or you can’t justify the complexity of maintaining synchronized views.

API Composition Pattern Fundamentals

An API composer is a service that receives a client request, fans out to multiple downstream services, combines the responses, and returns a unified result. The pattern is straightforward, but placement decisions matter.

API Gateway composition works for simple aggregations but risks bloating your gateway with business logic. Gateways should route, not transform.

Backend for Frontend (BFF) places composition logic in a client-specific layer. Good when different clients need different aggregations of the same underlying data.

Dedicated aggregation service makes sense for complex domain-specific composition that multiple clients share.

Here’s a basic composer structure:

// order-composer.service.ts
interface ComposedOrder {
  order: Order;
  customer: Customer;
  items: EnrichedOrderItem[];
}

class OrderComposerService {
  constructor(
    private orderService: OrderServiceClient,
    private customerService: CustomerServiceClient,
    private productService: ProductServiceClient
  ) {}

  async getComposedOrder(orderId: string): Promise<ComposedOrder> {
    // Fetch order first (we need it to know which customer and products)
    const order = await this.orderService.getOrder(orderId);
    
    // Fetch customer and product details in parallel
    const [customer, products] = await Promise.all([
      this.customerService.getCustomer(order.customerId),
      this.productService.getProducts(order.items.map(i => i.productId))
    ]);

    // Build product lookup map
    const productMap = new Map(products.map(p => [p.id, p]));

    // Compose the response
    return {
      order,
      customer,
      items: order.items.map(item => ({
        ...item,
        product: productMap.get(item.productId)
      }))
    };
  }
}

The trade-off is clear: you add a network hop and a failure point. The composer’s latency is at least as high as your slowest downstream call. But you gain a clean client interface and centralized aggregation logic.

Implementation Strategies

Execution strategy significantly impacts performance. Sequential calls are simple but slow. Parallel calls are faster but require independence between requests.

Most real compositions have partial dependencies: you need data from call A to make call B, but call C is independent. Model this as a dependency graph and execute with maximum parallelism:

// Dependency-aware parallel execution
class CompositionExecutor {
  async execute<T>(tasks: CompositionTask<T>[]): Promise<Map<string, T>> {
    const results = new Map<string, T>();
    const pending = new Map(tasks.map(t => [t.id, t]));
    
    while (pending.size > 0) {
      // Find tasks whose dependencies are satisfied
      const ready = [...pending.values()].filter(task =>
        task.dependencies.every(dep => results.has(dep))
      );
      
      if (ready.length === 0 && pending.size > 0) {
        throw new Error('Circular dependency detected');
      }
      
      // Execute ready tasks in parallel
      const executions = ready.map(async task => {
        const depResults = Object.fromEntries(
          task.dependencies.map(dep => [dep, results.get(dep)])
        );
        const result = await task.execute(depResults);
        return { id: task.id, result };
      });
      
      const completed = await Promise.all(executions);
      
      for (const { id, result } of completed) {
        results.set(id, result);
        pending.delete(id);
      }
    }
    
    return results;
  }
}

// Usage
const tasks: CompositionTask<unknown>[] = [
  {
    id: 'order',
    dependencies: [],
    execute: async () => orderService.getOrder(orderId)
  },
  {
    id: 'customer',
    dependencies: ['order'],
    execute: async (deps) => customerService.getCustomer(deps.order.customerId)
  },
  {
    id: 'inventory',
    dependencies: ['order'],
    execute: async (deps) => inventoryService.getStock(deps.order.items.map(i => i.productId))
  }
];

This approach maximizes parallelism while respecting data dependencies. The customer and inventory calls execute simultaneously once the order data is available.

Error Handling and Resilience

Composers aggregate failures as well as data. When one downstream service fails, you have two choices: fail the entire request or return partial data. The right choice depends on your domain.

For an e-commerce order page, missing inventory status is acceptable—show the order without stock levels. For a financial dashboard, partial data might be misleading—fail fast.

Implement circuit breakers for each downstream service independently:

import CircuitBreaker from 'opossum';

class ResilientOrderComposer {
  private customerBreaker: CircuitBreaker;
  private inventoryBreaker: CircuitBreaker;

  constructor(
    private orderService: OrderServiceClient,
    private customerService: CustomerServiceClient,
    private inventoryService: InventoryServiceClient
  ) {
    const breakerOptions = {
      timeout: 3000,
      errorThresholdPercentage: 50,
      resetTimeout: 30000
    };

    this.customerBreaker = new CircuitBreaker(
      (id: string) => this.customerService.getCustomer(id),
      breakerOptions
    );

    this.inventoryBreaker = new CircuitBreaker(
      (ids: string[]) => this.inventoryService.getStock(ids),
      breakerOptions
    );
  }

  async getComposedOrder(orderId: string): Promise<PartialComposedOrder> {
    const order = await this.orderService.getOrder(orderId);
    
    // Use Promise.allSettled for graceful degradation
    const [customerResult, inventoryResult] = await Promise.allSettled([
      this.customerBreaker.fire(order.customerId),
      this.inventoryBreaker.fire(order.items.map(i => i.productId))
    ]);

    return {
      order,
      customer: customerResult.status === 'fulfilled' 
        ? customerResult.value 
        : null,
      inventory: inventoryResult.status === 'fulfilled'
        ? inventoryResult.value
        : null,
      _partial: customerResult.status === 'rejected' || 
                inventoryResult.status === 'rejected'
    };
  }
}

The _partial flag tells clients the response is incomplete. Clients can display appropriate UI or retry later.

Performance Optimization

Composition layers are prone to N+1 problems. Fetching 50 orders, then fetching customer details for each order individually, means 51 service calls. The DataLoader pattern batches these lookups:

import DataLoader from 'dataloader';

class BatchingOrderComposer {
  private customerLoader: DataLoader<string, Customer>;
  private productLoader: DataLoader<string, Product>;

  constructor(
    private customerService: CustomerServiceClient,
    private productService: ProductServiceClient
  ) {
    // Batch customer lookups within a single tick
    this.customerLoader = new DataLoader(async (customerIds) => {
      const customers = await this.customerService.getCustomersBatch([...customerIds]);
      const customerMap = new Map(customers.map(c => [c.id, c]));
      return customerIds.map(id => customerMap.get(id) ?? new Error(`Customer ${id} not found`));
    });

    this.productLoader = new DataLoader(async (productIds) => {
      const products = await this.productService.getProductsBatch([...productIds]);
      const productMap = new Map(products.map(p => [p.id, p]));
      return productIds.map(id => productMap.get(id) ?? new Error(`Product ${id} not found`));
    });
  }

  async enrichOrders(orders: Order[]): Promise<EnrichedOrder[]> {
    return Promise.all(orders.map(async order => ({
      ...order,
      customer: await this.customerLoader.load(order.customerId),
      items: await Promise.all(order.items.map(async item => ({
        ...item,
        product: await this.productLoader.load(item.productId)
      })))
    })));
  }
}

DataLoader collects all .load() calls within a single event loop tick and executes one batch request. If 50 orders reference 30 unique customers, you make one call for 30 customers instead of 50 individual calls.

Add caching at the composition layer for data that changes infrequently. Product catalogs and customer profiles are good candidates. Order status and inventory levels are not.

GraphQL as a Composition Layer

GraphQL’s resolver architecture naturally implements composition. Each field can fetch from a different source, and the execution engine handles parallelism:

// GraphQL resolvers composing REST microservices
const resolvers = {
  Query: {
    order: async (_, { id }, { dataSources }) => {
      return dataSources.orderAPI.getOrder(id);
    }
  },
  
  Order: {
    customer: async (order, _, { dataSources }) => {
      return dataSources.customerAPI.getCustomer(order.customerId);
    },
    
    items: async (order, _, { dataSources }) => {
      return order.items.map(item => ({
        ...item,
        productId: item.productId
      }));
    }
  },
  
  OrderItem: {
    product: async (item, _, { dataSources }) => {
      return dataSources.productAPI.getProduct(item.productId);
    },
    
    stockLevel: async (item, _, { dataSources }) => {
      return dataSources.inventoryAPI.getStock(item.productId);
    }
  }
};

GraphQL Federation takes this further by letting each microservice define its own schema portion. The gateway composes schemas and routes queries to appropriate services.

However, REST composition remains simpler when:

  • You have fixed, well-known aggregation patterns
  • Clients don’t need field-level selection
  • Your team lacks GraphQL operational experience
  • You want simpler debugging and tracing

Operational Considerations

Distributed tracing is essential for debugging composition performance. Instrument each downstream call as a child span:

import { trace, SpanKind } from '@opentelemetry/api';

class InstrumentedComposer {
  private tracer = trace.getTracer('order-composer');

  async getComposedOrder(orderId: string): Promise<ComposedOrder> {
    return this.tracer.startActiveSpan('compose-order', async (parentSpan) => {
      try {
        const order = await this.tracer.startActiveSpan(
          'fetch-order',
          { kind: SpanKind.CLIENT },
          async (span) => {
            span.setAttribute('order.id', orderId);
            const result = await this.orderService.getOrder(orderId);
            span.end();
            return result;
          }
        );

        const [customer, products] = await Promise.all([
          this.tracer.startActiveSpan('fetch-customer', async (span) => {
            span.setAttribute('customer.id', order.customerId);
            const result = await this.customerService.getCustomer(order.customerId);
            span.end();
            return result;
          }),
          this.tracer.startActiveSpan('fetch-products', async (span) => {
            const productIds = order.items.map(i => i.productId);
            span.setAttribute('product.count', productIds.length);
            const result = await this.productService.getProducts(productIds);
            span.end();
            return result;
          })
        ]);

        parentSpan.end();
        return this.buildResponse(order, customer, products);
      } catch (error) {
        parentSpan.recordException(error);
        parentSpan.end();
        throw error;
      }
    });
  }
}

Monitor both aggregate latency (what clients experience) and individual service latency (where problems originate). When downstream APIs change versions, the composer must adapt. Version your composer API independently from downstream services, and maintain compatibility layers when necessary.

API composition is a pragmatic pattern that trades some latency for significant reductions in client complexity and coupling. Implement it with proper parallelism, resilience, and observability, and it becomes a reliable aggregation layer for your microservice architecture.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.