API Composition Pattern in Microservices
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
- Receive the query, for example
GET /order-details/ORD-1001. - Call the providers. Calls that depend on each other run in order; independent calls run in parallel.
- Join the responses in memory by matching IDs.
- 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.
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:
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);
}
}
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:
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
}
}
}
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.
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()));
}
}
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.
| Aspect | API composition | CQRS read model |
|---|---|---|
| Freshness | Always current: reads live data | Eventually consistent: slightly behind |
| Effort | Low: just call existing APIs | High: events, a new database, sync logic |
| Latency | Depends on the slowest provider | One fast local query |
| Availability | Fails if a required provider is down | Works while providers are down |
| Filtering and sorting across services | Poor: must fetch and filter in memory | Good: indexed in one database |
| Best for | Detail pages, a few providers, small results | Search, 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.
API Composition Pattern: Interview Questions
Common interview questions on the API composition pattern, from basic to scenario-based, with short answers.
Basic questions
Q. What problem does the API composition pattern solve?
Answer: With the database per service pattern, data for one screen is spread across several services, and you cannot join their tables with SQL. The API composition pattern lets one component call those services and join their responses, so clients still get the combined data in a single request.
Q. Who usually plays the role of the API composer?
Answer: Commonly an API gateway, a backend for frontend (BFF) built for one client such as the mobile app, or a dedicated query service. A service that already owns most of the data can also compose, by calling the others for the remaining parts.
Q. What is the difference between the API composition pattern and the API gateway pattern?
Answer: An API gateway is a single entry point that mainly routes requests, and handles cross-cutting work such as authentication and rate limiting. API composition is about combining data from several services into one response. A gateway can act as the composer, but routing alone is not composition.
Intermediate questions
Q. How do you decide which provider calls can run in parallel?
Answer: Look at the data each call needs. If a call needs an ID or value returned by another call, it must wait for that call; otherwise it can start immediately. Group the calls into stages, run each stage in parallel, and keep the number of stages small.
Q. How would you implement API composition in a Spring Boot application?
Answer: Use RestClient or WebClient to call each provider, run independent calls concurrently, and combine them into a response record. For blocking code, CompletableFuture with a virtual-thread executor works well on Java 21; with WebClient, Mono.zip(...) combines reactive calls.
Mono.zip(customerMono, shipmentMono).map(t -> new OrderDetails(order, t.getT1(), t.getT2()))
Q. What should the composer return when one provider is down?
Answer: It depends on whether that data is required. If required data is missing, return an error such as 503 Service Unavailable. If it is optional, return the rest of the response with a fallback value or an empty field, and make it clear to the client that the part is unavailable rather than silently empty.
Q. Should the composer cache provider responses?
Answer: Cache data that changes rarely, such as product names or customer profiles, with a short time to live. Do not cache fast-changing or sensitive data like stock levels or payment status. Caching reduces load and latency, but it means part of the composed response may be slightly old.
Q. How does API composition affect availability?
Answer: A composed response that needs several required providers succeeds only when all of them answer, so its availability is lower than any single provider's. Making parts optional, adding timeouts, caching and circuit breakers keeps a failing provider from taking the whole response down.
Tricky and scenario questions
Q. The product team wants a page listing orders over 1,000 from customers in one city, sorted by date. Would you use API composition?
Answer: Probably not. The filter needs order data and customer data at the same time, so neither service can apply it alone, and pagination breaks because the composer cannot know page 2 without fetching far more rows. A CQRS read model that stores orders together with customer city, updated from events, can filter, sort and page with one indexed query.
Q. A composed page became slow after a new provider was added. How do you investigate?
Answer: Use distributed tracing to see each provider call and how long it takes, and check whether the new call runs sequentially when it could run in parallel. Also check for N+1 calls, missing timeouts and an unnecessarily large response. Often the fix is to parallelise, batch, cache or make the new data optional.
Q. Can API composition be used for write operations?
Answer: Not safely in the same way. Calling several services to change data has no shared transaction, so a failure halfway leaves the services inconsistent. Writes that span services need a Saga with compensating actions, or events, not a simple composer.
Q. How do you handle a provider that returns data in a different format or version?
Answer: Keep a small adapter in the composer for each provider that maps its response to an internal model, so a provider change is fixed in one place. Providers should version their APIs, and consumer-driven contract tests catch breaking changes before they reach production.
Q. How do you secure calls from the composer to the providers?
Answer: Pass the user's identity, for example the original access token or a token exchanged for the composer, so each provider can still check permissions itself. Do not let the composer use a powerful technical account that bypasses provider checks, and protect service-to-service traffic with TLS.
Q. What is the difference between API composition and the aggregator pattern?
Answer: They are closely related: both collect results from several services into one response. API composition is usually described specifically for queries in a database-per-service architecture, while the aggregator pattern is the general idea of one component combining calls to others.