skip to content

Why does Hot Chocolate 16 recommend QueryContext<T> over [UseProjection] for an EF Core books connection, and what breaks when you mix them?

level: seniorimportance: should knowfreq 30%

answer

  1. explicit pipeline instead of middleware
  2. selector, predicate, sorting in one record
  3. With applies filter, sort, project
  4. two Selects on one field
  5. HC0099, and foreign keys not projected

basics

~20 s

QueryContext<T> hands the resolver the selection's projection, the where predicate and the sort as one value applied with .With(query), so the pipeline is explicit and reusable in services and DataLoaders; combining it with [UseProjection] applies two Selects, which analyzer HC0099 rejects.

solid answer

~40 s

With `[UseProjection]` the query is shaped by middleware around a resolver that returns `IQueryable`, so it only works where the resolver hands back a query and the attribute order is right. Hot Chocolate 16 recommends a `QueryContext<Book>` parameter instead: a record holding the `Selector` built from the selection set, the `Predicate` from `[UseFiltering]` and the `Sorting` from `[UseSorting]`. `db.Books.With(query)` applies filter, then sort, then projection, and `ToPageAsync(pagingArgs, ct)` executes one keyset-paged query. Because it is a value, I can pass it into a `BookService` or a DataLoader. Mixing it with `[UseProjection]` applies two `Select` expressions to the same field; analyzer HC0099 reports that as an error. The other trap: a projected `Book` lacks `AuthorId` unless the client selected it, so the author resolver has to declare it, for example with `[Parent(requires: nameof(Book.AuthorId))]` or `[BindMember]`.

code

csharp · 9 lines
csharp
[ObjectType<Book>]
public static partial class BookNode
{
    public static Task<Author?> GetAuthorAsync(
        [Parent(requires: nameof(Book.AuthorId))] Book book,
        IAuthorByIdDataLoader authorById,
        CancellationToken ct)
        => authorById.LoadAsync(book.AuthorId, ct);
}

go deeper

for a junior

Recall that projection reads only selected columns and that Hot Chocolate 16 prefers a QueryContext<T> parameter applied with With(query).

for a middle

Explain the three parts of QueryContext<T>, the filter-sort-project order of With, and why filtering and sorting attributes are still declared.

for a senior

Diagnose missing foreign keys under projection, pick among Parent requires, BindMember and IsProjected, and pass QueryContext through services and DataLoaders.

for a principal

Set the team rule for data access in the schema: explicit pipelines in a service layer versus middleware on IQueryable, and how that shapes testing and ownership.

