Files
asmhatre baff043748 Add graphql-dataloader: Spring GraphQL 2.0 DataLoader batching vs naive N+1, and non-null propagation on a dangling FK
- naive @SchemaMapping resolver: 6 statements (5 books/5 authors), 21 statements (20 books/5 authors)
- batched @BatchMapping resolver: flat 2 statements in both cases, via DataLoader + .distinct()
- dangling authorId nulls the entire GraphQL response via non-null propagation, byte-identical under both resolver strategies
- 6-test suite over real HTTP against a live embedded Tomcat instance, SQL captured via a JDK dynamic proxy (StatementLoggingDataSource, reused from sdjpa4-demo)
- docs/05: two Boot 4.1 packaging changes hit along the way (DataSourceAutoConfiguration's new package, Jackson 3 by default)
- root README: add row for graphql-dataloader; fix openapi-versioning's placeholder link now that post 7477 is live
2026-09-17 19:57:41 +00:00

3.7 KiB

The naive resolver, and exactly how it becomes N+1

NaiveAuthorResolver is the resolver nobody would flag in code 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);
    }
}

Source: resolver/NaiveAuthorResolver.java

One method, one repository call, correct output for any individual Book. @SchemaMapping targets a specific field of a specific type (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. If the query asks for 5 books, this method runs 5 times; if it asks for 20, it runs 20 times. That's not a framework limitation being worked around badly, it's simply what @SchemaMapping is: a per-object field resolver, full stop.

The evidence, at two scales

Test class: NaiveResolverSqlLogTest, run with @ActiveProfiles("naive"). Every query in these transcripts came from a real HTTP POST to /graphql on a live, randomly-ported embedded Tomcat instance under @SpringBootTest, captured by the JDK dynamic proxy in StatementLoggingDataSource — the same mechanism from the sdjpa4-demo companion project — not from Hibernate's own SQL logging, so what's counted is what actually reached H2.

5 books, 5 distinct authors (a_fiveBooksFromFiveDistinctAuthorsIssueOneAuthorQueryPerBook):

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

Source: docs/output/naive-a-five-distinct-authors.txt

One books query, five author queries. That's the "N+1" in its cleanest form: 1 query for the list, N queries for the thing every item in the list needs.

20 books, but only 5 distinct authors (b_twentyBooksFromFiveAuthorsStillIssuesTwentyAuthorQueries) — each author now has 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=?
...
21. select a1_0.id,a1_0.name from author a1_0 where a1_0.id=?

total statements: 21

Full transcript: docs/output/naive-b-twenty-books-five-authors.txt

This is the case worth sitting with. There are only 5 distinct authors involved — a batched lookup could satisfy the whole request with a single WHERE id IN (...) query. The naive resolver issues 21 statements anyway, because findById() inside the per-Book callback 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. That's the concrete cost @BatchMapping fixes in docs/03, and the .distinct() call that makes the fix worth having.