Back to Communication: Sync, Async, gRPC
Q4Question 4 of 8

Handling request aggregation when multiple services must be queried

Key Concepts
  • Request aggregation lets the gateway call several backend services and compose one response for the client.
  • Non-blocking WebClient returns a Mono without blocking the thread, enabling concurrent downstream calls.
  • Mono.zip combines multiple Monos and completes when all inputs complete, giving a tuple of results.
  • Total time is the slowest single call, not the sum of all calls, because they run in parallel.
  • Each downstream call needs its own timeout and fallback so one dead service does not blank the whole response.
  • For a variable number of calls, use Flux.flatMap with bounded concurrency instead of Mono.zip.
  • Keep aggregation simple and view-oriented at the gateway; heavy business aggregation belongs in a dedicated service.

Definition

Fire the downstream calls in parallel with non-blocking WebClient, combine them with Mono.zip, and the client gets one aggregated response in the time of the slowest single call, not the sum.
  • Request aggregation – the gateway responds to a single client call by querying several backend services and composing their responses into one payload.
  • WebClient – the reactive, non-blocking HTTP client in Spring WebFlux that returns a Mono without tying up a thread.
  • Mono.zip – a combinator that takes multiple Monos, subscribes to them concurrently, and emits a tuple when all complete.
  • Parallel execution – because the Monos are independent, total time equals the slowest call, not the sum.
  • Per-call resilience – each downstream call gets its own timeout and fallback so a failed service degrades gracefully rather than killing the aggregate.

Imagine Rajesh asks the National Bank of India help desk for a complete summary: his account balance, his card status, and his recent payments. A slow clerk would walk to the accounts room, come back, then walk to the cards room, come back, then walk to the payments room, taking three trips end to end. A smart clerk sends three runners at the same time, waits at the desk until all three return, and staples their papers into one summary sheet. The gateway doing Mono.zip over three parallel WebClient calls is that smart clerk.

Understanding the Concepts

What problem does request aggregation solve?

The problem is the chatty client. A mobile dashboard needs account, card, and payment data from three services. Without aggregation, the app makes three separate calls, each with its own latency, authentication overhead, and failure chance. Moving the aggregation into the gateway means the client makes one call and gets one response.

JAVA
// Without aggregation: 3 round trips from the mobile app
// GET /api/v1/accounts/CUST-1001      → 100ms
// GET /api/v1/cards/CUST-1001         → 80ms
// GET /api/v1/payments/CUST-1001      → 120ms
// Total from mobile: 100 + 80 + 120 = 300ms + 3x auth overhead

// With aggregation: 1 call to the gateway
// GET /api/v1/dashboard/CUST-1001     → max(100, 80, 120) = ~120ms
// One auth check, one round trip from mobile

Why is sequential aggregation wrong?

The wrong way is to call each service one after another, blocking between them. The total time is the sum of all latencies, and you also tie up a thread the whole time.

JAVA
package com.bank.gateway;

import org.springframework.web.client.RestClient;
import java.util.Map;

public class SequentialAggregatorWrong {

    private final RestClient restClient;

    public SequentialAggregatorWrong(RestClient.Builder builder) {
        this.restClient = builder.build();
    }

    // WRONG: sequential, blocking calls
    public Map<String, Object> getDashboard(String customerId) {
        Object account = restClient.get()
                .uri("http://account-service/api/v1/accounts/{id}", customerId)
                .retrieve().body(Object.class);  // blocks 100ms

        Object cards = restClient.get()
                .uri("http://card-service/api/v1/cards/{id}", customerId)
                .retrieve().body(Object.class);    // blocks 80ms

        Object payments = restClient.get()
                .uri("http://payment-service/api/v1/payments/{id}/recent", customerId)
                .retrieve().body(Object.class); // blocks 120ms

        return Map.of("account", account, "cards", cards, "payments", payments);
        // Total: 100 + 80 + 120 = 300ms, thread tied up the whole time
    }
}
TEXT
// Sequential: total = sum of all latencies = 300ms
// Thread is blocked for the entire 300ms
// On a reactive gateway this defeats the non-blocking model

How do you aggregate correctly with Mono.zip?

The correct approach uses the reactive WebClient, which returns a Mono without blocking. You start all three calls concurrently and combine them with Mono.zip.

