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

5.1 KiB

@BatchMapping: the same field, one annotation different

BatchedAuthorResolver answers the exact same schema field — Book.author — 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;
    }
}

Source: resolver/BatchedAuthorResolver.java

Three things changed relative to NaiveAuthorResolver, and only one of them is the annotation:

  1. @BatchMapping instead of @SchemaMapping. Spring GraphQL registers this method as a batch loading function for Book.author and wires it into a per-GraphQL-request DataLoader automatically — there's no BatchLoaderRegistry bean to declare for this shortcut form; the annotation alone is enough. Under the hood, GraphQL Java's execution engine collects every pending Book.author field resolution for the current batch of the current request before calling this method once with the full list, rather than calling a per-object resolver N times.
  2. The method signature takes List<Book> books, not a single Book, and returns Map<Book, Author> — the DataLoader machinery matches each Book key back to its Author value and resolves the individual GraphQL fields from that map.
  3. authorIds is built with .distinct() before it ever reaches the repository. This is the line that turns "one query instead of N" into "one query for the distinct authors involved," regardless of how many books repeat the same author. Without it, findAllById() would still be a single query, just one with duplicate IDs in its IN (...) list — correct, but not as tight as it could be.

The returned Map also deliberately omits an entry for any Book whose author lookup misses, rather than putting an explicit null value in for it. That choice doesn't change anything for the well-formed data in this chapter — it's the setup for docs/04, where a Book with no matching Author row shows that Spring GraphQL treats a missing map key exactly the same as an explicit null.

The evidence, same two scales as docs/02

Test class: BatchedResolverSqlLogTest, @ActiveProfiles("batched"), same seed helpers, same GraphQL query text, same real-HTTP mechanics as the naive test class — only the active profile (and therefore which resolver bean is wired to Book.author) differs.

5 books, 5 distinct authors (a_fiveBooksFromFiveDistinctAuthorsIssueOneBatchedAuthorQuery):

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

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

Six statements in the naive transcript became two. The books query is identical — same SQL, same one round trip — and the five separate author.id=? lookups collapsed into a single author.id in (?,?,?,?,?).

20 books, 5 distinct authors (b_twentyBooksFromFiveAuthorsDedupesToOneBatchedQueryForFiveAuthors):

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

Source: docs/output/batched-b-twenty-books-five-authors.txt

Still two statements — the same two, in fact, since the IN clause still has exactly 5 placeholders (asserted directly in the test by counting ? 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 at all. The .distinct() call is doing real work here: 20 pending DataLoader keys, 5 unique values, 1 query.

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 for @BatchMapping 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, which is very often a much smaller, much more slowly growing number.