Composition
Composing many back-ends behind one schema without coupling them — a GraphQL gateway that fans out to REST and gRPC, the resolver that makes it concrete, and the three places aggregation belongs.
A client rarely wants what a single service holds. It wants an order and its
live stock and its shipment status — data spread across services. The wrong fix
is to have order-service call inventory call pricing in a chain. The right
fix is to push the aggregation outward, to the edge, and keep the services
unaware of each other.
One query, many services
A GraphQL gateway presents one schema to clients and fans out to REST and gRPC back-ends, then joins and shapes the result. The client makes a single round trip and asks for exactly the fields it needs — no more, no less.
# the gateway's schema — one contract for every client
type Query {
order(id: ID!): Order
}
type Order {
id: ID!
sku: String!
status: String!
stock: Int! # resolved on demand from inventory-service
}
A client asks one question:
query {
order(id: "A-1001") {
sku
status
stock # the gateway fans this field out to inventory-service
}
}
The crucial property: the services stay unaware of each other. The gateway owns the composition.
The resolver makes it concrete
Federation stops being abstract at the resolver. The top-level order query
calls order-service over REST; the Order.stock field resolves on demand by
calling inventory over gRPC. Each field resolves against its owning service —
the gateway is only the composition layer.
@Controller // spring-boot-starter-graphql
public class OrderGraphQLController {
private final OrderClient orderClient;
private final InventoryGrpcClient inventory;
public OrderGraphQLController(OrderClient o, InventoryGrpcClient i) {
this.orderClient = o; this.inventory = i;
}
@QueryMapping // REST call to order-service
public Order order(@Argument String id) {
return orderClient.get(id);
}
@SchemaMapping(typeName = "Order", field = "stock") // resolved on demand
public Integer stock(Order order) { // via gRPC
return inventory.remaining(order.sku());
}
}
@GraphQLApi // the gateway's schema (SmallRye GraphQL)
public class OrderApi {
@Query // REST call to order-service
public Order order(String id) {
return orderClient.get(id);
}
// @Source adds a 'stock' field to Order, fetched only when queried
public Integer stock(@Source Order order) { // via gRPC
return inventory.reserveStockView(order.sku()).getRemaining();
}
}
// SmallRye GraphQL exports the SDL to the registry automatically.
// HotChocolate (MIT, by ChilliCream) — the dominant open-source GraphQL
// server for .NET, with a built-in DataLoader to batch away N+1.
public class Query // the gateway's schema
{
public async Task<Order?> GetOrder(string id, // REST call to order-service
[Service] IOrderClient orders) =>
await orders.GetAsync(id);
}
[ExtendObjectType<Order>] // adds fields to Order
public class OrderExtensions
{
// batched per request to kill N+1: one gRPC call for N orders
public async Task<int> GetStock(
[Parent] Order order,
IInventoryStockDataLoader loader) =>
await loader.LoadAsync(order.Sku); // via gRPC, batched
}
import strawberry # modern, code-first GraphQL for Python
@strawberry.type
class Order:
id: str; sku: str; status: str
@strawberry.field # resolved on demand, from another service
async def stock(self) -> int:
reply = await inventory_client.reserve_stock_view(self.sku) # gRPC
return reply.remaining
@strawberry.type
class Query:
@strawberry.field
async def order(self, id: str) -> Order:
return await order_client.get(id) # REST call to order-service
schema = strawberry.Schema(query=Query) # SDL exported to the registry
// schema.graphql is the contract — schemagen produces these types:
// type Query { order(id: ID!): Order }
// type Order { id: ID! sku: String! status: String! stock: Int! }
class OrderResolver : public object::Order {
public:
OrderResolver(Order o, InventoryClient& inv) : o_(std::move(o)), inv_(inv) {}
service::AwaitableScalar<std::string> getId() const { co_return o_.id; }
service::AwaitableScalar<std::string> getSku() const { co_return o_.sku; }
service::AwaitableScalar<std::string> getStatus() const { co_return o_.status; }
service::AwaitableScalar<int> getStock() const { // resolved on demand, via gRPC
auto reply = co_await inv_.ReserveStock_view(o_.sku);
co_return reply.remaining();
}
private:
Order o_;
InventoryClient& inv_;
};
// schema.resolvers.go — gqlgen generates the resolver stubs from graph/schema.graphqls:
// type Query { orders(limit: Int = 50): [Order!]! order(id: String!): Order }
// type Order { id: String! sku: String! quantity: Int! status: String! stock: Int }
func (r *queryResolver) Orders(ctx context.Context, limit *int) ([]*model.Order, error) {
var orders []*model.Order
if err := r.getJSON(ctx, r.OrderAPIURL+"/orders", &orders); err != nil { // REST call to order-api
return nil, err
}
r.attachStock(ctx, orders)
return orders, nil
}
// Order.stock is only fetched when the query selects it, and one GetStockBatch gRPC call
// covers every order in the response, which is how the N+1 fan-out is killed.
func (r *Resolver) attachStock(ctx context.Context, orders []*model.Order) {
if !slices.Contains(graphql.CollectAllFields(ctx), "stock") {
return // no "stock" in the selection set: no inventory call
}
stock, _ := r.stockFor(ctx, skusOf(orders)) // one batched gRPC call
for _, o := range orders {
s := int(stock[o.Sku])
o.Stock = &s
}
}
How the code works
The top-level order resolver does a REST call; the stock field resolver does a
gRPC call, and only runs when the client asks for stock. That on-demand
property is the point of GraphQL — unrequested fields cost nothing.
The trap to know is N+1: resolve stock naively for a list of 100 orders and
you fire 100 gRPC calls. Production gateways batch with a DataLoader — collect
the keys requested in one tick, make one batched back-end call. HotChocolate ships
one; Strawberry, the JVM servers, and the others have equivalents. When you write
a per-field resolver that calls another service, assume you will need batching.
Three ways to compose — and where each belongs
The resolver is one shape of a more general idea. There are three, and all three push aggregation outward — toward the client as coupling needs loosen.
- API gateway — one composed schema for everyone. Simple, central, one contract; it routes, authenticates, and rate-limits at the edge (our Istio ingress) but holds no business logic.
- Backend-for-frontend (BFF) — a tailored aggregate per client type, so the web and mobile apps each get a surface shaped for them rather than a lowest-common-denominator one. The BFF owns the aggregation for its client.
- GraphQL federation — a distributed graph, where each service owns its slice of one logical schema and a gateway plans and stitches the query across subgraphs.
Pick by how much each client’s needs diverge: a gateway when one shape serves everyone, a BFF when clients differ enough to deserve their own surface, and federation when many domains must present as one graph and reads dominate.
The anti-pattern to name explicitly is the opposite of all three:
service-to-service call chains, where order calls inventory calls
pricing calls… — synchronous, deeply coupled, and impossible to reason about. It
quietly rebuilds a distributed monolith. Composition belongs at the edge, not
buried in a chain of internal calls.
Composition through the coupling lens
Appendix G lays out Khononov’s three coupling dimensions — strength, distance, and volatility — and a four-rung strength ladder: intrusive, functional, model, contract. Every composition choice in this chapter maps directly onto this model.
The gateway keeps inter-service coupling at the contract rung: services expose schemas (OpenAPI, proto, GraphQL SDL), and the gateway composes contracts, never internals. No service knows another service’s database or in-memory model — exactly what “contract strength” means. By contrast, the call-chain anti-pattern drifts toward functional coupling: service A doesn’t just consume a contract from service B, it depends on B’s behavior — its timing, its side effects, its availability — because every hop in the chain is a synchronous call whose failure propagates upward.
Distance matters too. A gateway adds one hop between the client and the back-end — a small increase in distance — but that hop lowers strength, because the services behind it don’t know each other. A BFF increases distance further (client → BFF → services) but buys client-specific shaping, which means the coupling between the client and the back-end is shaped exactly to what that client needs, nothing more. Federation distributes the composition across subgraphs, each owning its slice — the gateway plans the query, and each subgraph exposes only its contract.
The general rule from the appendix applies: the greater the distance between components, the further down the strength ladder the coupling must be. Composition at the edge naturally satisfies this — the gateway is far from the services (high distance), and it couples only to their contracts (low strength). A call chain violates it — services that are far apart couple at the functional level, which is exactly why it becomes a distributed monolith.
Cross-check it yourself
The gateway is just HTTP. Send the same query with curl or Postman and request
stock in one call, then omit it in another — confirm the gateway only calls
inventory when stock is actually selected. Then request a list of orders with
stock and watch the back-end call count: without batching it climbs with the
list size; with a DataLoader it stays flat. That flat line is the N+1 fix working.
The code is in examples/03-composition/. The run script there builds and runs it;
its README.md covers what it does and how to drive it.
Verification status: verified — examples/03-composition/ passes 8/8 checks in each of the six languages on Docker Engine (2026-10-10)
(GraphQL gateway fan-out to the REST order API and the gRPC stock service, selective stock-field resolution, DataLoader batching of stock lookups into one gRPC call, single-order lookup).