Microservices Aggregator Pattern
In a microservices system, the data a user wants to see on one screen is often spread across many services. The Aggregator pattern solves this by adding one service that collects the pieces and returns them as a single, complete response.
This guide explains the problem, how the pattern works, and how to build an aggregator in Spring Boot step by step: sequential calls, parallel calls, reactive calls, partial responses when a service fails, and caching. It also compares the aggregator with an API gateway and a backend for frontend.
1. The Problem: Data Fragmentation
In a monolithic application, getting a complete view of data is simple: all the data lives in one database, and one query with a few joins returns everything.
In a microservices architecture, data is spread across many small, independent services, each with its own database. To show an order details page, you might need:
| Data on the page | Owned by |
|---|---|
| Order number, date and status | Order service |
| Customer name and address | Customer service |
| Product names, images and prices | Product service |
| Delivery status and tracking number | Shipping service |
If the client app had to call all four services itself, it would make many network calls, know the address and API of every service, and contain the logic to combine the results. That makes clients slow, complicated and fragile.
2. What Is the Aggregator Pattern?
The Aggregator pattern introduces a new, dedicated service to solve this.
In essence, the aggregator acts as a facade for one specific business use case. It hides the complexity of talking to many services, so the client makes one request and gets one response.
Think of a travel agent. Instead of booking the flight, hotel and car yourself, you ask the agent once. The agent contacts each company and hands you one complete itinerary.
3. How the Aggregator Works
- The client sends one request, for example
GET /api/orders/1001/details. - The aggregator calls the downstream services it needs, in parallel where possible.
- It waits for the responses, each with its own timeout.
- It combines and reshapes the data, keeping only the fields the client needs.
- If a non-essential service fails, it returns a partial response instead of an error.
- It returns a single JSON document to the client.
Client
| GET /api/orders/1001/details
v
[ Order Details Aggregator ]
| | | |
v v v v
Order Customer Product Shipping
Service Service Service Service
4. Example: E-Commerce Order Details Page
Let us see how the pattern solves the order details page.
4.1 Without an aggregator
- The mobile app calls the order service for the order.
- It reads the customer ID and calls the customer service.
- It reads the product IDs and calls the product service for each product.
- It calls the shipping service for the delivery status.
- It combines everything on the phone.
Result: many calls over a slow mobile network, internal service addresses exposed to the internet, and complex code that must change in every app whenever a service changes.
4.2 With an aggregator
We introduce a new service: the Order Details Aggregator Service. The app makes one call, and the aggregator does the rest over the fast internal network. The app receives exactly what it needs:
{
"orderId": "1001",
"status": "SHIPPED",
"orderedAt": "2026-10-01T10:15:00Z",
"customer": {
"name": "Priya Sharma",
"city": "Pune"
},
"items": [
{ "productId": "P-11", "name": "Wireless Mouse", "quantity": 1, "price": 799.00 },
{ "productId": "P-42", "name": "USB-C Cable", "quantity": 2, "price": 299.00 }
],
"shipment": {
"carrier": "FastShip",
"trackingNumber": "FS123456789",
"expectedDelivery": "2026-10-06"
},
"warnings": []
}
5. Building an Aggregator with Spring Boot
5.1 The response model
Java records make the combined response short and immutable:
package com.example.aggregator.model;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
public record OrderDetails(
String orderId,
String status,
Instant orderedAt,
CustomerSummary customer,
List<OrderItemView> items,
ShipmentSummary shipment,
List<String> warnings) {
public record CustomerSummary(String name, String city) {
}
public record OrderItemView(String productId, String name, int quantity, BigDecimal price) {
}
public record ShipmentSummary(String carrier, String trackingNumber, LocalDate expectedDelivery) {
}
}
5.2 Clients for the downstream services
Each downstream service gets a small client class. Here is the product client; the others follow the same shape:
package com.example.aggregator.client;
import java.time.Duration;
import java.util.List;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import com.example.aggregator.client.dto.Product;
@Component
public class ProductClient {
private final RestClient restClient;
public ProductClient(RestClient.Builder builder) {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(1));
factory.setReadTimeout(Duration.ofSeconds(2));
this.restClient = builder
.baseUrl("http://product-service")
.requestFactory(factory)
.build();
}
// One batch call instead of one call per product
public List<Product> getProducts(List<String> productIds) {
return restClient.get()
.uri(uri -> uri.path("/products").queryParam("ids", productIds).build())
.retrieve()
.body(new ParameterizedTypeReference<List<Product>>() { });
}
}
5.3 A first, sequential aggregator
public OrderDetails getOrderDetails(String orderId) {
Order order = orderClient.getOrder(orderId); // 1
Customer customer = customerClient.getCustomer(order.customerId()); // 2
List<Product> products = productClient.getProducts(order.productIds()); // 3
Shipment shipment = shippingClient.getShipment(orderId); // 4
return OrderDetailsMapper.combine(order, customer, products, shipment, List.of());
}
This works and is easy to read, but each call waits for the previous one. If each takes 100 ms, the client waits about 400 ms, even though calls 2, 3 and 4 do not depend on each other.
6. Calling Services in Parallel
The order must be loaded first, because it contains the customer and product IDs. After that, the customer, product and shipping calls are independent, so they can run at the same time:
| Approach | Total time with 100 ms per call |
|---|---|
| Sequential: order → customer → products → shipping | About 400 ms (the sum) |
| Parallel: order, then the other three together | About 200 ms (order + the slowest of the three) |
package com.example.aggregator;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Service;
import com.example.aggregator.client.*;
import com.example.aggregator.client.dto.*;
import com.example.aggregator.model.OrderDetails;
@Service
public class OrderDetailsAggregator {
private final OrderClient orderClient;
private final CustomerClient customerClient;
private final ProductClient productClient;
private final ShippingClient shippingClient;
private final ExecutorService executor;
public OrderDetailsAggregator(OrderClient orderClient, CustomerClient customerClient,
ProductClient productClient, ShippingClient shippingClient,
ExecutorService aggregatorExecutor) {
this.orderClient = orderClient;
this.customerClient = customerClient;
this.productClient = productClient;
this.shippingClient = shippingClient;
this.executor = aggregatorExecutor;
}
public OrderDetails getOrderDetails(String orderId) {
// Step 1: the order is required - without it there is nothing to show
Order order = orderClient.getOrder(orderId);
List<String> warnings = new ArrayList<>();
// Step 2: start the three independent calls at the same time
CompletableFuture<Customer> customer = CompletableFuture
.supplyAsync(() -> customerClient.getCustomer(order.customerId()), executor)
.orTimeout(2, TimeUnit.SECONDS);
CompletableFuture<List<Product>> products = CompletableFuture
.supplyAsync(() -> productClient.getProducts(order.productIds()), executor)
.orTimeout(2, TimeUnit.SECONDS);
CompletableFuture<Shipment> shipment = CompletableFuture
.supplyAsync(() -> shippingClient.getShipment(orderId), executor)
.orTimeout(2, TimeUnit.SECONDS);
// Step 3: wait for all three, then combine
CompletableFuture.allOf(customer, products, shipment)
.exceptionally(ex -> null) // failures are handled per call below
.join();
return OrderDetailsMapper.combine(
order,
valueOrNull(customer, "Customer details are temporarily unavailable", warnings),
valueOrNull(products, "Product details are temporarily unavailable", warnings),
valueOrNull(shipment, "Delivery status is temporarily unavailable", warnings),
warnings);
}
private static <T> T valueOrNull(CompletableFuture<T> future, String warning, List<String> warnings) {
if (future.isCompletedExceptionally()) {
warnings.add(warning);
return null;
}
return future.join();
}
}
The executor that runs the calls is defined once as a bean. With Java 21, virtual threads are a good fit, because the work is mostly waiting for the network:
package com.example.aggregator.config;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AggregatorConfig {
@Bean(destroyMethod = "close")
public ExecutorService aggregatorExecutor() {
// Java 21+: one cheap virtual thread per task
return Executors.newVirtualThreadPerTaskExecutor();
}
}
On Java 17, use a bounded ThreadPoolExecutor instead. Finally, a small controller exposes the aggregated endpoint:
package com.example.aggregator;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import com.example.aggregator.model.OrderDetails;
@RestController
public class OrderDetailsController {
private final OrderDetailsAggregator aggregator;
public OrderDetailsController(OrderDetailsAggregator aggregator) {
this.aggregator = aggregator;
}
@GetMapping("/api/orders/{orderId}/details")
public OrderDetails getDetails(@PathVariable String orderId) {
return aggregator.getOrderDetails(orderId);
}
}
7. Reactive Aggregation with WebClient
In a reactive application built with Spring WebFlux, Mono.zip runs calls in parallel and combines the results when all of them finish. Each call gets its own timeout and fallback:
public Mono<OrderDetails> getOrderDetails(String orderId) {
return orderClient.getOrder(orderId) // Mono<Order>, required
.flatMap(order -> {
Mono<Optional<Customer>> customer = customerClient.getCustomer(order.customerId())
.timeout(Duration.ofSeconds(2))
.map(Optional::of)
.onErrorReturn(Optional.empty());
Mono<List<Product>> products = productClient.getProducts(order.productIds())
.timeout(Duration.ofSeconds(2))
.onErrorReturn(List.of());
Mono<Optional<Shipment>> shipment = shippingClient.getShipment(orderId)
.timeout(Duration.ofSeconds(2))
.map(Optional::of)
.onErrorReturn(Optional.empty());
// All three run at the same time
return Mono.zip(customer, products, shipment)
.map(t -> OrderDetailsMapper.combine(order, t.getT1(), t.getT2(), t.getT3()));
});
}
Optional is used because Mono.zip completes empty if any of its inputs is empty, so a missing value must still be represented by an actual item.
8. Handling Failures and Partial Responses
An aggregator depends on several services, so the chance that one of them is slow or down is much higher than for a single call. Decide in advance what each failure means:
| Downstream data | Required? | Behavior when it fails |
|---|---|---|
| Order | Yes | Return an error, such as 404 if not found or 503 if unavailable |
| Customer | Nice to have | Show the order without the customer block and add a warning |
| Products | Nice to have | Show product IDs only, or cached product names |
| Shipping | Nice to have | Show “Delivery status is temporarily unavailable” |
Returning a partial response with a warnings list lets the client show as much as possible. The essential tools are:
- Timeouts on every call, so one slow service cannot delay the whole response. See the Timeout pattern.
- Circuit breakers, so the aggregator stops calling a service that keeps failing. See the Circuit Breaker pattern.
- Bulkheads, so one slow dependency cannot use up all the aggregator’s threads. See the Bulkhead pattern.
- Fallbacks such as cached data or empty sections.
9. Caching in the Aggregator
Some data changes rarely, such as product names and images. Caching it in the aggregator cuts load on downstream services and speeds up responses:
@Component
public class CachedProductClient {
private final ProductClient productClient;
public CachedProductClient(ProductClient productClient) {
this.productClient = productClient;
}
@Cacheable(cacheNames = "products", key = "#productId")
public Product getProduct(String productId) {
return productClient.getProducts(List.of(productId)).get(0);
}
}
# Requires @EnableCaching and the Caffeine dependency
spring.cache.cache-names=products
spring.cache.caffeine.spec=maximumSize=5000,expireAfterWrite=10m
Do not cache data that must always be current, such as stock levels, prices at checkout or order status, unless a short delay is acceptable. Never cache one user’s private data in a way another user could receive it.
10. Variations of the Pattern
| Variation | How it works | Example |
|---|---|---|
| Parallel aggregator | Calls independent services at the same time and merges the results | Order details page (this guide) |
| Chained aggregator | Each call needs data from the previous one | Load the order first, then the customer using the order’s customer ID |
| Scatter–gather | Sends the same request to many services and picks or merges the answers | Ask five suppliers for a price and show the cheapest |
| Branch aggregator | Chooses which services to call based on the request | Digital products skip the shipping service |
Real aggregators often combine these: a short chain for required data, followed by a parallel fan-out for the rest, as in section 6.
11. Aggregator vs API Gateway vs Backend for Frontend
The Aggregator pattern is often confused with an API gateway. They are related but different:
- API gateway: an infrastructure layer that handles cross-cutting concerns for all incoming traffic, such as routing, rate limiting, authentication and SSL termination. It is the entry point for the entire system.
- Aggregator: a business pattern in which a specific service is built to fulfill one use case by composing data from other services.
| API gateway | Aggregator service | Backend for frontend (BFF) | |
|---|---|---|---|
| Main job | Routing and cross-cutting concerns | Combine data for a business use case | Shape the whole API for one type of client |
| Scope | All traffic | One or a few use cases | All screens of one client, such as the mobile app |
| Contains business logic? | No | Light composition logic | Client-specific composition logic |
| Owned by | Platform team | The team owning the use case | The team building that client |
Typically, an aggregator service sits behind the API gateway: the gateway authenticates the request and routes /api/orders/*/details to the aggregator, which then calls the other services. Some gateways can do simple aggregation themselves, but complex composition belongs in a dedicated service. Learn more in the API Gateway pattern guide.
12. GraphQL as an Aggregator
GraphQL is a popular way to build a flexible aggregator. Instead of a fixed endpoint per screen, the client describes exactly which fields it needs, and the GraphQL server fetches them from the right services:
query {
order(id: "1001") {
status
customer { name }
items { name quantity price }
shipment { trackingNumber }
}
}
With Spring for GraphQL, each field such as customer or shipment is resolved by a method that calls the matching service. GraphQL is a good fit when many different screens need different combinations of the same data; for one or two fixed screens, a simple REST aggregator is easier.
13. When to Use It, and When Not To
Use the Aggregator pattern for:
- Dashboards and summary pages that show data from several services.
- Complex composite screens, such as an order details or product detail page.
- Mobile clients, where every extra network call costs time and battery.
- Reducing “chatter” between clients and the backend.
Avoid it, or think twice, when:
- The client needs data from only one service; just call it, perhaps through the API gateway.
- You need to change data in several services together; that is a workflow, better handled with the Saga pattern.
- The same combined data is read very often and must be very fast; a read model kept up to date with events (CQRS) may be better than calling services on every request.
14. Benefits and Drawbacks
| Benefits | Drawbacks |
|---|---|
| Clients make one call instead of many | One more service to build, deploy and monitor |
| Internal services stay hidden from clients | Adds a network hop and some latency |
| Combination logic lives in one place, not in every app | Depends on many services, so it needs strong failure handling |
| Responses can be reshaped for exactly what the client needs | Can grow into a “god service” if it collects too much logic |
| Parallel calls on a fast internal network make pages faster | Must be scaled to handle the combined load of its use cases |
15. Best Practices
- Call independent services in parallel to keep latency close to the slowest call, not the sum.
- Set a timeout on every call, and an overall time budget for the whole response.
- Decide what is required and what is optional, and return partial responses for optional data.
- Use batch endpoints such as
/products?ids=...instead of one call per item. - Keep the aggregator thin: combine and reshape data, but keep business rules in the owning services.
- Return only what the client needs, so responses stay small.
- Cache stable data with sensible expiry times.
- Protect calls with circuit breakers and bulkheads.
- Pass along tracing headers, so one aggregated request can be followed through every service.
- Create one aggregator per use case or client, not one giant aggregator for everything.
16. Common Mistakes to Avoid
| Mistake | What goes wrong | Better approach |
|---|---|---|
| Calling services one after another | Response time is the sum of all calls | Run independent calls in parallel |
| No timeouts | One slow service makes the whole page slow | Set a timeout per call and an overall budget |
| Failing completely when one service fails | Users see an error page for a missing tracking number | Return partial data with warnings |
| One call per item (N+1 calls) | Fifty products mean fifty network calls | Use batch endpoints |
| Putting business rules in the aggregator | It becomes a new monolith that every team must change | Keep rules in the services; aggregate only |
| Using the aggregator for updates across services | Partial failures leave data inconsistent | Use the Saga pattern for multi-service changes |
| Exposing internal models directly | Clients break whenever a service changes | Map to a dedicated response model |
17. Summary
The Aggregator pattern solves data fragmentation in microservices by adding one service that collects data from several downstream services and returns a single, unified response. Clients make one call instead of many, internal services stay hidden, and combination logic lives in one place. Build aggregators with parallel calls, timeouts, partial responses and caching; protect them with circuit breakers and bulkheads; and keep them thin and focused on one use case. Place them behind an API gateway, and consider GraphQL or a backend for frontend when many clients need different views of the same data. Continue with the API Gateway pattern to see how the two work together.
18. Frequently Asked Questions (FAQ)
Q1. What problem does the Aggregator pattern solve?
It solves data fragmentation. In microservices, the data for one screen is spread across several services. Without an aggregator, the client must call each service, know all their addresses, and combine the results itself.
The aggregator does this work on the server: the client sends one request and receives one complete response.
Q2. Is the Aggregator pattern the same as an API gateway?
No. An API gateway is infrastructure: it routes all incoming traffic and handles cross-cutting concerns such as authentication and rate limiting. An aggregator is a business service built for a specific use case, such as an order details page.
They work together: the gateway sits at the edge and forwards the request to the aggregator, which then calls the other services.
Q3. Should the aggregator be a separate microservice?
Usually yes, especially when the aggregation has its own logic, scaling needs or owning team. A separate service can be deployed and scaled independently.
For very simple cases, aggregation can live in an existing backend for frontend or, if the logic is trivial, in the API gateway. Avoid placing it inside one of the downstream services, because that service would then depend on all the others.
Q4. How do I keep an aggregator fast?
- Call independent services in parallel, so total time is close to the slowest call.
- Use batch endpoints to fetch many items in one call.
- Cache stable data, such as product names and images.
- Set timeouts, so one slow service cannot delay everything.
- Request only the fields you need, keeping payloads small.
Q5. What should the aggregator return if one service is down?
It depends on whether the data is required:
- Required data, such as the order itself: return an error, such as
503 Service Unavailable. - Optional data, such as delivery tracking: return the rest of the response and add a warning, or use cached data.
Agree on this behavior with the client team, so the user interface can show missing sections gracefully.
Q6. Should the aggregator call services in parallel or one after another?
Call them in parallel whenever they are independent. Call them one after another only when a later call needs data from an earlier one, such as needing the order to know which customer to load.
A common shape is a short sequential step for required data, followed by a parallel fan-out for everything else.
Q7. Can the aggregator update data in several services?
It can forward a single command, but it should not coordinate multi-service updates itself. If one update succeeds and another fails, data becomes inconsistent, and there is no shared transaction to roll back.
For processes that change data in several services, such as placing an order, use the Saga pattern with compensating actions. Keep aggregators mainly for reading and combining data.
Q8. How is the Aggregator pattern different from API composition?
They are very closely related. API composition describes the technique of implementing a query by calling several services and joining the results in memory. The Aggregator pattern usually refers to a dedicated service that performs this composition for a use case.
In practice, many people use the two names for the same idea.
Q9. When is CQRS better than an aggregator?
An aggregator calls services at request time. That is simple, but it can be slow if the data comes from many services or the page is extremely busy.
With CQRS, a separate read model, such as a table or search index, is kept up to date through events. Reads then come from one place and are very fast. Choose CQRS when the combined view is read far more often than it changes, or when it needs complex searching and filtering.
Q10. Should I use GraphQL instead of a REST aggregator?
GraphQL is a good choice when many different screens or clients need different combinations of the same data, because each client asks for exactly the fields it wants.
For one or two fixed screens, a REST aggregator endpoint is simpler to build, cache and monitor. Both are forms of the Aggregator pattern.
Q11. How do I avoid the N+1 call problem in an aggregator?
The N+1 problem happens when you load a list and then make one extra call per item, for example 1 call for an order and 20 calls for its 20 products.
Fix it with batch endpoints:
GET /products?ids=P-11,P-42,P-87
If a service has no batch endpoint, ask its team to add one, or at least cache results so repeated items are fetched only once.
Q12. How should the aggregator handle security?
- Check the user’s token, usually already validated by the API gateway.
- Forward the user’s token to downstream services, so they apply their own permission rules.
- Make sure the user may see the requested data, for example that the order belongs to them.
- Remove fields the client should not see before returning the response.
Q13. How do I test an aggregator?
- Unit tests: mock the clients and check how responses are combined, including partial-failure cases.
- Integration tests: use WireMock to fake downstream services, including slow responses and errors.
- Contract tests: check that the aggregator still matches the downstream APIs it depends on.
- Performance tests: confirm calls really run in parallel and timeouts work.
Q14. Does an aggregator increase latency?
It adds one extra hop, typically a few milliseconds inside the data center. But it usually reduces total latency for users, because:
- The client makes one call over the slow internet instead of several.
- Downstream calls run in parallel over the fast internal network.
- The aggregator can cache data and send smaller responses.
Q15. Can one aggregator serve both web and mobile clients?
Yes, if they need similar data. If their needs differ a lot, for example the mobile app wants small summaries and the web app wants full details, consider a separate aggregator or a backend for frontend (BFF) per client.
This keeps each response lean and lets each client team change its API without affecting the other.
Q16. How do I stop an aggregator from becoming a monolith?
- One aggregator per use case or client, not one for the whole system.
- No business rules: calculations and decisions stay in the owning services.
- No own database for core data; at most a cache.
- Clear ownership by the team responsible for that screen or client.
Q17. What is the scatter-gather variation?
In scatter–gather, the aggregator sends the same request to many services at once and then combines or chooses between the answers.
Examples: asking several suppliers for prices and showing the cheapest, or searching several catalogs and merging the results. Use a deadline, and return whatever answers arrived in time, so one slow supplier does not hold up the page.
Q18. Should the aggregator cache responses?
Cache data that is stable and shared, such as product names, categories or images, with a sensible expiry time.
Avoid caching data that must always be current, such as order status or stock levels, and be very careful with personal data so one user never receives another user’s information. Caching the whole aggregated response works only when it is the same for many users.
Q19. How do I monitor an aggregator?
- Track response time of the aggregated endpoint and of each downstream call.
- Count partial responses and which service caused them.
- Watch timeouts and circuit breaker states per dependency.
- Use distributed tracing to see all downstream calls of one request on a timeline.
Q20. Is the Aggregator pattern only for microservices?
It is most common in microservices, but the idea applies anywhere data comes from several sources: combining several third-party APIs, merging data from legacy systems, or building a dashboard from different databases.
Wherever a client would otherwise make many calls and combine the results itself, an aggregator can simplify things.