Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1
A naive @SchemaMapping resolver measured at 21 SQL statements for 20 books versus a @BatchMapping resolver’s flat 2, via DataLoader batching and one .distinct() call. Plus a dangling foreign key nulling an entire GraphQL response through non-null propagation, byte-identical under both resolver strategies.
A staging environment I looked at recently had SQL query logging turned on for a week, mostly to catch slow queries before a launch. Nobody was looking for a GraphQL problem specifically. But the query log for one screen — a list of maybe 30 books with their authors shown inline — had 31 SELECT statements against it, every single load. Thirty of them were identical in shape, different only in the id in the WHERE clause. The GraphQL resolver responsible was a single, unremarkable method: given a book, look up its author. Nothing about it looked wrong in a code review, because in isolation it wasn’t wrong — it did exactly what it was asked to do, once per book, correctly, every time. I built a small Spring Boot 4.1 module to reproduce that shape on purpose, count the SQL statements two different resolver strategies actually send, and separately check what happens when the data underneath one of those resolvers is missing entirely. Companion project: asmhatre/spring-boot-demo/graphql-dataloader, where a 6-test suite hits a real running instance over real HTTP and every statement count and error message below is quoted from a transcript under docs/output/.
Versions. Spring Boot 4.1.1, Spring Framework 7.0.9, Spring GraphQL 2.0.5, GraphQL Java 25.0 with java-dataloader 6.0.0 — all read directly off this module’s own Maven dependency tree against the Boot 4.1.1 BOM, not copied from a docs page — JDK 25 (Temurin 25.0.4.1+1). The schema is two types, Book and Author, with Book.author as the one relationship in the whole module — deliberately not mapped as a JPA @ManyToOne, so the only thing controlling when an Author gets loaded is whichever GraphQL resolver is wired up for that field.
The naive resolver, and exactly how it becomes N+1
The resolver nobody would flag in review:
@Profile("naive")
@Controller
public class NaiveAuthorResolver {
private final AuthorRepository authorRepository;
public NaiveAuthorResolver(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@SchemaMapping(typeName = "Book", field = "author")
public Author author(Book book) {
return authorRepository.findById(book.getAuthorId()).orElse(null);
}
}
Full source: resolver/NaiveAuthorResolver.java. One method, one repository call, correct output for any individual Book. @SchemaMapping targets a specific field of a specific type — here, Book.author — and Spring GraphQL calls that method once per Book instance GraphQL Java needs to resolve the field for, independently, with no visibility into what the other invocations are doing. Query 5 books, it runs 5 times. Query 20, it runs 20 times.
A real HTTP POST to /graphql against a live, randomly-ported embedded Tomcat instance, with 5 books each by a different author, captured through a JDK dynamic proxy sitting in front of the JDBC driver — the same mechanism from the sdjpa4-demo companion project, counting what actually reaches H2, not what Hibernate’s own SQL logging chooses to print:
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
total statements: 6
From docs/output/naive-a-five-distinct-authors.txt. One query for the list, five for the thing every item in the list needs — N+1 in its cleanest form. The case worth sitting with is the next one: 20 books, but only 5 distinct authors, each author credited on 4 books:
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
3. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
4. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
5. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
6. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
7. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
8. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
9. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
10. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
11. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
12. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
13. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
14. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
15. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
16. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
17. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
18. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
19. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
20. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
21. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?
total statements: 21
From docs/output/naive-b-twenty-books-five-authors.txt. There are only 5 distinct authors involved here — a batched lookup could satisfy the whole request with one WHERE id IN (...) query. The naive resolver issues 21 statements anyway, because the findById() call inside it has no way to know that the author it’s about to fetch for book #6 is the same author it already fetched for book #2. Repetition in the data doesn’t help this resolver at all: it costs exactly as much as 20 books by 20 different authors would have.
@BatchMapping: the same field, one annotation different
BatchedAuthorResolver answers the exact same schema field with a method that takes every pending Book at once instead of one at a time:
@Profile("batched")
@Controller
public class BatchedAuthorResolver {
private final AuthorRepository authorRepository;
public BatchedAuthorResolver(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@BatchMapping(typeName = "Book", field = "author")
public Map<Book, Author> author(List<Book> books) {
List<Long> authorIds = books.stream().map(Book::getAuthorId).distinct().toList();
Map<Long, Author> byId = authorRepository.findAllById(authorIds).stream()
.collect(Collectors.toMap(Author::getId, a -> a));
Map<Book, Author> result = new HashMap<>();
for (Book book : books) {
Author author = byId.get(book.getAuthorId());
if (author != null) {
result.put(book, author);
}
}
return result;
}
}
Full source: resolver/BatchedAuthorResolver.java. Three things changed, and only one of them is the annotation. @BatchMapping instead of @SchemaMapping registers this method as a batch loading function for Book.author and wires it into a per-request DataLoader automatically — no BatchLoaderRegistry bean to declare for this shortcut form, the annotation alone is enough. GraphQL Java’s execution engine collects every pending Book.author resolution for the current batch before calling this method once with the full list, rather than calling a per-object resolver N times. The method signature takes List<Book> and returns Map<Book, Author>, and the DataLoader machinery matches each key back to its value. And authorIds is built with .distinct() before it ever reaches the repository — the line that turns “one query instead of N” into “one query for the distinct authors involved,” no matter how many books repeat the same one.
Same two experiments as above, same seed data, only the active Spring profile — and therefore which resolver bean is wired to Book.author — different:
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
total statements: 2
5 books, 5 distinct authors — from docs/output/batched-a-five-distinct-authors.txt. Six statements became two: the books query is identical, and the five separate author.id=? lookups collapsed into a single author.id in (?,?,?,?,?). And with the 20-books-5-authors case that produced 21 statements above:
1. select b1_0.id,b1_0.author_id,b1_0.title from book b1_0
2. select a1_0.id,a1_0.name from author a1_0 where a1_0.id in (?,?,?,?,?)
total statements: 2
From docs/output/batched-b-twenty-books-five-authors.txt — the same two statements, in fact, with the IN clause still carrying exactly 5 placeholders (confirmed directly in the test by counting the ? characters in the captured SQL). Where the naive resolver went from 6 statements to 21 as the book count quadrupled with no new authors, the batched resolver’s query count didn’t move.
5 books / 5 authors
20 books / 5 authors
naive (@SchemaMapping)
6 statements
21 statements
batched (@BatchMapping)
2 statements
2 statements
That table is the whole argument in one place: the naive resolver’s cost is a function of how many books are in the result; the batched resolver’s cost is a function of how many distinct authors are in the result — very often a much smaller, much more slowly growing number.
A dangling foreign key and GraphQL’s non-null propagation
The schema underneath both resolvers is small and strict:
type Book {
id: ID!
title: String!
author: Author!
}
author: Author! — non-null. The module’s schema.sql puts no foreign key constraint on book.author_id, which makes it possible to insert a Book whose authorId matches no row in AUTHOR at all — a data integrity problem a real foreign key would normally prevent, but one that’s entirely possible in a system that doesn’t enforce referential integrity at the database level, or mid-migration, or from a bad import. Seed one good book and one orphaned one, run the same query as above, and this is the raw HTTP response body, quoted exactly:
{"errors":[{"message":"The field at path '/books[1]/author' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value. The graphql specification requires that the parent field be set to null, or if that is non nullable that it bubble up null to its parent and so on. The non-nullable type is 'Author' within parent type 'Book'","path":["books",1,"author"],"extensions":{"classification":"NullValueInNonNullableField"}}],"data":null}
From docs/output/naive-c-dangling-foreign-key-null-propagation.txt. Read that message once, slowly — it names its own mechanism. A non-null field that resolves to null doesn’t just fail that one field; the null has to go somewhere, and the rule is that it climbs to the nearest ancestor field in the response that’s actually allowed to be null. Here the chain is author (non-null) inside Book (a non-null element of a non-null list, [Book!]!) inside books (non-null). Nothing between the failure and the root is nullable, so the null keeps climbing until it reaches the top: data itself becomes null, and the one broken book takes the working ones down with it. errors is populated; data isn’t partial, it’s entirely absent.
The batched resolver’s contribution to this is a design choice worth calling out: it omits a map entry for a Book whose author lookup misses, rather than storing an explicit null against the key. Running the identical experiment against the batched profile confirms that choice doesn’t matter — the response is byte-for-byte identical, same message, same path, same classification. Spring GraphQL treats a missing DataLoader/batch result exactly the same as an explicit null for that source object, so the null-propagation behavior lives entirely in GraphQL Java’s execution engine, not in either resolver strategy — switching from naive to batched buys better query counts without changing this failure mode at all.
Non-null propagation is a deliberate, spec-mandated safety property, not a bug: it exists so a client can trust that a non-null field, if present in a response, is genuinely never null, which simplifies every consumer of the API. The cost is that a single bad row — one dangling foreign key out of however many rows exist — is enough to null out an entire query’s data, including the parts whose own data was completely fine. The practical response, in a schema where “we don’t know the author” is a real possibility, is usually one of: make the field nullable if that’s a legitimate state; enforce the foreign key at the database layer so the dangling reference can’t be created; or have the resolver return a sentinel “unknown author” object instead of null. This module deliberately does none of those, so the failure mode stays visible instead of getting designed around.
A packaging surprise along the way, unrelated to GraphQL
None of this is about DataLoader, but it cost real debugging time building this module and is worth a paragraph since it will hit anyone else moving code to Spring Boot 4.1 for the first time. The very first build failed with package org.springframework.boot.autoconfigure.jdbc does not exist — the import for DataSourceAutoConfiguration, used here (as in the sdjpa4-demo companion project) to stop Boot’s own auto-configuration from building a competing DataSource. Listing the actual spring-boot-autoconfigure-4.1.1.jar confirms why: as of Boot 4.1, that artifact only contains generic auto-configuration infrastructure. The per-feature auto-configuration classes moved into their own artifacts with new packages — DataSourceAutoConfiguration specifically now lives in spring-boot-jdbc-4.1.1.jar, at org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration, the jdbc and autoconfigure segments swapped relative to the Boot 3 package. Separately, the first test class that touched ObjectMapper directly failed to compile with package com.fasterxml.jackson.databind does not exist — Boot 4.1’s default Jackson integration is Jackson 3, whose jackson-core and jackson-databind moved to the tools.jackson.core Maven group and the tools.jackson.databind Java package, a deliberate break from the old com.fasterxml.jackson.* namespace (jackson-annotations is the one piece that stayed put, since annotations are largely compatible across the 2.x/3.x line). Both fixes were one-line import swaps, not code changes — the trap is only in the package name. Full detail, including the exact dependency-tree output that confirmed it: docs/05-known-issues.md.
What to actually do
If a GraphQL field resolves a relationship with a per-object @SchemaMapping, its query cost scales with the number of parent objects in the result, not with how much genuinely distinct data those objects need — 20 books by 5 repeated authors costs exactly as much as 20 books by 20 different ones.
Switching to @BatchMapping is close to a drop-in change — same field, same schema, a different method signature and a .distinct() call — and turns that per-object cost into a cost proportional to the distinct related entities actually present, with no BatchLoaderRegistry bean required for the shortcut form.
Neither strategy changes what happens when the underlying data is missing. A non-null field that can’t be resolved nulls out its nearest nullable ancestor, which in a deeply non-null schema can mean the entire response. If “unknown” is a real state your data can be in, model it as nullable, or fix it at the database layer, rather than discovering it in production from a client that’s confused about why data came back empty.
Further reading
The companion project — both resolvers, the null-propagation experiment, 6 tests, 6 transcripts, 5 documentation chapters
No Comments yet!