Skip to main content

Conquering the N+1 Problem in Spring GraphQL with DataLoader

· 10 min read
GraphQL Guy

DataLoader Pattern

The N+1 problem can turn your elegant GraphQL API into a database nightmare. Learn how Spring GraphQL's batch loading solves this efficiently.

The N+1 Problem Revisited

Before diving into solutions, let's understand the problem clearly. Consider this query:

query BooksWithAuthors {
books { # 1 query to get 100 books
title
author { # 100 queries - one per book!
name
}
}
}

With a naive implementation, fetching 100 books triggers 101 database queries:

SELECT * FROM books;                   -- 1 query
SELECT * FROM authors WHERE id = 1; -- + 100 queries, one per book
SELECT * FROM authors WHERE id = 2;
SELECT * FROM authors WHERE id = 3;
-- ... 97 more queries

That's the N+1 problem: 1 query for the list, plus N queries for related items.

How Spring GraphQL Solves This

Spring GraphQL provides built-in support for batch loading through the @BatchMapping annotation and the BatchLoaderRegistry.

The simplest approach uses @BatchMapping:

@Controller
public class BookController {

private final BookRepository bookRepository;
private final AuthorRepository authorRepository;

@QueryMapping
public List<Book> books() {
return bookRepository.findAll();
}

@BatchMapping
public Map<Book, Author> author(List<Book> books) {
// Collect all author IDs
Set<String> authorIds = books.stream()
.map(Book::authorId)
.collect(Collectors.toSet());

// Single query for all authors
Map<String, Author> authorsById = authorRepository
.findAllById(authorIds)
.stream()
.collect(Collectors.toMap(Author::id, Function.identity()));

// Map each book to its author. Build the map with an explicit loop:
// Collectors.toMap rejects null values, and a book whose author row
// is missing would take down the whole batch. See Pitfall 2 below.
Map<Book, Author> result = new HashMap<>();
for (Book book : books) {
result.put(book, authorsById.get(book.authorId()));
}
return result;
}
}

Now the same query executes only 2 queries:

SELECT * FROM books;
SELECT * FROM authors WHERE id IN (1, 2, 3, ...);

Understanding @BatchMapping

The @BatchMapping annotation:

  1. Collects all instances that need the field resolved
  2. Calls your method once with the entire batch
  3. Maps results back to individual instances

Return Types for @BatchMapping

@BatchMapping supports several return types:

// Map - each source maps to one result
@BatchMapping
public Map<Book, Author> author(List<Book> books) { ... }

// Mono<Map> - async loading
@BatchMapping
public Mono<Map<Book, Author>> author(List<Book> books) { ... }

// For collections (one-to-many relationships)
@BatchMapping
public Map<Author, List<Book>> books(List<Author> authors) { ... }

Approach 2: BatchLoaderRegistry

For more control, register batch loaders manually:

@Configuration
public class GraphQLConfig {

@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer(
AuthorRepository authorRepository) {

return wiringBuilder -> wiringBuilder
.type("Book", builder -> builder
.dataFetcher("author", environment -> {
Book book = environment.getSource();
DataLoader<String, Author> loader =
environment.getDataLoader("authorLoader");
return loader.load(book.authorId());
}));
}

// Register against the auto-configured BatchLoaderRegistry bean
public GraphQLConfig(BatchLoaderRegistry registry,
AuthorRepository authorRepository) {

registry.forName("authorLoader")
.registerMappedBatchLoader((Set<String> ids, env) -> {
Map<String, Author> authors = authorRepository
.findAllById(ids)
.stream()
.collect(Collectors.toMap(Author::id, a -> a));

return Mono.just(authors);
});
}
}

Registering a Loader Class

If the loading logic is big enough to want its own class, put it in one and register it against the registry Spring GraphQL already created for you.

Note the signature Spring expects. BatchLoaderRegistry is a Reactor-flavoured API: registerBatchLoader takes a BiFunction<List<K>, BatchLoaderEnvironment, Flux<V>> and registerMappedBatchLoader takes one returning Mono<Map<K, V>>. It does not accept java-dataloader's BatchLoaderWithContext, so a class implementing that interface will not register directly. Write the method in the shape Spring wants and pass a method reference:

@Component
public class AuthorBatchLoader {

private final AuthorRepository authorRepository;

public AuthorBatchLoader(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}

// Mapped loader: return keys to values, and let missing keys be absent.
// This is the safer of the two shapes, because you never have to keep
// a result list aligned with the input list by hand.
public Mono<Map<String, Author>> loadAuthors(Set<String> authorIds,
BatchLoaderEnvironment env) {
return Mono.fromCallable(() -> authorRepository
.findAllById(authorIds)
.stream()
.collect(Collectors.toMap(Author::id, Function.identity())))
.subscribeOn(Schedulers.boundedElastic());
}
}

