00:00

API Composition Pattern in Microservices

Last updated:

The API composition pattern answers a query that needs data from several microservices. A component called the API composer calls each service that owns part of the data, then joins the results in memory into one response. The client makes a single request and never needs to know how many services were involved.

This tutorial explains why the pattern is needed, how it works, and how to build it in Java 21 with three runnable examples. It also covers when a different approach is better, and the mistakes teams make most often. Interview questions follow at the end.

1. What Is the API Composition Pattern?

In microservices, each service owns its own database, the database per service pattern. That keeps services independent, but it removes something a monolith takes for granted: the SQL JOIN across tables.

The API composition pattern puts the join back, one level higher. Instead of joining tables, the composer joins API responses. It is the query-focused form of the aggregator pattern.

2. Why Microservices Need API Composition

Take an "order details" page in an online shop. It shows:

  • the order and its total, from the Order Service;
  • the customer's name and city, from the Customer Service;
  • the delivery status and carrier, from the Shipment Service.

Without a composer, the mobile app would make three calls, know three addresses and join the data itself. Every client (web, Android, iOS) would repeat that logic. With a composer, each client makes one call and receives exactly the data it needs.

3. How the API Composition Pattern Works

How the API composition pattern answers an order details queryA web or mobile client sends one request to the API composer, which can be an API gateway, a backend for frontend or a query service. The composer first gets the order from the Order Service, because the order holds the customer and shipment ids. It then calls the Customer Service and the Shipment Service in parallel. Each service owns its data in its own database: orders, customers and shipments. Finally the composer joins the three results in memory and returns one response to the client. Client web or mobile one request one response API Composer gateway, BFF or query service 4. joins the three results in memory Order Service owns its data orders Customer Service owns its data customers Shipment Service owns its data shipments 1. get order (first: has the ids) 2 and 3 run in parallel
Figure 1: the composer gets the order first, calls the other two services in parallel, joins the results and returns one response.
  1. Receive the query, for example GET /order-details/ORD-1001.
  2. Call the providers. Calls that depend on each other run in order; independent calls run in parallel.
  3. Join the responses in memory by matching IDs.
  4. Return one combined response.

The composer can live in an API gateway, in a backend-for-frontend built for one client, or in a dedicated query service.

What happens internally: the provider calls are not one transaction. Each service is read at a slightly different moment, so the result is not a single consistent snapshot. A shipment may already show "delivered" while the order still says "shipped". For display this is usually fine, but never base money decisions on a composed view.

4. API Composition in Java: Examples

These programs run on Java 21 with no libraries. Maps and small methods stand in for the remote services; in production they would be HTTP calls.

4.1 A Basic Composer

The order must be fetched first, because it contains the customer and shipment IDs. This dependency is easy to overlook:

OrderDetailsComposer.java
import java.math.BigDecimal;
import java.util.Map;

public class OrderDetailsComposer {

    // Data owned by three different services
    record Order(String orderId, String customerId, String shipmentId, BigDecimal total) {}
    record Customer(String customerId, String name, String city) {}
    record Shipment(String shipmentId, String status, String carrier) {}

    // The composed view the client asked for
    record OrderDetails(String orderId, String customerName, String city,
                        BigDecimal total, String shipmentStatus, String carrier) {}

    // Stand-ins for the three services (in a real system: HTTP calls)
    static final Map<String, Order> orderService = Map.of(
            "ORD-1001", new Order("ORD-1001", "C-42", "SHP-77", new BigDecimal("1499.00")));
    static final Map<String, Customer> customerService = Map.of(
            "C-42", new Customer("C-42", "Asha Rao", "Pune"));
    static final Map<String, Shipment> shipmentService = Map.of(
            "SHP-77", new Shipment("SHP-77", "OUT_FOR_DELIVERY", "SwiftShip"));

