Cross-Service Queries & API Composition
Learn the API composition pattern: how a single component can call multiple microservices concurrently and merge their responses into one answer, plus where this simple approach starts to break down.
Learning objectives
- Explain what the API composition pattern does and when to reach for it.
- Implement a composed endpoint that calls several services concurrently using Spring WebClient.
- Explain why calling services concurrently instead of sequentially matters for response time.
- Identify the situations where API composition genuinely struggles.
◆ Story
Recall the four separate filing rooms: Customer, Accounts, Cards, and Loans, each locked to its own department. Rather than making a branch manager personally walk to all four rooms to build one combined customer summary, imagine a helpful receptionist who does the legwork instead. The receptionist walks to Customer and gets the profile, walks to Accounts and gets the balances, then visits Cards and Loans for the rest. Once all four pieces are in hand, the receptionist staples everything together into a single neat report and hands it over. The branch manager never has to know there were four separate rooms at all — as far as they're concerned, they asked one question and got one answer.
That receptionist is doing exactly what a piece of software can do for you when data lives behind several independent services: receive one request, fetch each piece of data from wherever it actually lives, and combine everything into a single response before anyone downstream has to think about where any of it came from.
This idea generalizes far beyond banking. Any product screen that needs to show information owned by more than one service — an order summary pulling from inventory, payments, and shipping, or a dashboard pulling from several independent systems — faces exactly this same shape of problem, and the receptionist idea is the starting point for solving it.
That receptionist is exactly what's called the API composition pattern: one component — often called a composer, and sometimes built directly into an API gateway — receives a single incoming request, calls each of the individual microservices that owns a piece of the needed data, waits for all of their responses, and combines them into one response sent back to the client.
The flow looks like this: a client sends one request to the composer. The composer fans that single request out into several independent calls — one to each service that owns a relevant piece of data. Each of those services responds with just its own slice: Customer Service returns the profile, Accounts Service returns the balances, Cards Service returns the card list, Loans Service returns the loan list. The composer waits until every one of those calls has returned, merges the pieces into a single combined object, and sends that back as one response. The client only ever sees one request and one response — the fan-out and merge happen entirely inside the composer.
This pattern deliberately keeps each service completely unaware of the others. Customer Service has no idea Accounts Service even exists; it just answers questions about customers. All of the cross-service knowledge lives in exactly one place — the composer — which keeps every individual service simple and focused on the reality that it owns.
This is also the same underlying idea behind a "backend for frontend": a composer built specifically to shape data for one particular client or screen, rather than trying to be a generic combiner for every possible use case.
Here's what a composed customer-profile endpoint looks like in a Spring Boot service, using the reactive WebClient to call all four services.
◆ Under the hood
Every one of the four calls below returns a Mono — a reactive type representing "a value that will arrive later." Nothing actually executes until something subscribes to the combined pipeline; Spring handles that subscription for you once it sees a Mono returned from a controller method.
The key detail is Mono.zip: it fires all four requests at essentially the same instant, rather than calling Customer, waiting for it to fully finish, then calling Accounts, and so on. If each service takes roughly 100 milliseconds to respond, a sequential approach costs you close to 400 milliseconds in total; the concurrent approach shown in the code costs you close to 100 milliseconds — the time of the single slowest call, not the sum of all four. On a screen where a customer is actively waiting, that difference is the difference between a page that feels instant and one that feels sluggish.
💻 Code example
@RestController public class CustomerProfileController { private final WebClient webClient; public CustomerProfileController(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.build(); } @GetMapping("/profile/{customerId}") public Mono<CustomerProfileResponse> getProfile(@PathVariable String customerId) { Mono<CustomerResponse> customer = webClient.get() .uri("http://customer-service/customers/{id}", customerId) .retrieve() .bodyToMono(CustomerResponse.class); Mono<List<AccountResponse>> accounts = webClient.get() .uri("http://accounts-service/accounts?customerId={id}", customerId) .retrieve() .bodyToFlux(AccountResponse.class) .collectList(); Mono<List<CardResponse>> cards = webClient.get() .uri("http://cards-service/cards?customerId={id}", customerId) .retrieve() .bodyToFlux(CardResponse.class) .collectList(); Mono<List<LoanResponse>> loans = webClient.get() .uri("http://loans-service/loans?customerId={id}", customerId) .retrieve() .bodyToFlux(LoanResponse.class) .collectList(); // all four calls fire concurrently -- total wait time is the SLOWEST call, not the sum of all four return Mono.zip(customer, accounts, cards, loans) .map(tuple -> new CustomerProfileResponse( tuple.getT1(), tuple.getT2(), tuple.getT3(), tuple.getT4())); } }
▲ Common mistake
Assuming API composition scales to any read requirement just because it's simple to build. It's an excellent fit for a handful of services and straightforward "fetch and merge" screens — but it strains in a few very real situations.
Sorting or filtering a combined result across all four services' data is one of the sharpest limits: you can't ask a database to sort data it never had in the first place, so any sorting or filtering that spans services has to happen in application code, after every response has already arrived, which is slow and memory-hungry once result sets grow.
The composer's total response time is also permanently capped by its slowest single dependency, no matter how well the concurrent calls are written. If Loans Service is having a bad day and takes three seconds to respond, every combined profile request now takes at least three seconds too, even though Customer, Accounts, and Cards all answered instantly.
Partial failure is the third honest problem: if one of the four services is temporarily unreachable, the composer has to decide what an incomplete response even means. Do you return the three pieces you have with a placeholder for the fourth? Do you fail the whole request? Neither answer is obviously correct, and the right one usually depends on the specific screen.
These exact limits are the honest reason a fundamentally different pattern exists for situations where composition alone genuinely isn't enough — one that stops fetching and merging data on every single request, and instead keeps a ready-to-read copy sitting there in advance.
API composition, implemented at the API gateway layer or inside a dedicated "backend for frontend" service, is an extremely common pattern for exactly the kind of dashboard or profile screen this topic builds. Many real banking, e-commerce, and travel-booking applications use it for straightforward aggregation — an order summary pulling from inventory, payment, and shipping services, or an account dashboard pulling from several product-specific services — reaching for something more elaborate only once the read-side demands genuinely outgrow simple fan-out-and-merge.
Spring Cloud Gateway is a common choice for implementing composition at the gateway layer in Java shops, since it's already reactive and already sitting in the request path for every incoming call. Teams that don't want gateway-level logic often build a small, dedicated composition service instead — sometimes called a backend for frontend — so that each client type (a mobile app, a web dashboard, an internal admin tool) can get a response shaped exactly for its own screens, without forcing every client to share one generic combined format.
A useful rule of thumb in production systems: API composition is the right first tool for any read that touches two or three services and doesn't need cross-service sorting, filtering, or extremely low latency. Once any of those three requirements shows up, it's usually a signal to consider a pattern that maintains a pre-built, query-optimized copy of the combined data instead of assembling it fresh on every request.
Q: What does the API composition pattern actually do? A: One component calls multiple microservices, waits for all of their responses, and merges them into a single response for the client.
Q: Why does the code example fire all four service calls concurrently instead of one after another? A: Because total response time becomes the time of the single slowest call rather than the sum of every call, which matters a lot for a screen where a user is actively waiting.
Q: What kind of read requirement does API composition genuinely struggle with? A: Sorting or filtering a combined result across data that lives in different services, since you can't sort data a service never returned to you.
Q: What's a common way to implement API composition in a Spring-based system? A: At the API gateway layer (for example with Spring Cloud Gateway) or inside a dedicated backend-for-frontend service using a reactive WebClient.
Want a visual for this concept?
Generate a diagram tailored to “Cross-Service Queries & API Composition” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →