In Hot Chocolate 16, how do you fix the N+1 on a Book.author field with a source-generated [DataLoader]?
answer
- collect keys, one fetch
- static method over a key list
- Dictionary is batch, ILookup is group
- Get and Async stripped from the name
- inject the generated interface, LoadAsync
basics
~10 sWrite a static method marked [DataLoader] that takes IReadOnlyList<int> author ids and returns a Dictionary<int, Author> from one WHERE-IN query; the generator emits IAuthorByIdDataLoader, which the author resolver injects and calls with LoadAsync(book.AuthorId).
solid answer
~40 sThe author resolver runs once per book, so 50 books means 50 author queries. In Hot Chocolate I write `[DataLoader] public static async Task<Dictionary<int, Author>> GetAuthorByIdAsync(IReadOnlyList<int> ids, BookstoreContext db, CancellationToken ct)` with one `Where(a => ids.Contains(a.Id))`. The source generator strips `Get` and `Async` and emits `AuthorByIdDataLoader` plus `IAuthorByIdDataLoader`, and the generated module registers it. The resolver on `[ObjectType<Book>]` takes `IAuthorByIdDataLoader` as a parameter and calls `LoadAsync(book.AuthorId, ct)`. Keys collect while resolvers run, dispatch as one call when no more resolver work is ready, are deduplicated, and are cached for that request only. Since version 15 a hand-written DataLoader must accept `DataLoaderOptions` and be registered with `AddDataLoader<T>()`; one registered with plain `AddScoped` stops batching.
code
csharp · 24 linespublic sealed class AuthorByIdDataLoader : BatchDataLoader<int, Author>
{
private readonly IServiceProvider _services;
public AuthorByIdDataLoader(
IServiceProvider services,
IBatchScheduler batchScheduler,
DataLoaderOptions options)
: base(batchScheduler, options)
=> _services = services;
protected override async Task<IReadOnlyDictionary<int, Author>> LoadBatchAsync(
IReadOnlyList<int> keys, CancellationToken ct)
{
await using var scope = _services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<BookstoreContext>();
return await db.Authors
.Where(a => keys.Contains(a.Id))
.ToDictionaryAsync(a => a.Id, ct);
}
}
// registration that keeps batching:
// builder.Services.AddDataLoader<AuthorByIdDataLoader>();go deeper
Recall that a DataLoader collects keys and fetches them in one call, and that the author resolver injects the generated interface and calls LoadAsync.
Explain the [DataLoader] method signature, how the return type picks batch or group, the generated names and registration, and when the batch dispatches.
Diagnose a DataLoader that stopped batching after an upgrade, handle missing keys deliberately, and keep scoped DbContext use safe inside fetch methods.
Decide where batching belongs across services and teams: DataLoaders shared by several fields, batch size limits against the database, and when a join via projection is cheaper.
## The N+1 case in the bookstore A client asks for a page of books and each book's author: ```graphql { books(first: 50) { nodes { title author { name } } } } ``` If `Book.author` is resolved by querying the database for one author, the resolver runs **once per book**: one query for the list plus fifty for authors. That is the N+1 problem. A **DataLoader** fixes it by collecting the keys that many resolvers ask for and fetching them in one call. In Hot Chocolate the DataLoader implementation comes from **GreenDonut**, and version 16 documents the source-generated form as the recommended one. ## Writing a source-generated DataLoader You write a **static method** marked `[DataLoader]`; the Hot Chocolate source generator writes the class around it. ```csharp internal static class AuthorDataLoaders { [DataLoader] public static async Task<Dictionary<int, Author>> GetAuthorByIdAsync( IReadOnlyList<int> ids, BookstoreContext db, CancellationToken ct) => await db.Authors .Where(a => ids.Contains(a.Id)) .ToDictionaryAsync(a => a.Id, ct); } ``` The **signature decides the kind** of DataLoader: | Method shape | Kind | Missing key yields | |---|---|---| | `IReadOnlyList<TKey>` in, `Dictionary<TKey, TValue>` out | Batch (one-to-one) | `null` / default | | `IReadOnlyList<TKey>` in, `ILookup<TKey, TValue>` out | Group (one-to-many) | empty array | | single `TKey` in, `TValue` out | Cache only | fetched per key, cached per request | Besides the keys, the method can take a `CancellationToken`, DI services such as the `DbContext`, and data parameters such as `PagingArguments` or `QueryContext<T>`. ## What the generator emits and how it is wired 1. **Names**: the `Get` prefix and `Async` suffix are stripped, so `GetAuthorByIdAsync` produces `AuthorByIdDataLoader` and the interface `IAuthorByIdDataLoader`. `[DataLoader("AuthorLookup")]` overrides the name. 2. **Registration**: the generated type module (the method behind `AddTypes()`) registers the generated DataLoaders by default. An assembly that holds only DataLoaders can declare `[assembly: DataLoaderModule("BookstoreDataLoaders")]` and call the generated `services.AddBookstoreDataLoaders()`. 3. **Injection**: the resolver declares the interface as a parameter; Hot Chocolate supplies that request's instance. ```csharp [ObjectType<Book>] public static partial class BookNode { public static Task<Author?> GetAuthorAsync( [Parent] Book book, IAuthorByIdDataLoader authorById, CancellationToken ct) => authorById.LoadAsync(book.AuthorId, ct); } ``` ## How the batch is dispatched 1. Each `author` resolver calls `LoadAsync`; no query runs yet — the key joins the current batch and the resolver gets a task. 2. The engine keeps executing other ready resolvers. 3. When **no more resolver work is ready**, the batch dispatches as a single call to `GetAuthorByIdAsync` with the distinct keys. 4. Results are handed back to each waiting resolver. Keys are **deduplicated** (fifty books by twelve authors fetch twelve ids), and results are **cached for the request** — or for one subscription event — and never across requests. `MaxBatchSize` defaults to 1024 keys per call; larger batches are split. ## Hand-written DataLoaders and the version 15 rule You can still subclass `BatchDataLoader<int, Author>` and override `LoadBatchAsync`. Since Hot Chocolate 15: - the constructor must take `IBatchScheduler` and **`DataLoaderOptions`** and pass both to the base class; - the class must be registered with **`AddDataLoader<T>()`** (or `AddDataLoader<TService, TImpl>()`), not with `services.AddScoped`. The migration guide states that a manually registered DataLoader is stuck in auto-dispatch mode and no longer batches: it falls back to `AutoBatchScheduler`, which dispatches every batch immediately. `GroupedDataLoader` and the ad-hoc DataLoader methods on the resolver context are no longer the recommended pattern; a `[DataLoader]` method returning `Dictionary<int, Book[]>` or `ILookup<int, Book>` covers the one-to-many case. ## The reverse direction: an author's books The one-to-many case uses the same attribute with a different return type: ```csharp [DataLoader] public static async Task<ILookup<int, Book>> GetBooksByAuthorIdAsync( IReadOnlyList<int> authorIds, BookstoreContext db, CancellationToken ct) { var books = await db.Books.Where(b => authorIds.Contains(b.AuthorId)).ToListAsync(ct); return books.ToLookup(b => b.AuthorId); } ``` The generator emits `IBooksByAuthorIdDataLoader`; an `[ObjectType<Author>]` resolver calls `LoadAsync(author.Id, ct)` and receives a `Book[]`, empty for an author with no books. A list of fifty authors therefore costs one books query, not fifty. ## Pitfalls worth naming in an interview - **Missing keys**: a batch DataLoader returns `null` for an absent key. On a non-null field that becomes a non-null violation (error code `HC0018`); `LoadRequiredAsync` throws `KeyNotFoundException` naming the key instead. - **Key list lifetime**: the `IReadOnlyList<TKey>` is valid only during the fetch call — copy it if you need it later. - **Scopes**: a generated DataLoader that takes services resolves them in its own scope by default, so a scoped `DbContext` is not shared with a resolver running in parallel. - **Projection interplay**: if `[UseProjection]` builds the book query, `AuthorId` must be projected even when the client did not select it, or the loader receives `0`.
- Why does a Hot Chocolate DataLoader registered with services.AddScoped stop batching?Plain DI construction gives it the default `IBatchScheduler`, which is `AutoBatchScheduler`: every batch dispatches immediately on its own, so each `LoadAsync` becomes its own query. `AddDataLoader<T>()` registers it with GreenDonut's DataLoader registrar, so Hot Chocolate creates it inside the request's DataLoader scope with the execution engine's scheduler, which waits until no resolver work is ready. The v15 migration guide calls this being stuck in auto-dispatch mode.
- How do you load all books for an author with a Hot Chocolate [DataLoader]?Take `IReadOnlyList<int>` author ids and return either `Dictionary<int, Book[]>` (built with GroupBy and ToDictionaryAsync) or `ILookup<int, Book>`. With the dictionary a missing author yields `null`, so the resolver writes `?? []`; with the lookup the generated loader returns an empty array. Both fetch every author's books in one query.
- What does a Hot Chocolate DataLoader cache, and for how long?Each loaded key's result, for exactly one request or one subscription event. Within that request a second `LoadAsync` for the same id, from any field or depth, returns the cached value, so all resolvers see the same data. Nothing is cached across requests, so no data leaks between users.
A DataLoader is a waiter who takes every order at the table before walking to the kitchen once, instead of making a trip per guest; repeat orders for the same dish are cooked once.
saying these in an interview costs you the question
- A DataLoader caches authors across requests like a shared memory cache.
- Registering a DataLoader with services.AddScoped is equivalent to AddDataLoader.
- The DataLoader dispatches once per level of the query, on a fixed schedule.
- Returning List<Author> from a [DataLoader] method is the batch shape.
- GroupedDataLoader is the recommended one-to-many pattern in Hot Chocolate 16.