API Composition Unifies Frontend Data Aggregation
Emily Parker
Product Engineer · Leapcell

Introduction
In the ever-evolving landscape of backend development, the demands placed on delivering rich, dynamic, and personalized user experiences continually escalate. Frontend applications, in their quest for such experiences, often require data from various disparate backend services. Traditionally, the Backend for Frontend (BFF) pattern has served as a widely adopted solution to aggregate and transform this data, tailoring it specifically for different client types or views. While BFFs provide undeniable benefits in decoupling frontend concerns from backend complexities, they can sometimes introduce their own set of challenges, particularly around maintainability, scalability, and the duplication of logic when the number of client types or feature sets grows. This article delves into an alternative paradigm: leveraging API composition patterns to achieve more flexible frontend data aggregation, offering a compelling evolution from the conventional BFF approach. We will explore how this methodology can empower developers to build more adaptable and robust systems.
Understanding the Landscape
Before diving into the specifics of API composition, let's establish a common understanding of the key concepts at play:
-
Backend for Frontend (BFF): A design pattern where a dedicated backend service is created for each specific frontend application or client type. Its primary role is to aggregate data from multiple downstream microservices, transform it to suit the frontend's needs, and handle client-specific concerns like authentication or data formatting. BFFs abstract backend complexity and optimize data fetching for a particular UI.
-
Microservices: An architectural style that structures an application as a collection of loosely coupled, independently deployable, and often independently maintainable services. Each service typically performs a specific business capability.
-
API Gateway: A service that acts as a single entry point for all client requests. It can handle request routing, composition, protocol translation, authentication, authorization, caching, and rate limiting, among other cross-cutting concerns. While API gateways can perform some data aggregation, they are typically designed for generic routing and policy enforcement rather than client-specific data shaping.
-
API Composition: A pattern where a service (which could be the frontend itself, an API Gateway, or a dedicated composition service) dynamically combines the results of multiple downstream API calls to form a single, coherent response. Unlike a fixed BFF, API composition emphasizes the dynamic assembly of data based on the request's context or the consuming client's needs, often leveraging GraphQL, HATEOAS, or server-side composition libraries.
The Shift to API Composition
The core principle behind API composition as an alternative to traditional BFFs is to move away from rigid, client-specific backend services towards more dynamic, declarative, or configurable aggregation mechanisms. Instead of writing a new microservice (the BFF) for every new client or feature, we aim for systems that can compose the required data on demand.
Principles of API Composition
-
Declarative Data Fetching: Clients specify what data they need rather than how to get it. Tools like GraphQL excel here, allowing the frontend to define the exact data shape, thus avoiding over-fetching or under-fetching.
-
Stateless Aggregation Logic: The composition logic should ideally be stateless and reusable. This reduces complexity and improves scalability compared to managing state within numerous distinct BFF instances.
-
Flexible Transformation: While aggregation brings data together, transformation ensures it's in the right format. API composition should facilitate flexible, often client-driven, transformation capabilities.
-
Decoupling: Maintain a clear separation of concerns. Backend services focus on their domain, and the composition layer focuses on assembling responses.
Implementation Strategies and Code Examples
There are several ways to implement API composition, each with its trade-offs.
1. GraphQL Gateway
GraphQL is perhaps the most prominent example of API composition. A single GraphQL endpoint can serve as a composition layer, allowing clients to query multiple underlying microservices through a unified schema.
Example Scenario: An e-commerce frontend needs to display a product's details, its reviews, and the user's order history for that product. These might come from ProductService
, ReviewService
, and OrderService
respectively.
Traditional BFF Approach:
A ProductBFF
service would:
- Call
ProductService
to get product details. - Call
ReviewService
to get reviews for the product. - Call
OrderService
(with user context) to get order history for the product. - Combine and return the data.
API Composition with GraphQL:
First, define a GraphQL schema that aggregates types from your microservices:
# Product Service Schema (simplified) type Product { id: ID! name: String! description: String price: Float # ... other product fields } # Review Service Schema (simplified) type Review { id: ID! productId: ID! rating: Int! comment: String # ... other review fields } # Order Service Schema (simplified) type OrderItem { productId: ID! quantity: Int! # ... other order item fields } type Query { product(id: ID!): Product # ... other queries } # Extend Product with reviews and user orders extend type Product { reviews: [Review!] userOrders: [OrderItem!] }
Then, implement resolvers in your GraphQL gateway/server to fetch data from the respective microservices:
// Example in Node.js with Apollo Server const { ApolloServer, gql } = require('apollo-server'); const axios = require('axios'); // For making HTTP requests to microservices const typeDefs = gql` # ... schema definition from above ... `; const resolvers = { Query: { product: async (_, { id }) => { const response = await axios.get(`http://product-service/products/${id}`); return response.data; }, }, Product: { reviews: async (product) => { const response = await axios.get(`http://review-service/reviews?productId=${product.id}`); return response.data; }, userOrders: async (product, _, { userId }) => { // userId from context/authentication const response = await axios.get(`http://order-service/orders?userId=${userId}&productId=${product.id}`); return response.data; }, }, }; const server = new ApolloServer({ typeDefs, resolvers, context: ({ req }) => { // Extract userId from authentication token, for example const userId = req.headers.authorization || ''; return { userId }; }, }); server.listen().then(({ url }) => { console.log(`🚀 Server ready at ${url}`); });
Frontend Query: The frontend can then issue a single GraphQL query:
query ProductDetails($productId: ID!) { product(id: $productId) { id name description reviews { rating comment } userOrders { quantity } } }
This approach centralizes the composition logic in the GraphQL layer. The frontend gets exactly what it asks for, reducing network payload and simplifying client-side data handling.
2. Server-Side Composition (Proxy/Gateway with Smart Orchestration)
This pattern involves an intelligent proxy or API gateway that can orchestrate calls to multiple services and combine their responses. Unlike a simple proxy, this gateway understands the data model and can reconstruct a unified response. This is often leveraged with frameworks that support declarative routing and response transformation.
Example Scenario: Similar to the above, but instead of GraphQL, we use a Gateway that intelligently stitches together REST responses.
Implementation (Conceptual using e.g., Apache Camel, Spring Cloud Gateway with custom filters):
Consider a Gateway that receives a request like /api/v1/product-rich-info/{productId}
.
// Spring Cloud Gateway Predicate/Filter example (conceptual) @Configuration public class GatewayConfig { @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("product_rich_info_route", r -> r.path("/api/v1/product-rich-info/{productId}") .filters(f -> f.filter(new ProductRichInfoCompositionFilter())) // Custom filter .uri("lb://product-service")) // Initial call to product service .build(); } // Custom GatewayFilter to compose data public class ProductRichInfoCompositionFilter implements GatewayFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { // First, let the request proceed to the product-service return chain.filter(exchange).then(Mono.defer(() -> { ServerHttpResponse response = exchange.getResponse(); // Capture the product data from the initial response // (requires custom BodyCaptureFilter or similar to intercept response body) // For simplicity, let's assume we can get the product ID from the path String productId = exchange.getRequest().getPath().pathWithinApplication().value().split("/")[4]; // Make subsequent calls to review-service and order-service // (using WebClient in a non-blocking fashion) Mono<ProductData> productMono = /* ... extract product data from initial response ... */; Mono<List<ReviewData>> reviewsMono = WebClient.builder().build() .get().uri("http://review-service/reviews?productId=" + productId) .retrieve().bodyToFlux(ReviewData.class).collectList(); Mono<List<OrderItemData>> ordersMono = WebClient.builder().build() .get().uri("http://order-service/orders?userId=someUserId&productId=" + productId) // UserID from JWT/session .retrieve().bodyToFlux(OrderItemData.class).collectList(); // Combine results return Mono.zip(productMono, reviewsMono, ordersMono) .flatMap(tuple -> { ProductData product = tuple.getT1(); List<ReviewData> reviews = tuple.getT2(); List<OrderItemData> orders = tuple.getT3(); // Create a combined JSON response Map<String, Object> combinedResponse = new HashMap<>(); combinedResponse.put("product", product); combinedResponse.put("reviews", reviews); combinedResponse.put("userOrders", orders); // Write combined response back to the client byte[] bytes = new Gson().toJson(combinedResponse).getBytes(StandardCharsets.UTF_8); DataBuffer buffer = response.bufferFactory().wrap(bytes); response.getHeaders().setContentLength(bytes.length); return response.writeWith(Mono.just(buffer)); }); })); } } }
This approach, while more complex to set up than a simple proxy, centralizes the composition logic within a robust gateway. It allows for dynamic assembly without requiring a whole new BFF service.
Application Scenarios
API composition patterns are particularly well-suited for:
- Microservice Architectures: Where data is inherently distributed across many specialized services.
- Rapid UI Development: Allows frontends to quickly adapt to changing data requirements without waiting for backend changes in numerous BFFs.
- Omnichannel Experiences: Provides a single, unified data access layer that can be leveraged by web, mobile, and other clients, leading to more consistent data models.
- Public APIs with Flexible Data Needs: When you offer an API to third-party developers who might have diverse data requirements.
- Reducing Backend Duplication: Avoids writing similar aggregation logic across multiple BFF services.
Conclusion
While the traditional Backend for Frontend (BFF) pattern has served as a valuable mechanism for frontend data aggregation, its limitations in flexibility, scalability, and maintainability for complex systems can become evident. By embracing API composition patterns, particularly through technologies like GraphQL or sophisticated API gateways, developers can achieve a more dynamic and less prescriptive approach to data aggregation. This shift empowers frontend teams with greater control over data fetching, reduces backend development overhead, and ultimately leads to more adaptable, robust, and performant application architectures. API composition streamlines data delivery, making frontend aggregation truly flexible and efficient.