Lesson 64 of 129
BFF and API Composition
Clients rarely need the data of exactly one microservice. A product page may need catalog data, prices, stock, and reviews from four services. Two related patterns address this: API composition, where a component calls several services and merges their responses, and Backend for Frontend (BFF), where each client type gets its own dedicated backend that performs this composition and shapes the responses for that client.
The problem
Calling each service directly from the client causes:
- Many round trips over slow mobile networks.
- Clients coupled to the internal service structure.
- Duplicated aggregation logic in every client application.
- Over-fetching, since service APIs return fields designed for general use.
A single API gateway can route and aggregate, but one shared gateway serving web, mobile, and third parties tends to accumulate conflicting requirements.
API composition
An API composer (or aggregator) receives one request, calls the owning services, and combines the results in memory.
flowchart LR
C["Client"] --> A["API Composer"]
A --> P["Product Service"]
A --> PR["Pricing Service"]
A --> S["Inventory Service"]
A --> R["Review Service"]Execution rules:
- Call independent services in parallel, so latency is the slowest call rather than the sum.
- Call dependent services in sequence (for example, fetch the order, then fetch each product listed in it).
- Set a timeout per call and decide per service whether failure is fatal or yields a partial response.
- Merge into the response shape the client needs.
It is also the standard way to implement queries that would otherwise be joins across databases owned by different services (see database per service and eventual consistency and cross-service queries).
Worked example: the product page needs product data (20 ms), price (15 ms), stock (25 ms), and reviews (80 ms). Sequential calls take 20 + 15 + 25 + 80 = 140 ms. Parallel calls take max(20, 15, 25, 80) = 80 ms. If reviews are optional and have a 50 ms timeout, the composer returns after about 50 ms with the review section omitted, and the response marks that section as unavailable.
The sketch below calls three simulated services in parallel on virtual threads, each as a CompletableFuture. Each has a delay, and the composer waits on the futures until one shared deadline. Services that miss the deadline are reported as unavailable, and the response is partial.
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public class Main {
record Downstream(String name, long delayMillis) {
String call() throws InterruptedException {
Thread.sleep(delayMillis);
return name + "-data";
}
}
static Map<String, String> compose(List<Downstream> services, Duration deadline) throws InterruptedException {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = new LinkedHashMap<String, CompletableFuture<String>>();
for (var service : services) {
futures.put(service.name(), CompletableFuture.supplyAsync(() -> {
try {
return service.call();
} catch (InterruptedException e) {
throw new IllegalStateException("interrupted", e);
}
}, executor));
}
long deadlineNanos = System.nanoTime() + deadline.toNanos();
var results = new LinkedHashMap<String, String>();
for (var entry : futures.entrySet()) {
long left = Math.max(0, deadlineNanos - System.nanoTime());
try {
results.put(entry.getKey(), entry.getValue().get(left, TimeUnit.NANOSECONDS));
} catch (TimeoutException | ExecutionException e) {
results.put(entry.getKey(), "unavailable");
entry.getValue().cancel(true);
}
}
executor.shutdownNow();
return results;
}
}
public static void main(String[] args) throws InterruptedException {
var services = List.of(
new Downstream("product", 20),
new Downstream("price", 15),
new Downstream("reviews", 5000));
compose(services, Duration.ofMillis(300))
.forEach((name, data) -> System.out.println(name + ": " + data));
}
}product: product-data
price: price-data
reviews: unavailableLimitations
- Availability. If the composer requires all services, availability is the product of theirs (see synchronous vs asynchronous communication). Optional data with fallbacks reduces this.
- Latency. Response time is bounded below by the slowest downstream call.
- In-memory joins. Filtering, sorting, and paginating across data from several services is inefficient, since the composer cannot push predicates into a shared query engine. Fetching large result sets from each service and joining them in memory does not scale.
- Fan-out amplification. One client request becomes several downstream requests, so the composer multiplies load on services and must limit concurrency per dependency.
- Consistency. Responses combine data read at slightly different times, so the view may be inconsistent.
When these limits matter, a precomputed read model (see event sourcing and CQRS) replaces composition at query time.
Backend for Frontend
A BFF is a backend owned by the same team as one client experience (web app, iOS app, partner API). Each BFF:
- Exposes an API shaped for its client: fields, granularity, and pagination suited to the screen.
- Composes calls to downstream services.
- Handles client-specific concerns such as payload size, protocol (REST, GraphQL), and session handling.
- Is versioned and deployed with its client, so UI changes do not require changes in core services.
flowchart TB
W["Web app"] --> BW["Web BFF"]
M["Mobile app"] --> BM["Mobile BFF"]
T["Partner API client"] --> BT["Partner BFF"]
BW --> S1["Catalog"]
BW --> S2["Orders"]
BM --> S1
BM --> S2
BT --> S2BFF contains presentation-oriented logic only. Business rules stay in the domain services, otherwise rules are duplicated across BFFs.
Trade-offs:
- Duplication between BFFs for shared aggregation logic. Common code can move to a shared downstream service or library, at the cost of coupling.
- More services to deploy and operate.
- Value depends on how different the clients are. With one client type, a BFF adds a hop without benefit.
GraphQL federation
GraphQL federation (for example, Apollo Federation) combines the GraphQL schemas of several services into one graph. A router or gateway plans a query, calls the services that own each field, and stitches the results. See GraphQL.
| Aspect | BFF | GraphQL federation |
|---|---|---|
| Who defines the response shape | BFF developers | The client, per query |
| Number of endpoints | One per client type | One shared graph |
| Ownership | Client team | Domain teams own subgraphs, a platform team owns the router |
| Over-fetching | Solved by tailored endpoints | Solved by field selection |
| Costs | Duplicate BFF code | Query planning complexity, N+1 across subgraphs, caching difficulty |
In a federated graph, each subgraph declares the entity types it owns and the key fields that identify them (for example, Product keyed by id). A subgraph that extends an entity supplies additional fields, and the router fetches these by sending the keys to that subgraph. The router therefore performs the composition step that a BFF does in code.
They combine: a BFF can expose GraphQL and fetch from federated services, or a federated graph can serve as the backend of BFFs.
Interview questions
- How does API composition differ from a BFF? Composition is a mechanism, calling several services and merging results. A BFF is an organizational and deployment pattern, a per-client backend that typically uses composition.
- Why is composition a poor fit for large filtered, sorted, paginated queries? The composer has no query engine over the combined data, so it must fetch and join in memory. A read model stored in one database handles this efficiently.
- What should not go in a BFF? Domain business rules. They belong in the owning services so that all clients get the same behavior.
- How to keep the composer available when a dependency fails? Apply timeouts, treat non-essential data as optional, return partial responses, and use fallbacks or cached values.