@Configuration
public class DataLoaderConfig {

// Inject the auto-configured registry and register into it.
public DataLoaderConfig(BatchLoaderRegistry registry,
AuthorBatchLoader authorBatchLoader) {

registry.forTypePair(String.class, Author.class)
.registerMappedBatchLoader(authorBatchLoader::loadAuthors);
}
}

The mapped variant is worth preferring. With the list-returning registerBatchLoader, you are responsible for returning exactly as many values as you were given keys, in the same order, which is the ordering pitfall below. With the mapped variant, a key without a matching row is simply absent from the map and the field resolves to null.

Don't declare your own BatchLoaderRegistry bean

It is tempting to write @Bean public BatchLoaderRegistry batchLoaderRegistry() { return registry -> ... }. That fails twice over. BatchLoaderRegistry declares forTypePair, forName and registerDataLoaders, so it is not a functional interface and the lambda does not compile. And if you make it compile some other way, your bean replaces the auto-configured DefaultBatchLoaderRegistry that Spring GraphQL hands to the execution service, so none of your loaders are ever registered and every batched field silently goes back to N+1.

Inject the registry, never define it.

Nested Batch Loading

What about nested relationships?

query BooksWithAuthorPublishers {
books {
title
author {
name
publisher { # Another level!
name
}
}
}
}

Just add another @BatchMapping:

@Controller
public class AuthorController {

@BatchMapping
public Map<Author, Publisher> publisher(List<Author> authors) {
Set<String> publisherIds = authors.stream()
.map(Author::publisherId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());

Map<String, Publisher> publishersById = publisherRepository
.findAllById(publisherIds)
.stream()
.collect(Collectors.toMap(Publisher::id, Function.identity()));

Map<Author, Publisher> result = new HashMap<>();
for (Author author : authors) {
// Authors with no publisher, and publisher IDs with no row,
// both land here as null. Collectors.toMap would reject that.
result.put(author, publishersById.get(author.publisherId()));
}
return result;
}
}

Spring GraphQL handles the execution order automatically.

One-to-Many Relationships

For collections, the return type changes slightly:

@Controller
public class AuthorController {

@QueryMapping
public List<Author> authors() {
return authorRepository.findAll();
}

@BatchMapping
public Map<Author, List<Book>> books(List<Author> authors) {
Set<String> authorIds = authors.stream()
.map(Author::id)
.collect(Collectors.toSet());

// Single query for all books by these authors
List<Book> allBooks = bookRepository.findByAuthorIdIn(authorIds);

// Group by author ID
Map<String, List<Book>> booksByAuthorId = allBooks.stream()
.collect(Collectors.groupingBy(Book::authorId));

// Map each author to their books
return authors.stream()
.collect(Collectors.toMap(
Function.identity(),
author -> booksByAuthorId.getOrDefault(author.id(), List.of())
));
}
}

Measuring It Yourself

The query count is the part you can reason about without measuring: 101 versus 2, for any batch size of 100. The wall-clock saving is not something anyone can quote at you honestly, because it depends entirely on your round-trip time to the database. On a local Postgres with sub-millisecond round trips the difference is modest. Across a network hop, 99 extra round trips is the whole cost of the request.

So measure it on your own infrastructure. This harness gives you the shape:

@SpringBootTest
class BatchLoadingPerformanceTest {

@Autowired
private GraphQlTester graphQlTester;

@Test
void measureQueryPerformance() {
// Setup: 100 books, 20 authors
long start = System.currentTimeMillis();

graphQlTester.document("""
query BooksWithAuthors {
books {
title
author {
name
}
}
}
""")
.execute()
.path("books").entityList(Book.class).hasSize(100);

long duration = System.currentTimeMillis() - start;
System.out.println("Query executed in: " + duration + "ms");
}
}
ApproachQueries for 100 booksWall clock
Naive (N+1)1011 round trip + 100 round trips
@BatchMapping22 round trips

Multiply your database round-trip time by 99 and you have your saving. That is the number worth putting in a pull request description, and it is one you can defend.

Caching Within a Request

DataLoader automatically caches within a request:

query BookAuthors {
book1: bookById(id: "1") {
author { name } # Loads author "A"
}
book2: bookById(id: "2") {
author { name } # Also loads author "A" - cached!
}
}

