Definition
- 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
Monowithout 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.
// 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.
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
}
}
// 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.
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()));
}
}
// 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.
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()));
}
}
// 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.
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();
}
}
// 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.
// 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.
// 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.
// 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
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.
---