    static OrderDetails compose(String orderId) {
        Order order = orderService.get(orderId);                       // 1. needed first: it holds the other ids
        Customer customer = customerService.get(order.customerId());   // 2. uses order.customerId()
        Shipment shipment = shipmentService.get(order.shipmentId());   // 3. uses order.shipmentId()

        // 4. join the three results in memory
        return new OrderDetails(order.orderId(), customer.name(), customer.city(),
                order.total(), shipment.status(), shipment.carrier());
    }

    public static void main(String[] args) {
        OrderDetails details = compose("ORD-1001");
        System.out.println(details);
    }
}
Output
OrderDetails[orderId=ORD-1001, customerName=Asha Rao, city=Pune, total=1499.00, shipmentStatus=OUT_FOR_DELIVERY, carrier=SwiftShip]

4.2 Parallel Calls with a Timeout and Fallback

When calls are independent, sequential calls waste time: the total is the sum of all response times. In parallel it is roughly the slowest one. Here, one provider hangs, so optional data gets a fallback after 300 ms:

ParallelComposer.java
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ParallelComposer {

    record CustomerSummary(String name, int loyaltyPoints) {}
    record ProfilePage(String name, int loyaltyPoints, String recommendations) {}

    static void pause(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    // Three independent services with different response times
    static String customerName() { pause(150); return "Asha Rao"; }
    static int loyaltyPoints() { pause(200); return 320; }
    static String recommendations() { pause(5_000); return "Mechanical Keyboard"; } // this one hangs

    public static void main(String[] args) {
        // Java 21: one cheap virtual thread per remote call
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            long start = System.nanoTime();

            CompletableFuture<String> name = CompletableFuture.supplyAsync(ParallelComposer::customerName, executor);
            CompletableFuture<Integer> points = CompletableFuture.supplyAsync(ParallelComposer::loyaltyPoints, executor);
            CompletableFuture<String> recs = CompletableFuture.supplyAsync(ParallelComposer::recommendations, executor)
                    .completeOnTimeout("(recommendations unavailable)", 300, TimeUnit.MILLISECONDS); // optional data: fallback

            ProfilePage page = name
                    .thenCombine(points, (n, p) -> new CustomerSummary(n, p))
                    .thenCombine(recs, (summary, r) -> new ProfilePage(summary.name(), summary.loyaltyPoints(), r))
                    .join();

            long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
            System.out.println(page);
            // Sequential calls would take 150 + 200 + 5000 ms; in parallel we wait only until the 300 ms timeout
            System.out.println("Waited for the timeout, not the sum of all calls: "
                    + (elapsedMillis >= 300 && elapsedMillis < 1_000));
            executor.shutdownNow(); // stop the hung recommendations call instead of waiting for it
        }
    }
}
Output
ProfilePage[name=Asha Rao, loyaltyPoints=320, recommendations=(recommendations unavailable)]
Waited for the timeout, not the sum of all calls: true

Required data (name and points) must arrive, but optional data (recommendations) should never block the page. The timeout pattern explains how to pick limits like this.

4.3 Avoiding N+1 Calls on List Pages

For a list, calling the product service once per row creates the N+1 problem across the network: one call for the list, plus one per item.

OrderHistoryComposer.java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

public class OrderHistoryComposer {

    record Order(String orderId, String productId) {}
    record OrderRow(String orderId, String productName) {}

    static int remoteCalls = 0;   // counts simulated network round trips

    static final List<Order> orders = List.of(
            new Order("ORD-1", "P-10"), new Order("ORD-2", "P-20"),
            new Order("ORD-3", "P-10"), new Order("ORD-4", "P-30"));
    static final Map<String, String> productCatalog = Map.of(
            "P-10", "Mechanical Keyboard", "P-20", "Wireless Mouse", "P-30", "USB-C Hub");

    static List<Order> fetchOrders() {
        remoteCalls++;
        return orders;
    }

    // One call per product: GET /products/{id}
    static String fetchProductName(String productId) {
        remoteCalls++;
        return productCatalog.get(productId);
    }