If both books have the same author, the author is fetched only once per request.

Important: This cache is per-request. New requests start with an empty cache.

Common Pitfalls

1. Returning Results in Wrong Order

DataLoader requires results in the same order as the input keys:

// WRONG - iterates the map, so order is undefined and
// no longer aligned with the input keys
Map<String, Author> authorsById = ...;
return new ArrayList<>(authorsById.values());

// RIGHT - iterate the input keys 'ids' to preserve their order.
// The map's own iteration order is irrelevant here because
// it's only ever queried by key.
return ids.stream()
.map(id -> authorsById.getOrDefault(id, null))
.toList();

2. Collecting Nulls With Collectors.toMap

Some IDs do not have a corresponding row: a deleted author, a bad foreign key, a race with a concurrent write. The natural way to write the batch mapping walks straight into a NullPointerException.

// Broken. Throws a NullPointerException the first time an author is missing.
return books.stream()
.collect(Collectors.toMap(
Function.identity(),
book -> authorsById.get(book.authorId()) // null for a missing author
));

The reason is worth knowing, because the obvious workaround does not help. Collectors.toMap is implemented over Map.merge, and Map.merge rejects null values by contract. Supplying a HashMap does not change that, even though a HashMap is perfectly happy to store a null value if you put one in directly:

// Also broken, for exactly the same reason. The map supplier is irrelevant.
return books.stream()
.collect(Collectors.toMap(
Function.identity(),
book -> authorsById.get(book.authorId()),
(a, b) -> a,
HashMap::new
));

Use a loop. It is shorter than the version that does not work, and it does what you meant:

@BatchMapping
public Map<Book, Author> author(List<Book> books) {
Map<String, Author> authorsById = loadAuthorsFor(books);

Map<Book, Author> result = new HashMap<>();
for (Book book : books) {
result.put(book, authorsById.get(book.authorId())); // null is fine
}
return result;
}

A null value in the returned map means the book does not have an author, which Spring GraphQL resolves to null for that field. If the schema declares author: Author!, that null propagates and the error surfaces where it should, at the field that made a promise it could not keep.

3. Not Using Indexes

Batch loading is only fast if the database query is fast:

-- authors(id) is the primary key, so it is already indexed.
-- The foreign key is the one that usually is not.
CREATE INDEX idx_books_author_id ON books(author_id);

The batched query is SELECT * FROM authors WHERE id IN (...), which uses the primary key index you already have. The index that earns its keep is on the other side: books(author_id), for the one-to-many direction where you look up every book belonging to a set of authors.

Advanced: Custom DataLoader Configuration

The most common thing you'll want to change is the maximum batch size, so that a query asking for 10,000 books doesn't produce an IN clause with 10,000 entries.

Options belong to a registration, not to the application context. Attach them where you register the loader:

@Configuration
public class DataLoaderConfig {

public DataLoaderConfig(BatchLoaderRegistry registry,
AuthorRepository authorRepository) {

registry.forTypePair(String.class, Author.class)
// The consumer receives a DataLoaderOptions.Builder
.withOptions(builder -> builder.setMaxBatchSize(100))
.registerMappedBatchLoader((Set<String> ids, env) ->
Mono.just(authorRepository.findAllById(ids).stream()
.collect(Collectors.toMap(Author::id, Function.identity()))));
}
}

Declaring a standalone @Bean public DataLoaderOptions ... does not configure anything here. Spring GraphQL never looks for such a bean, so the options are silently ignored and you get the defaults while believing you configured something. The withOptions hook on the registration is the one that takes effect.

Debugging Batch Loading

Add logging to see what's happening:

@BatchMapping
public Map<Book, Author> author(List<Book> books) {
log.debug("Batch loading authors for {} books", books.size());
log.debug("Author IDs: {}", books.stream()
.map(Book::authorId)
.distinct()
.toList());

// ... rest of implementation
}

Or enable SQL logging:

logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE

Summary

PatternUse CaseAnnotation
Single related objectBook.author@BatchMapping returning Map<Book, Author>
List of related objectsAuthor.books@BatchMapping returning Map<Author, List<Book>>
Manual controlComplex logicBatchLoaderRegistry

The N+1 problem is GraphQL's most common performance issue, but Spring GraphQL makes it easy to solve. Use @BatchMapping by default, and you'll have efficient queries without complex configuration.

Next: Testing Spring GraphQL - comprehensive strategies for unit and integration testing.


This post loaded all eleven of its sections in a single batch. The alternative was eleven round trips and a much longer afternoon.