JAVA
package com.bank.gateway;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;

@RestController
public class DashboardAggregator {

    private final WebClient webClient;

    public DashboardAggregator(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    @GetMapping("/api/v1/dashboard/{customerId}")
    public Mono<Map<String, Object>> getDashboard(
            @PathVariable String customerId) {

        Mono<Object> account = webClient.get()
                .uri("http://account-service/api/v1/accounts/{id}", customerId)
                .retrieve().bodyToMono(Object.class);

        Mono<Object> cards = webClient.get()
                .uri("http://card-service/api/v1/cards/{id}", customerId)
                .retrieve().bodyToMono(Object.class);

        Mono<Object> payments = webClient.get()
                .uri("http://payment-service/api/v1/payments/{id}/recent", customerId)
                .retrieve().bodyToMono(Object.class);

        return Mono.zip(account, cards, payments)
                .map(tuple -> Map.of(
                        "account", tuple.getT1(),
                        "cards", tuple.getT2(),
                        "payments", tuple.getT3()));
    }
}
TEXT
// Parallel: total = max(100, 80, 120) = ~120ms
// No thread is blocked: reactive event loop handles all three
// 2.5x faster than sequential, with better resource usage

How do you add per-call resilience?

By default Mono.zip fails if any input fails, which blanks the whole dashboard. Add a timeout and fallback to each individual Mono so one dead service degrades gracefully.

JAVA
package com.bank.gateway;

import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;

public class ResilientDashboardAggregator {

    private final WebClient webClient;

    public ResilientDashboardAggregator(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    public Mono<Map<String, Object>> getDashboard(String customerId) {
        Mono<Object> account = webClient.get()
                .uri("http://account-service/api/v1/accounts/{id}", customerId)
                .retrieve().bodyToMono(Object.class)
                .timeout(Duration.ofMillis(300))
                .onErrorReturn(Map.of("status", "unavailable"));

        Mono<Object> cards = webClient.get()
                .uri("http://card-service/api/v1/cards/{id}", customerId)
                .retrieve().bodyToMono(Object.class)
                .timeout(Duration.ofMillis(300))
                .onErrorReturn(Map.of("status", "unavailable"));

        Mono<Object> payments = webClient.get()
                .uri("http://payment-service/api/v1/payments/{id}/recent", customerId)
                .retrieve().bodyToMono(Object.class)
                .timeout(Duration.ofMillis(300))
                .onErrorReturn(Map.of("status", "unavailable"));

        return Mono.zip(account, cards, payments)
                .map(tuple -> Map.of(
                        "account", tuple.getT1(),
                        "cards", tuple.getT2(),
                        "payments", tuple.getT3()));
    }
}
DIAGRAM
// Card Service is down:
// account: {balance: 75000, ...}       ← real data
// cards: {status: "unavailable"}        ← graceful fallback
// payments: [{txnId: TXN-123, ...}]     ← real data
// Dashboard renders 2 of 3 sections instead of a blank error screen

When should you use Flux instead of Mono.zip?

Mono.zip is for a fixed, known set of inputs. When the number of downstream calls is variable or large, use Flux.flatMap with bounded concurrency.

JAVA
package com.bank.gateway;

import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.List;
import java.util.Map;

public class DynamicAggregator {

    private final WebClient webClient;

    public DynamicAggregator(WebClient.Builder builder) {
        this.webClient = builder.build();
    }

