How do you register and use a DataLoader via BatchLoaderRegistry in Spring for GraphQL, and how does a resolver consume it?
answer
- forTypePair(K,V).registerMappedBatchLoader
- fresh DataLoaderRegistry per request
- load(key) → return CompletableFuture, never .get()
- inject DataLoader arg or env.getDataLoader(name)
- mapped=keyed (preferred), batch=positional
basics
~20 sInject the BatchLoaderRegistry bean and register a batch function for a key/value type pair (registerMappedBatchLoader or registerBatchLoader). Spring adds that DataLoader to each request. A @SchemaMapping resolver then calls dataLoader.load(key) and returns the CompletableFuture; Spring dispatches all keys in one batched call.
solid answer
~40 sBatchLoaderRegistry is a Spring-provided bean where you register batch loading functions once at startup. You call registry.forTypePair(Long.class, Author.class) and then either registerMappedBatchLoader((ids, env) -> Mono<Map<Long,Author>>) for keyed results or registerBatchLoader((ids, env) -> Flux<Author>) for positional. Spring wires each registered loader into a fresh DataLoaderRegistry per GraphQL request. In the field resolver you obtain the DataLoader — either as a typed @SchemaMapping method argument (Spring resolves it by the registered value type) or via env.getDataLoader(name) — and call load(key), returning the resulting CompletableFuture. The engine defers, collects all keys at the level, invokes your batch function once, and completes each future. Use this over @BatchMapping when you need custom DataLoaderOptions, explicit naming, shared loaders across multiple fields, or reactive fine-tuning.
code
java · 19 lines@Configuration
class DataLoaderConfig {
DataLoaderConfig(BatchLoaderRegistry registry, AuthorRepository authors) {
registry.forTypePair(Long.class, Author.class)
.registerMappedBatchLoader((authorIds, env) ->
Mono.fromCallable(() ->
authors.findAllById(authorIds).stream()
.collect(Collectors.toMap(Author::id, a -> a)))
.subscribeOn(Schedulers.boundedElastic()));
}
}
@Controller
class BookController {
@SchemaMapping
public CompletableFuture<Author> author(Book book, DataLoader<Long, Author> loader) {
return loader.load(book.authorId()); // deferred; batched across all books
}
}go deeper
Likely only aware @BatchMapping exists; this manual path is beyond typical junior scope.
Should recognize DataLoader.load returns a future and that a batch function runs once.
Must be able to register via forTypePair, choose mapped vs positional, inject/consume the loader, and avoid blocking.
Reasons about per-request scoping, DataLoaderOptions, shared loaders, and when the declarative form is insufficient.
## The pieces **`DataLoader<K, V>`** (from the `java-dataloader` library that graphql-java uses) is a per-request utility that **batches** and **caches** loads. You call `load(key)` many times; it returns a `CompletableFuture<V>` for each, defers, then invokes a single **batch function** with the collected keys. **`BatchLoaderRegistry`** is a Spring for GraphQL bean (auto-configured) where you **declaratively register** those batch functions. Spring then contributes them to a **fresh `DataLoaderRegistry` for every request**, so DataLoaders are correctly request-scoped (no cross-request state leakage). ## Registering — two flavors ```java @Configuration class DataLoaderConfig { DataLoaderConfig(BatchLoaderRegistry registry, AuthorRepository authors) { // MAPPED: return Map<key, value>; missing keys → null for that field registry.forTypePair(Long.class, Author.class) .registerMappedBatchLoader((authorIds, env) -> Mono.fromCallable(() -> authors.findAllById(authorIds).stream() .collect(Collectors.toMap(Author::id, a -> a))) .subscribeOn(Schedulers.boundedElastic())); } } ``` - **`registerMappedBatchLoader`** → returns `Mono<Map<K, V>>`. Result correlated by key; robust to missing/reordered rows. **Preferred.** - **`registerBatchLoader`** → returns `Flux<V>` (or `Mono<List<V>>`), **positional** — element order must match key order. - `forTypePair(K, V)` also determines the **default name** under which the loader is registered (the value type name, e.g. `"Author"`). You can name it explicitly with `.withName("...")` / `.withOptions(...)`. ## Consuming it in a resolver Two ways: **(a) Typed method argument** — Spring for GraphQL has a `DataLoader` argument resolver that injects the loader matching the value type: ```java @Controller class BookController { @SchemaMapping public CompletableFuture<Author> author(Book book, DataLoader<Long, Author> loader) { return loader.load(book.authorId()); } } ``` **(b) From the environment** — `DataFetchingEnvironment.getDataLoader("Author")`, useful when you named the loader or need `loadMany`. You **must return the `CompletableFuture`** (or a `Mono` bridging it). Returning `loader.load(id).get()` (blocking) defeats batching entirely — it forces a dispatch per call and re-creates N+1. ## How batching happens During execution graphql-java resolves the `author` field for every `Book` at that level. Each call to `load()` **queues a key** and hands back an incomplete future. When the engine has walked the whole level it **dispatches** the DataLoader once → your batch function runs with all keys → futures complete. This is **deferred resolution batching dependent fetches into one call**. ## Caching DataLoader also **caches within the request**: two `load(5L)` calls return the same future, so a repeated key is fetched once. Caching is **per-request** and cleared afterward; it is not a second-level cache. ## @BatchMapping vs BatchLoaderRegistry `@BatchMapping` is sugar that registers a loader for you. Reach for `BatchLoaderRegistry` when you need: custom `DataLoaderOptions` (cache off, max batch size), an explicit loader **name**, a loader **shared across several fields**, or reactive scheduling control. ## Gotchas - **Don't block** on the future in the resolver. - Mapped loader **omitting a key** → that field resolves to `null` (often the desired 'not found' behavior). - The key type must match what you `load()` — `forTypePair(Long, Author)` but `load("5")` (String) won't find the loader. - Per-request scope means no benefit across separate operations; that is by design.
- What happens if your resolver calls loader.load(id).get() and returns the resolved value instead of the future?Blocking on get() forces the DataLoader to dispatch immediately for that single key, so you lose batching and reintroduce N+1 — plus you may deadlock the execution thread. Always return the CompletableFuture and let the engine dispatch the whole batch.
- When would you choose BatchLoaderRegistry over @BatchMapping?When you need custom DataLoaderOptions (disable caching, cap batch size), an explicit loader name, a loader shared across multiple fields/types, or precise reactive scheduling — things the declarative @BatchMapping hides.
- What scope do the registered DataLoaders have?Per GraphQL request. Spring builds a fresh DataLoaderRegistry for each execution from the BatchLoaderRegistry, so batching and caching are isolated to one request and cleared afterward.
saying these in an interview costs you the question
- Blocking with .get() on the load future inside the resolver
- Thinking the DataLoader cache persists across requests
- Registering the batch function inside the resolver on every call instead of once at startup
- Assuming registerBatchLoader (positional) is interchangeable with the mapped variant