## Two ways to project in Hot Chocolate **Projection** means reading only the columns the client selected. Hot Chocolate supports two ways of doing it over EF Core: - **`[UseProjection]`** — field middleware. The resolver returns an unexecuted `IQueryable<Book>`; the middleware adds a `Select` built from the selection set, and `[UsePaging]` executes it. The attribute order `UsePaging` > `UseProjection` > `UseFiltering` > `UseSorting` must hold. - **`QueryContext<T>`** — a resolver parameter. Hot Chocolate 16's documentation calls this the recommended way: you receive the projection, the filter and the sort as data and apply them yourself. ## What QueryContext<T> contains `QueryContext<T>` (from GreenDonut's data primitives) is a record with three members: | Member | Type | Built from | |---|---|---| | `Selector` | `Expression<Func<T, T>>?` | the GraphQL selection set | | `Predicate` | `Expression<Func<T, bool>>?` | the `where` argument of `[UseFiltering]` | | `Sorting` | `SortDefinition<T>?` | the `order` argument of `[UseSorting]` | `queryable.With(query)` applies them in a fixed order: 1. `Where(Predicate)` — shrink the rows first; 2. `OrderBy(Sorting)` — order the filtered rows; 3. `Select(Selector)` — read only the requested columns. ## The bookstore connection, version 16 style ```csharp [QueryType] public static partial class BookQueries { [UseConnection] [UseFiltering] [UseSorting] public static async Task<PageConnection<Book>> GetBooksAsync( PagingArguments pagingArgs, QueryContext<Book> query, BookstoreContext db, CancellationToken ct) { var page = await db.Books .With(query, s => s.IfEmpty(o => o.AddAscending(b => b.Title)).AddAscending(b => b.Id)) .ToPageAsync(pagingArgs, ct); return new PageConnection<Book>(page); } } ``` - `[UseFiltering]` and `[UseSorting]` are still needed: they create the `where` and `order` arguments that feed the context. - `ToPageAsync` builds **keyset cursors** from the ordering and throws if the query has no `OrderBy` key; the sort modifier supplies a default order and a unique `Id` tiebreaker. - The whole pipeline is **one statement**, written where you can read and test it. ## Why the explicit form is preferred - **Services and DataLoaders**: a `QueryContext<Book>` can be passed into `BookService.GetBooksAsync(pagingArgs, query, ct)`; a DataLoader method can accept one, and `loader.With(query)` branches the loader for that selection. `[UseProjection]` only works on a resolver that returns a query. - **No ordering trap**: `.With()` always applies filter, sort, project. - **Testability**: making the parameter nullable (`QueryContext<Book>? query = null`) lets background jobs and tests call the same service without GraphQL. ## What the analyzers reject 1. **Double projection.** `[UseProjection]` adds its own `Select`, and `.With(query)` adds another. The docs call the result unexpected behaviour; in 16.6.7 the analyzer **HC0099** ("Methods with QueryContext<T> parameters cannot use the [UseProjection] attribute") is an **error**, so it fails the build. 2. **Mismatched node type.** With `[UseConnection]`, the `QueryContext<T>` type must match the connection's node type — analyzer **HC0101**. ## The foreign-key trap in both styles A projected `Book` contains only what the client selected. If the query is `{ books { nodes { title author { name } } } }` and `author` is resolved through a DataLoader from `book.AuthorId`, `AuthorId` was never selected, so the resolver sees `0`. Version 16 adds a related change: a field whose resolver is declared on a separate type (most commonly an `[ExtendObjectType]` class) no longer has its backing member projected by default, so a resolver that reads a parent member it has not declared sees its type's default value (0 for an int). The fixes: - `[Parent(requires: nameof(Book.AuthorId))]` on the parent parameter, in the `QueryContext` style; - `[BindMember(nameof(Book.AuthorId))]` on the resolver, which replaces the `authorId` field with `author` and keeps the member projected; - `[IsProjected]` on the `AuthorId` property, to always project it; - `query.Include(b => b.AuthorId)` when building the query yourself. ## Migrating an existing field 1. Keep `[UseFiltering]` and `[UseSorting]`; delete `[UseProjection]`. 2. Replace `[UsePaging]` over `IQueryable` with `[UseConnection]` and a `PagingArguments` parameter. 3. Add a `QueryContext<Book>` parameter and move the query into a service method that accepts it (nullable, default `null`). 4. Give the query a deterministic order with a unique tiebreaker, then call `ToPageAsync`. 5. Audit resolver classes on `Book` for members they read from the parent, and declare them with `requires` or `[BindMember]`. Run the field's tests after each step and compare the generated SQL: the column list should shrink to what the selection needs, and pages after the first should seek with a `WHERE` on the cursor keys instead of an `OFFSET`. ## When [UseProjection] still makes sense It remains in the product and is fine for a simple `IQueryable` field with no service layer. The recommendation is about where the code goes as a schema grows: projection as a value you pass around ages better than projection as middleware you must order correctly.

  • How does QueryContext<T> reach a Hot Chocolate DataLoader?
    The `[DataLoader]` method declares a `QueryContext<Author>` parameter and applies it with `.With(query)` in its batch query, adding `.Include(a => a.Id)` if the dictionary key must be present. The caller branches the loader for the current selection with `loader.With(query).LoadAsync(id, ct)`, so the batch query reads only the columns that selection needs.
  • After upgrading to Hot Chocolate 16, an [ExtendObjectType] resolver returning author.Name now returns null under projection; why?
    Version 16 no longer projects the backing member of a field whose resolver lives on a separate type. The resolver receives an `Author` whose `Name` was never selected, so it holds `null`, its type's default. Annotate the resolver with `[BindMember(nameof(Author.Name))]` or set `[IsProjected(true)]` to keep the member in the projection.

saying these in an interview costs you the question

  • QueryContext<T> removes the need for [UseFiltering] and [UseSorting] attributes.
  • Adding [UseProjection] next to QueryContext<T> just projects twice as efficiently.
  • A projected entity still carries every foreign key the resolvers might need.
  • ToPageAsync works on an unordered query and orders by primary key itself.
  • HC0099 is only a warning, so mixing the two styles is acceptable.