    // One call for many products: GET /products?ids=...
    static Map<String, String> fetchProductNames(Set<String> productIds) {
        remoteCalls++;
        return productIds.stream().collect(Collectors.toMap(id -> id, productCatalog::get));
    }

    static List<OrderRow> naive() {
        List<OrderRow> rows = new ArrayList<>();
        for (Order order : fetchOrders()) {
            rows.add(new OrderRow(order.orderId(), fetchProductName(order.productId()))); // N extra calls
        }
        return rows;
    }

    static List<OrderRow> batched() {
        List<Order> list = fetchOrders();
        Set<String> ids = list.stream().map(Order::productId).collect(Collectors.toSet());
        Map<String, String> names = fetchProductNames(ids);                               // 1 extra call
        return list.stream().map(o -> new OrderRow(o.orderId(), names.get(o.productId()))).toList();
    }

    public static void main(String[] args) {
        remoteCalls = 0;
        List<OrderRow> rows = naive();
        System.out.println("Naive:   " + remoteCalls + " remote calls");

        remoteCalls = 0;
        List<OrderRow> same = batched();
        System.out.println("Batched: " + remoteCalls + " remote calls");

        System.out.println("Same result: " + rows.equals(same));
        same.forEach(row -> System.out.println("  " + row.orderId() + " -> " + row.productName()));
    }
}
Output
Naive:   5 remote calls
Batched: 2 remote calls
Same result: true
  ORD-1 -> Mechanical Keyboard
  ORD-2 -> Wireless Mouse
  ORD-3 -> Mechanical Keyboard
  ORD-4 -> USB-C Hub

With 4 orders the difference is small; with 50 orders it is 51 network calls against 2.

5. API Composition vs a CQRS Read Model

The main alternative is CQRS (Command Query Responsibility Segregation): a separate service keeps a ready-made, denormalised copy of the data, updated from events, and answers queries from it.

AspectAPI compositionCQRS read model
FreshnessAlways current: reads live dataEventually consistent: slightly behind
EffortLow: just call existing APIsHigh: events, a new database, sync logic
LatencyDepends on the slowest providerOne fast local query
AvailabilityFails if a required provider is downWorks while providers are down
Filtering and sorting across servicesPoor: must fetch and filter in memoryGood: indexed in one database
Best forDetail pages, a few providers, small resultsSearch, reports, large lists

Start with API composition, and move a query to a read model when it becomes slow, large or business-critical.

6. Common Mistakes and Pitfalls

  • Filtering across services. "Orders over 1,000 from customers in Pune, sorted by date, page 2" cannot be paginated by one service. The composer would have to fetch huge result sets from both and filter in memory. Use a read model for queries like this.
  • N+1 calls on list pages, as in section 4.3. Ask providers for batch endpoints.
  • All-or-nothing responses. If any provider fails, the whole page fails. Decide which data is required and which is optional, and give optional data a fallback.
  • No timeouts. One slow provider holds the composer's threads and slows every request.
  • Business logic in the composer. The composer should join and shape data, not make decisions that belong to a service.

7. Best Practices

  • Call independent providers in parallel, and only chain calls that truly depend on each other.
  • Set a timeout on every provider call, shorter than the composer's own deadline.
  • Mark each part as required or optional, with a fallback for optional parts.
  • Use batch endpoints for lists, and cache data that rarely changes.
  • Protect providers with a circuit breaker so a failing service is not called again and again.
  • Return only what the client needs, shaped for that client.

8. Summary

  • The API composition pattern answers queries that span several services by calling each one and joining the results in memory.
  • It is needed because each service owns its database, so cross-service SQL joins are impossible.
  • Run independent calls in parallel, respect dependencies, and add timeouts and fallbacks.
  • Watch for N+1 calls, cross-service filtering and the lack of a consistent snapshot.
  • Use the API composition pattern for simple, live queries, and a CQRS read model for heavy search and reporting.