    public Mono<List<Map<String, Object>>> aggregateServices(
            List<String> serviceUrls, String customerId) {
        return Flux.fromIterable(serviceUrls)
                .flatMap(url -> webClient.get()
                        .uri(url + "/{id}", customerId)
                        .retrieve().bodyToMono(Map.class)
                        .timeout(Duration.ofMillis(300))
                        .onErrorReturn(Map.of("status", "unavailable")),
                        5) // Max 5 concurrent calls
                .map(m -> (Map<String, Object>) m)
                .collectList();
    }
}
DIAGRAM
// 10 service URLs, bounded concurrency of 5:
// Batch 1: 5 calls in parallel → max latency ~100ms
// Batch 2: 5 calls in parallel → max latency ~100ms
// Total: ~200ms instead of 10 x 100ms = 1000ms sequential

What is the difference between all-or-nothing and best-effort aggregation?

You must decide whether the aggregated response fails entirely if any call fails, or returns whatever data is available with per-section status.

JAVA
// All-or-nothing (default Mono.zip behavior):
// If card service fails → entire dashboard returns 500
// Appropriate when ALL data is required for correctness

// Best-effort (with per-call fallbacks):
// If card service fails → dashboard returns account + payments + "cards unavailable"
// Appropriate for display dashboards where partial data is better than nothing

// In banking, best-effort with explicit per-section status is usually safer
// Rajesh sees his balance and recent payments even if cards are temporarily down

Where should aggregation logic live?

Simple view composition for a specific client fits the gateway, especially as a Backend-for-Frontend. Heavy aggregation with business rules should live in a dedicated composition service, not the gateway, to keep the gateway thin.

JAVA
// Gateway aggregation (simple, view-oriented):
// Combine account + cards + payments for the mobile dashboard
// No business logic, just data stitching

// Dedicated service aggregation (complex, business rules):
// Calculate portfolio value from accounts, investments, and market prices
// Apply tax rules, compliance checks, risk scoring
// This belongs in a PortfolioService, NOT the gateway

What should a senior engineer know?

A senior engineer ensures that the aggregation timeout is shorter than the client's timeout, adds structured logging with correlation IDs, and monitors the p99 latency of each downstream call independently. They also know that caching individual downstream responses at the gateway can eliminate redundant calls across multiple dashboard requests.

JAVA
// Senior checklist for production aggregation:
// 1. Per-call timeout < gateway timeout < client timeout
// 2. Per-call fallback so partial data is returned
// 3. Correlation ID propagated to all downstream calls
// 4. Metrics: p50/p99 latency per downstream service
// 5. Cache: short TTL for slowly changing data (interest rates)
// 6. Circuit breaker per downstream route

Visual Overview

DIAGRAM
  Sequential (Wrong)
  ──────────────────
  Gateway    Account Svc    Card Svc    Payment Svc
    │            │              │            │
    │── call ───▶│              │            │
    │◀── resp ──│   100ms      │            │
    │── call ──────────────────▶│            │
    │◀── resp ─────────────────│   80ms     │
    │── call ──────────────────────────────▶│
    │◀── resp ─────────────────────────────│  120ms
    │                                       │
    Total: 100 + 80 + 120 = 300ms


  Parallel with Mono.zip (Correct)
  ────────────────────────────────
  Gateway    Account Svc    Card Svc    Payment Svc
    │            │              │            │
    │── call ───▶│              │            │
    │── call ──────────────────▶│            │
    │── call ──────────────────────────────▶│
    │◀── resp ──│   100ms      │            │
    │            │◀── resp ────│   80ms     │
    │            │              │◀── resp ──│  120ms
    │                                       │
    │── zip: all three done ──▶ merge       │
    │◀── single dashboard response          │
    │                                       │
    Total: max(100, 80, 120) = 120ms

Key Takeaway

Request aggregation is when the gateway responds to a single client call by querying several backend services and composing their answers into one payload. The whole point is to turn a chatty client, which would make many round trips, into one call, and to do the backend work in parallel. In a reactive Spring Cloud Gateway or WebFlux service, the tool for this is the non-blocking WebClient combined with Mono.zip.

The key is concurrency. The wrong way is to call each service sequentially and block between them, because then the total time is the sum of all latencies and you tie up a thread the whole time. The correct way starts all WebClient calls concurrently, each returning a Mono, and combines them with Mono.zip, which completes when all inputs complete. Because the calls overlap, the total time is the slowest single call, not the sum.

At National Bank of India, the mobile dashboard for Rajesh needs account, card, and payment data from three services. The gateway aggregates them in parallel, so the app makes one call and gets one response in about 120ms instead of 300ms. The part that makes it production-grade is per-call resilience: each downstream call has its own timeout and fallback, so when the card service was briefly down, the dashboard still showed account and payment data while the card section showed "unavailable."

Remember one sentence: use Mono.zip with per-call timeouts and fallbacks to aggregate parallel WebClient calls into one response, so the client gets one fast call instead of many slow ones.

---

Share
Use ← → arrow keys to navigate