00:00

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 pageOwned by
Order number, date and statusOrder service
Customer name and addressCustomer service
Product names, images and pricesProduct service
Delivery status and tracking numberShipping 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.

Definition: The Aggregator pattern is a microservice design pattern in which a composite service (the aggregator) collects data from several downstream microservices, applies business logic if needed, and combines the results into a single, unified response for the client.

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

  1. The client sends one request, for example GET /api/orders/1001/details.
  2. The aggregator calls the downstream services it needs, in parallel where possible.
  3. It waits for the responses, each with its own timeout.
  4. It combines and reshapes the data, keeping only the fields the client needs.
  5. If a non-essential service fails, it returns a partial response instead of an error.
  6. It returns a single JSON document to the client.
Aggregator flow
              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

  1. The mobile app calls the order service for the order.
  2. It reads the customer ID and calls the customer service.
  3. It reads the product IDs and calls the product service for each product.
  4. It calls the shipping service for the delivery status.
  5. 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:

GET /api/orders/1001/details
{
  "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:

OrderDetails.java
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:

ProductClient.java
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

OrderDetailsAggregator.java (simple)
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:

ApproachTotal time with 100 ms per call
Sequential: order → customer → products → shippingAbout 400 ms (the sum)
Parallel: order, then the other three togetherAbout 200 ms (order + the slowest of the three)
OrderDetailsAggregator.java
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:

AggregatorConfig.java
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:

OrderDetailsController.java
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:

ReactiveOrderDetailsAggregator.java
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 dataRequired?Behavior when it fails
OrderYesReturn an error, such as 404 if not found or 503 if unavailable
CustomerNice to haveShow the order without the customer block and add a warning
ProductsNice to haveShow product IDs only, or cached product names
ShippingNice to haveShow “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:

CachedProductClient.java
@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);
    }
}
application.properties
# 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

VariationHow it worksExample
Parallel aggregatorCalls independent services at the same time and merges the resultsOrder details page (this guide)
Chained aggregatorEach call needs data from the previous oneLoad the order first, then the customer using the order’s customer ID
Scatter–gatherSends the same request to many services and picks or merges the answersAsk five suppliers for a price and show the cheapest
Branch aggregatorChooses which services to call based on the requestDigital 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 gatewayAggregator serviceBackend for frontend (BFF)
Main jobRouting and cross-cutting concernsCombine data for a business use caseShape the whole API for one type of client
ScopeAll trafficOne or a few use casesAll screens of one client, such as the mobile app
Contains business logic?NoLight composition logicClient-specific composition logic
Owned byPlatform teamThe team owning the use caseThe 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:

GraphQL query
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

BenefitsDrawbacks
Clients make one call instead of manyOne more service to build, deploy and monitor
Internal services stay hidden from clientsAdds a network hop and some latency
Combination logic lives in one place, not in every appDepends on many services, so it needs strong failure handling
Responses can be reshaped for exactly what the client needsCan grow into a “god service” if it collects too much logic
Parallel calls on a fast internal network make pages fasterMust be scaled to handle the combined load of its use cases

15. Best Practices

  1. Call independent services in parallel to keep latency close to the slowest call, not the sum.
  2. Set a timeout on every call, and an overall time budget for the whole response.
  3. Decide what is required and what is optional, and return partial responses for optional data.
  4. Use batch endpoints such as /products?ids=... instead of one call per item.
  5. Keep the aggregator thin: combine and reshape data, but keep business rules in the owning services.
  6. Return only what the client needs, so responses stay small.
  7. Cache stable data with sensible expiry times.
  8. Protect calls with circuit breakers and bulkheads.
  9. Pass along tracing headers, so one aggregated request can be followed through every service.
  10. Create one aggregator per use case or client, not one giant aggregator for everything.

16. Common Mistakes to Avoid

MistakeWhat goes wrongBetter approach
Calling services one after anotherResponse time is the sum of all callsRun independent calls in parallel
No timeoutsOne slow service makes the whole page slowSet a timeout per call and an overall budget
Failing completely when one service failsUsers see an error page for a missing tracking numberReturn partial data with warnings
One call per item (N+1 calls)Fifty products mean fifty network callsUse batch endpoints
Putting business rules in the aggregatorIt becomes a new monolith that every team must changeKeep rules in the services; aggregate only
Using the aggregator for updates across servicesPartial failures leave data inconsistentUse the Saga pattern for multi-service changes
Exposing internal models directlyClients break whenever a service changesMap 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.