GraphQL Federation with Java subgraphs, without the N+1
Three Spring Boot services, one graph, one query. How entity resolution, DataLoaders and resolver timing work in a federated Java setup — and the N+1 I left in on purpose to find.
When I worked on GraphQL APIs that sat in front of several backend services, the hard part was never the query language. It was the joins: one screen needs a product from one service, its reviews from another and each reviewer’s name from a third. Do that in a single GraphQL server and you get a “god schema” that every team must change. Do it naively across services and you get a storm of small requests.
Apollo Federation is one answer: each team owns a subgraph for its own types, and a router composes them into one supergraph. I built graphql-federation-playground to understand the patterns you hit in production, with Java subgraphs instead of the usual Node examples.
The setup
| Service | Port | Owns | Extends |
|---|---|---|---|
user-subgraph |
8081 | User |
— |
product-subgraph |
8082 | Product |
User with products |
review-subgraph |
8083 | Review |
Product with reviews, averageRating, reviewCount; User with reviews |
| Apollo Router | 4000 | the supergraph | — |
Each subgraph is a Spring Boot 3 app using the Netflix DGS framework. A single docker compose up boots all three, the router and Jaeger.


Entities: a type with a key
The idea that makes federation work is the entity: a type that several subgraphs can contribute fields to, identified by a key. The review subgraph doesn’t own Product, but it can add fields to it:
type Product @key(fields: "id") @extends {
id: ID! @external
reviews: [Review!]!
averageRating: Float
reviewCount: Int!
}
When a client asks for a product’s name and its reviews, the router first fetches the product from the product subgraph, then sends the review subgraph an _entities query containing just { __typename: "Product", id: "p1" }. The review subgraph turns that key into an object and resolves the fields it owns:
@DgsEntityFetcher(name = "Product")
public Product fetchProductEntity(Map<String, Object> values) {
return new Product((String) values.get("id"));
}
The reverse direction is equally small. A Review stores only an authorId, so the review subgraph returns a reference and lets the router fetch the rest from the user subgraph:
@DgsData(parentType = "Review", field = "author")
public Map<String, Object> author(DataFetchingEnvironment env) {
Review review = env.getSource();
return Map.of("__typename", "User", "id", review.getAuthorId());
}
No service calls another service directly. Ownership stays clean: the user team can change how users are stored without the review team noticing.
The N+1 problem, multiplied
Here’s the catch. Ask for 50 products with their reviews and the router sends one _entities request with 50 product keys. If Product.reviews runs a repository query per product, that’s 50 queries for one request — and in federation the pattern repeats at every hop.
The fix is a DataLoader: resolvers ask for a key, the loader collects every key requested in the same execution tick, and fetches them in one batch.
@DgsDataLoader(name = "reviewsByProduct")
public class ReviewsByProductDataLoader implements BatchLoader<String, List<Review>> {
@Override
public CompletionStage<List<List<Review>>> load(List<String> productIds) {
return CompletableFuture.supplyAsync(() -> {
Map<String, List<Review>> byProduct =
reviewRepository.findByProductIds(Set.copyOf(productIds));
// results must come back in the same order as the keys
return productIds.stream()
.map(id -> byProduct.getOrDefault(id, List.of()))
.toList();
}, executor);
}
}
The resolver then becomes a one-liner returning a future:
@DgsData(parentType = "Product", field = "reviews")
public CompletableFuture<List<Review>> productReviews(DataFetchingEnvironment env) {
Product product = env.getSource();
return env.<String, List<Review>>getDataLoader("reviewsByProduct").load(product.id());
}
Two details worth calling out: the batch function must return results in key order (a missing product gets an empty list, not a gap), and keys are de-duplicated before they hit the repository.
Measure every resolver
I added a ResolverTimingInstrumentation to each subgraph. It extends graphql-java’s SimplePerformantInstrumentation, times every field fetch, records a Micrometer timer graphql.resolver.duration tagged with parent type, field and status, and logs anything slower than a threshold (observability.graphql.slow-threshold-ms, default 50 ms) at WARN.
That is how I found the N+1 that’s still in the code — and I’ve left it there as the example for this post.
The N+1 I didn’t fix (yet)
Look at averageRating and reviewCount:
@DgsData(parentType = "Product", field = "averageRating")
public Double averageRating(DataFetchingEnvironment env) {
Product product = env.getSource();
List<Review> reviews = reviewRepository.findByProductId(product.id());
...
}
Both call the repository per product. Request reviews, averageRating and reviewCount for 50 products and you get one batched query plus 100 individual ones. The timing logs show it immediately: the batched field takes one slow-ish call, the other two fields take many fast ones that add up.
The fix is to derive both from the same DataLoader — load the reviews once per product and compute the average and count from the cached list. DataLoaders cache per request, so the second and third field reuse the first fetch for free.
What I’d do differently in production
- Bound the DataLoader thread pool. The playground uses
Executors.newCachedThreadPool(), which grows without limit under load. Use a fixed pool sized to your database connection pool. - Add query cost limits at the router. Federation makes deep queries cheap to write and expensive to run. Depth and cost limits belong at the edge.
- Treat the supergraph schema as a contract. Run schema composition checks in CI for every subgraph change, so a breaking change fails a build instead of the router.
Takeaways
- Federation is about ownership: each team owns its types and contributes fields to shared entities by key.
- Every field that crosses an entity boundary needs a DataLoader, or your API slows down as lists grow.
- Per-resolver timing turns “the API feels slow” into a specific field name.
The full playground — three subgraphs, router config, subscriptions and tests — is on GitHub.