Mert Özen Further With Every Line
Backend gRPC and GraphQL — #07

Building the Bookstore GraphQL API with Hot Chocolate 16: Schema, EF Core Integration and Mutations

Mert Özen Nov 14, 2026 15 min 6 views
Building the Bookstore GraphQL API with Hot Chocolate 16: Schema, EF Core Integration and Mutations

The safe way to connect EF Core in Hot Chocolate 16 is to give every resolver its own DbContext from a pooled factory and to choose deliberately every field, filter and error the schema exposes. We show the random failures of a shared DbContext, the SQL behind projection and sorting, a purchase price leaking from the entity, typed errors with mutation conventions, and onError, all with real output.

Version note: The code in this post was built and run with .NET 10, Hot Chocolate 16.7, Entity Framework Core (EF Core) 10.0 and SQL Server 2025.

Guess First

You've wired EF Core into the Catalog service. You registered BookstoreContext as scoped with AddDbContext, just like in any ASP.NET Core project: one DbContext per HTTP request. A client sends this query:

{
  a: book(id: 1) { title }
  b: book(id: 2) { title }
  c: books(where: { id: { eq: 3 } }) { title }
}

Three root fields, three separate database queries, one request. What happens? Do the three run one after another and return cleanly, or does something blow up? And if it blows up, is it always in the same place?

You'll see the answer halfway through the post, in the output of sending the same query three times in a row. A hint: in GraphQL, query fields may run in parallel, while EF Core's DbContext does not tolerate parallel use.

Three Ways to Write a Schema, and Our Choice

In the previous post we looked at GraphQL's concepts with a small in-memory data set. In this one we connect Bookstore's Catalog service to a real SQL Server database through EF Core. The first decision is how to define the schema. Hot Chocolate offers three ways:

ApproachWhere the schema comes fromStrengthWeakness
Implementation-firstAttributed C# classes + a source generatorLeast code, analysis at compile time, resolvers are plain methodsYou have to export the SDL to see the final schema
Code-firstDerive ObjectType<T> and use the fluent API in ConfigureEvery detail is explicitly under controlLong and repetitive; resolvers get lost inside delegates
Schema-firstHandwritten SDL + resolver bindingSchema design comes before code, teams agree on the SDLMismatches between SDL and C# only show up at runtime

We go with implementation-first, as in the series' previous post. Hot Chocolate 16's source generator finds static classes marked with [QueryType], [MutationType] and [ObjectType<T>] at build time and generates the registration code. The generator lives in the HotChocolate.Types.Analyzers package; forget that package and there is simply no AddCatalogTypes() method. We don't drop code-first entirely: when a type needs fine-tuning, we step down to the same fluent API through a Configure method inside the implementation-first class. You'll see this on the Book type below.

Setting Up: AddGraphQLServer and a DbContext Factory

Packages: HotChocolate.AspNetCore, HotChocolate.Data.EntityFramework, HotChocolate.Types.Analyzers (all 16.7.5; Hot Chocolate package versions must match each other) and Microsoft.EntityFrameworkCore.SqlServer 10.0.15. The entities are ordinary EF Core classes:

public sealed class Book
{
    public int Id { get; set; }
    public required string Title { get; set; }
    public required string Isbn { get; set; }
    public decimal Price { get; set; }
    public decimal CostPrice { get; set; }
    public string? SupplierNote { get; set; }
    public int Stock { get; set; }
    public int AuthorId { get; set; }
    public Author? Author { get; set; }
}

CostPrice (the purchase price) and SupplierNote are internal information; we'll come back to both shortly. Author, Order and OrderItem follow the same shape. The database has 10 authors and 50 books. Program.cs:

using Bookstore.Catalog.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPooledDbContextFactory<BookstoreContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Bookstore")));

builder.Services
    .AddGraphQLServer()
    .RegisterDbContextFactory<BookstoreContext>()
    .AddCatalogTypes()
    .AddProjections()
    .AddFiltering()
    .AddSorting()
    .AddMutationConventions(applyToAllMutations: true);

var app = builder.Build();

await SeedData.EnsureSeededAsync(app.Services.GetRequiredService<IDbContextFactory<BookstoreContext>>());

app.MapGraphQL();

app.Run();

AddGraphQLServer() returns a request executor builder; everything that goes into the schema is added to this chain. By default MapGraphQL() binds three things to /graphql: GraphQL requests over POST and GET, the schema as SDL via ?sdl, and Nitro IDE when you open it in a browser. Requesting /graphql/ with Accept: text/html returned 200 text/html and a page titled "Nitro IDE". Nitro is Hot Chocolate's own browser UI that autocompletes from the schema as you write queries.

The DbContext registration has two parts. AddPooledDbContextFactory is EF Core's own API: it keeps DbContext instances in a pool and reuses them. RegisterDbContextFactory tells Hot Chocolate "when you see BookstoreContext as a resolver parameter, take it from this factory". The EF Core series' post on AddDbContext, scoped lifetime, factories and pooling covers the difference between these registrations in detail. Why the factory matters on the GraphQL side is what the opening question shows.

DbContext and Resolvers Running in Parallel

The setup behind the opening question was this: the classic scoped registration instead of the factory, and telling Hot Chocolate "let all query resolvers share the request's scope":

// wrong: one DbContext per request
builder.Services.AddDbContext<BookstoreContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Bookstore")));

builder.Services
    .AddGraphQLServer()
    .AddCatalogTypes()
    // ...
    .ModifyOptions(o => o.DefaultQueryDependencyInjectionScope = DependencyInjectionScope.Request);

I sent the same query three times in a row:

200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["b"]},{"message":"Unexpected Execution Error","path":["c"]}],"data":null}
200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["b"]}],"data":{"a":{"title":"Distributed Systems Vol. 1"},"b":null,"c":[{"title":"Streaming Vol. 1"}]}}
200 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"Unexpected Execution Error","path":["b"]},{"message":"Unexpected Execution Error","path":["c"]}],"data":null}
fail: Microsoft.EntityFrameworkCore.Query[10100]
      System.InvalidOperationException: A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. ...

A different result each time. Three resolvers hit the same DbContext at the same moment; depending on which one got the connection first, one or two of them failed. In the second attempt b was null but c came back; in the first and third c failed too, and because [Book!]! is non-null the null spread to the whole of data. In a development environment, with one click at a time, you rarely see this kind of error; under load it shows up at random.

That's why Hot Chocolate 16's default is different: the DI scope of query fields is DependencyInjectionScope.Resolver; each resolver gets its own service scope and therefore its own DbContext. When I removed the ModifyOptions line, the same query (even without the factory) returned all three books on all three attempts. Mutations default to the request scope: top-level mutation fields run sequentially anyway, so mutations in the same request share one DbContext.

The price of a DbContext per resolver is several DbContexts per request. That's where the factory and pooling earn their keep: with RegisterDbContextFactory each resolver takes an instance from the pool and returns it when it's done. The rule is short: in GraphQL, never share a DbContext between resolvers; every resolver or DataLoader uses its own.

The SQL Behind Projection, Filtering and Sorting

I returned the book list as an IQueryable and added the three middlewares:

[QueryType]
public static partial class BookQueries
{
    [UseProjection]
    [UseFiltering]
    [UseSorting]
    public static IQueryable<Book> GetBooks(BookstoreContext db) =>
        db.Books.AsNoTracking().OrderBy(b => b.Id);

    public static async Task<Book?> GetBookAsync(int id, BookstoreContext db, CancellationToken cancellationToken) =>
        await db.Books.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, cancellationToken);
}

The attribute order matters and reads top to bottom: projection outermost, then filtering, then sorting. Hot Chocolate 16's analyzer flags the wrong order as a compile error. The middlewares add Where, OrderBy and Select to the IQueryable the resolver returns; the query goes to the database only at the very end, in one go. The schema gained books(where: BookFilterInput, order: [BookSortInput!]) arguments. I turned on EF Core's SQL log (the Microsoft.EntityFrameworkCore.Database.Command category at Information level) and sent a query:

{ books(where: { price: { lt: 170 } }, order: [{ price: DESC }]) { title price author { name } } }
{"data":{"books":[{"title":"Distributed Systems Vol. 1","price":155.00,"author":{"name":"Ada Byron"}},{"title":"Protobuf Vol. 1","price":160.00,"author":{"name":"Alan Turing"}},{"title":"Streaming Vol. 1","price":165.00,"author":{"name":"Grace Hopper"}}]}}

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (24ms) [Parameters=[@p='?' (Precision = 10) (Scale = 2) (DbType = Decimal)], CommandType='Text', CommandTimeout='30']
      SELECT [b].[Title], [b].[Price], CAST(1 AS bit), [a].[Name]
      FROM [Books] AS [b]
      INNER JOIN [Authors] AS [a] ON [b].[AuthorId] = [a].[Id]
      WHERE [b].[Price] < @p
      ORDER BY [b].[Id], [b].[Price] DESC

The good news: projection works. The SELECT has only the three columns the query asked for, and author is a single JOIN rather than a separate query. The filter became a parameterised WHERE. The bad news is the order of the response: I asked for prices descending and got them ascending. The last line of the SQL shows why: ORDER BY [b].[Id], [b].[Price] DESC. The resolver's OrderBy(b => b.Id) stayed as the primary sort, and the client's sort became a secondary key. Since ids are unique, the secondary key changed nothing. If you use the sorting middleware, don't sort in the resolver.

The second surprise is in book(id). The query { book(id: 1) { title author { name } } } returned:

{"data":{"book":{"title":"Distributed Systems Vol. 1","author":null}}}

No error, and the author is null. The entity loaded with FirstOrDefaultAsync has an empty Author navigation without Include; since author is nullable in the schema, GraphQL returned it as a legitimate null. On top of that, the SQL in the log selected all eight columns (including CostPrice and SupplierNote) even when the query asked only for title. I fixed both methods:

[QueryType]
public static partial class BookQueries
{
    [UseProjection]
    [UseFiltering<BookFilterType>]
    [UseSorting]
    public static IQueryable<Book> GetBooks(BookstoreContext db) =>
        db.Books.AsNoTracking();

    [UseFirstOrDefault]
    [UseProjection]
    public static IQueryable<Book> GetBook(int id, BookstoreContext db) =>
        db.Books.AsNoTracking().Where(b => b.Id == id);
}
{"data":{"book":{"title":"Distributed Systems Vol. 1","author":{"name":"Ada Byron"}}}}

      SELECT [b0].[Title], CAST(1 AS bit), [a].[Name]
      FROM (
          SELECT TOP(@p) [b].[AuthorId], [b].[Title]
          FROM [Books] AS [b]
          WHERE [b].[Id] = @id
      ) AS [b0]
      INNER JOIN [Authors] AS [a] ON [b0].[AuthorId] = [a].[Id]

{ books(where: { price: { lt: 170 } }, order: [{ price: DESC }]) { title price } }
      SELECT [b].[Title], [b].[Price]
      FROM [Books] AS [b]
      WHERE [b].[Price] < @p
      ORDER BY [b].[Price] DESC

[UseFirstOrDefault] runs a single-item field through an IQueryable as well, so projection applies there too. EF Core logged a "Take without OrderBy" warning for this query; since the filter is on the primary key, it's harmless. And the sort order is now correct.

Filtering has a cost. A query like { books(where: { title: { contains: "Cach" }, author: { name: { startsWith: "D" } } }) { title } } produced WHERE [b].[Title] LIKE @p_contains ESCAPE N'\' AND [a].[Name] LIKE @p1_startswith .... A LIKE with a leading wildcard can't use an index; every request scans the whole table. The default filter type exposes every property of the entity and every operator, which means it gives the client the right to make the database scan however it likes. Hot Chocolate 16 allows at most 64 operations per filter argument (exceeding it gives HC0117), but that's a ceiling, not a design. In a public API, choose the filterable fields explicitly:

public sealed class BookFilterType : FilterInputType<Book>
{
    protected override void Configure(IFilterInputTypeDescriptor<Book> descriptor)
    {
        // only indexed or cheap columns are filterable
        descriptor.BindFieldsExplicitly();
        descriptor.Field(b => b.Isbn);
        descriptor.Field(b => b.Price);
        descriptor.Field(b => b.AuthorId);
    }
}
input BookFilterInput {
  and: [BookFilterInput!]
  or: [BookFilterInput!]
  isbn: StringOperationFilterInput
  price: DecimalOperationFilterInput
  authorId: IntOperationFilterInput
}

{ books(where: { title: { contains: "Cach" } }) { title } }
400 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"The specified input object field `title` does not exist.", ...}]}

If searching titles is really needed, it's healthier to design it as a separate search(term:) field backed by a full-text index rather than as a filter operator. Limiting the length of the list is a separate topic: books can currently return all 50 books. In the next post we close that with cursor pagination.

Controlling What Reaches the Schema

Back to the first SDL. Implementation-first carried every public property of the entity into the schema:

type Book {
  id: Int!
  title: String!
  isbn: String!
  price: Decimal!
  costPrice: Decimal!
  supplierNote: String
  stock: Int!
  authorId: Int!
  author: Author
}

The purchase price and the supplier note are in a public API. The filter input is sneakier: costPrice was there too. Even if you hide the field from queries, { books(where: { costPrice: { gt: 200 } }) { title price } } returned 14 books; with a binary search over the filter, anyone can find each book's purchase price. The problem we described for REST (Representational State Transfer) in the API Security series' API3 BOPLA post gains another channel in GraphQL through filter and sort inputs.

The fix is to decide explicitly which properties a type publishes:

[ObjectType<Book>]
public static partial class BookNode
{
    static partial void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        // cost and supplier note are internal; keep them out of the schema
        descriptor.Ignore(b => b.CostPrice);
        descriptor.Ignore(b => b.SupplierNote);
    }
}

After this change both fields disappeared from the Book type and from the sorting input too; we had already narrowed the filter input with BookFilterType. A query trying to filter by costPrice now gets a 400 at validation with The specified input object field `costPrice` does not exist. In a larger model, a separate DTO with an EF Core Select projection is a safer default than an Ignore list: a newly added entity property doesn't leak into the schema on its own.

Mutation Conventions and Typed Errors

Now the place-order mutation. With AddMutationConventions(applyToAllMutations: true) on, you write a method with plain parameters and Hot Chocolate generates the input and payload types. Domain errors are thrown as exceptions, and [Error<T>] attributes turn them into typed errors in the schema:

public sealed record OrderLineInput(int BookId, int Quantity);

public sealed class OutOfStockException(int bookId, int requested, int available)
    : Exception($"Book {bookId} has only {available} item(s) in stock.")
{
    public int BookId { get; } = bookId;
    public int Requested { get; } = requested;
    public int Available { get; } = available;
}

[MutationType]
public static partial class OrderMutations
{
    [Error<OutOfStockException>]
    [Error<BookNotFoundException>]
    [Error<OrderValidationException>]
    public static async Task<Order> PlaceOrderAsync(
        string customerEmail,
        IReadOnlyList<OrderLineInput> lines,
        BookstoreContext db,
        CancellationToken cancellationToken)
    {
        if (customerEmail.Length > 320 || !customerEmail.Contains('@'))
        {
            throw new OrderValidationException("customerEmail", "Customer email is not valid.");
        }

        if (lines.Count == 0 || lines.Any(l => l.Quantity is < 1 or > 20))
        {
            throw new OrderValidationException("lines", "Each line needs a quantity between 1 and 20.");
        }

        int[] bookIds = [.. lines.Select(l => l.BookId).Distinct()];
        Dictionary<int, Book> books = await db.Books
            .Where(b => bookIds.Contains(b.Id))
            .ToDictionaryAsync(b => b.Id, cancellationToken);

        Order order = new() { CustomerEmail = customerEmail, CreatedAt = DateTimeOffset.UtcNow };
        foreach (OrderLineInput line in lines)
        {
            if (!books.TryGetValue(line.BookId, out Book? book))
            {
                throw new BookNotFoundException(line.BookId);
            }

            if (book.Stock < line.Quantity)
            {
                throw new OutOfStockException(book.Id, line.Quantity, book.Stock);
            }

            book.Stock -= line.Quantity;
            order.Items.Add(new OrderItem { BookId = book.Id, Quantity = line.Quantity, UnitPrice = book.Price });
        }

        order.Total = order.Items.Sum(i => i.UnitPrice * i.Quantity);
        db.Orders.Add(order);
        await db.SaveChangesAsync(cancellationToken);
        return order;
    }
}

BookNotFoundException and OrderValidationException follow the same shape (BookId; Field). The generated schema:

type Mutation {
  placeOrder(input: PlaceOrderInput!): PlaceOrderPayload!
}

input PlaceOrderInput {
  customerEmail: String!
  lines: [OrderLineInput!]!
}

type PlaceOrderPayload {
  order: Order
  errors: [PlaceOrderError!]
}

union PlaceOrderError =
  | OutOfStockError
  | BookNotFoundError
  | OrderValidationError

interface Error {
  message: String!
}

type OutOfStockError implements Error {
  message: String!
  bookId: Int!
  requested: Int!
  available: Int!
}

Hot Chocolate replaced the Exception suffix in the exception name with Error, turned the exception's public properties into fields, and gathered them all in a union of types implementing the Error interface. order in the payload is nullable: if there's an error, there's no order. The client side:

mutation PlaceOrder($input: PlaceOrderInput!) {
  placeOrder(input: $input) {
    order { id total items { book { title } quantity unitPrice } }
    errors {
      __typename
      ... on Error { message }
      ... on OutOfStockError { bookId requested available }
    }
  }
}
{"input":{"customerEmail":"deniz@example.com","lines":[{"bookId":1,"quantity":2},{"bookId":6,"quantity":1}]}}
{"data":{"placeOrder":{"order":{"id":1,"total":490.00,"items":[{"book":{"title":"Distributed Systems Vol. 1"},"quantity":2,"unitPrice":155.00},{"book":{"title":"Caching Vol. 1"},"quantity":1,"unitPrice":180.00}]},"errors":null}}}

{"input":{"customerEmail":"deniz@example.com","lines":[{"bookId":12,"quantity":3}]}}
{"data":{"placeOrder":{"order":null,"errors":[{"__typename":"OutOfStockError","message":"Book 12 has only 1 item(s) in stock.","bookId":12,"requested":3,"available":1}]}}}

{"input":{"customerEmail":"deniz@example.com","lines":[{"bookId":999,"quantity":1}]}}
{"data":{"placeOrder":{"order":null,"errors":[{"__typename":"BookNotFoundError","message":"Book 999 was not found."}]}}}

For the successful order, EF Core ran two stock UPDATEs, one INSERT INTO [Orders] and one MERGE [OrderItem] for the lines, all inside SaveChangesAsync's transaction. When stock runs short, the response isn't a runtime error but data: errors sits inside the payload rather than at the top level, can be told apart by __typename, and carries fields like available that let the client say "you can buy at most 1". On the Angular side you can handle this union with a switch.

Validation has two layers. GraphQL does the type validation: a request sending a string for quantity never reached the resolver and came back as 400 with "inputPath":["input","lines",0,"quantity"]. Business-rule validation (quantity between 1 and 20, a valid email) is your job and should come back as a typed error:

{"data":{"placeOrder":{"order":null,"errors":[{"__typename":"OrderValidationError","message":"Each line needs a quantity between 1 and 20.","field":"lines"}]}}}

Unexpected Errors: Null Propagation and onError

An exception you didn't list with [Error<T>] doesn't become a typed error. The first version of the mutation had no email length check; I sent a 342-character email. SQL Server said String or binary data would be truncated in table 'grpcseries_07.dbo.Orders', column 'CustomerEmail' and the response was:

{"errors":[{"message":"Unexpected Execution Error","path":["placeOrder"]}],"data":null}

The exception details didn't leak to the client, which is good. But data is null because placeOrder: PlaceOrderPayload! is non-null; the null propagation we saw in the series' previous post applied here too. Had there been other root fields in the same request, they would have been lost as well.

Hot Chocolate 16 implements the onError proposal from the GraphQL working group: by adding "onError": "NULL" to the request body, a client can say "make the failing field null, don't bubble it up". It's off by default; on my first attempt the value was ignored and the same response came back. I turned it on in the server:

builder.Services
    .AddGraphQLServer()
    // ...
    .ModifyRequestOptions(o => o.AllowErrorHandlingModeOverride = true);
{"query":"...","variables":{...},"onError":"NULL"}
{"errors":[{"message":"Unexpected Execution Error","path":["placeOrder"]}],"data":{"placeOrder":null}}

{"query":"...","variables":{...},"onError":"PROPAGATE"}
{"errors":[{"message":"Unexpected Execution Error","path":["placeOrder"]}],"data":null}

{"query":"{ book(id: 1) { title } }","onError":"HALT"}
400 {"errors":[{"message":"Unknown 'onError' value 'HALT'. Allowed values are 'PROPAGATE' or 'NULL'.","extensions":{"code":"HC0012"}}]}

In NULL mode the error was reported in the same place but the pruning stopped: data is now {"placeOrder":null}. This matters for clients that keep a normalized cache; they don't have to throw away all the data on screen because of one field's error. The proposal also discusses a third HALT mode, while Hot Chocolate 16.7 accepts only PROPAGATE and NULL. If you want to change the default for everyone there's a DefaultErrorHandlingMode = ErrorHandlingMode.Null option; but that changes the meaning of the schema's !s in the client's eyes and surprises clients written on the assumption that "a non-null field is never null".

The right fix is still a typed error: after adding the email check, the same request came back with OrderValidationError, without losing data. onError is a safety net for unexpected errors, not a replacement for domain errors.

Production Notes

The stock decrement is open to races. PlaceOrderAsync reads the stock and decrements it in memory; two orders arriving at the same moment can sell the same last book. A concurrency token from the EF Core series' concurrency post, or a conditional update with ExecuteUpdateAsync and WHERE Stock >= @quantity, is a must here. Hot Chocolate 16 also removed the old transaction scope handlers; instead of tying several root mutation fields to one transaction, put the things that must change together into a single coarse-grained mutation.

Keep the SQL log on during development and off in production. There's no other way to see that projection really works and which index a filter hits. In the next post we get the same information in production from an OpenTelemetry trace.

Export the schema in CI. With implementation-first the schema is born from code; adding a property to an entity can change the public API without anyone noticing. Keeping the /graphql?sdl output in the repo and looking at its diff in every PR catches these leaks and breaking changes.

Treat filter and sort arguments as a cost. Every field you expose is a query shape the client can run on your database. Sorting by an unindexed column also means a table scan; the default sort type exposes every field as well, and you can narrow it just like the filter.

Common Pitfalls

Sharing a DbContext between resolvers. A DbContext pulled into the request scope, or one buried inside a singleton service, produces random errors in parallel query fields.

Writing OrderBy in the resolver alongside the sorting middleware. The client's sort drops to the secondary key and silently has no effect.

Returning a materialized entity instead of an IQueryable and expecting navigations. The author of a book returned by FirstOrDefaultAsync is null without Include; GraphQL doesn't count that as an error.

Exposing the entity as is. Hiding the field isn't enough; check the filter and sort inputs too.

Leaving domain errors as exceptions. Every exception not marked with [Error<T>] becomes "Unexpected Execution Error" and takes the whole response with it because of the non-null payload.

A Small Experiment

★☆☆ Narrow BookSortInput the way BookFilterType does: allow sorting only by title and price. Confirm the schema change with ?sdl.

★★☆ Turn the stock decrement in PlaceOrderAsync into a conditional UPDATE with ExecuteUpdateAsync. Try ordering the last remaining book from two terminals at the same time and show with the SQL log that only one succeeds.

★★★ Separate the Book entity from the schema completely: define a BookDto record, change GetBooks to return db.Books.Select(b => new BookDto(...)), and check in the SQL log that projection, filtering and sorting still work together, meaning that WHERE and ORDER BY run in the database rather than in memory.

The schema, EF Core and mutations are in place. For now everything fits in a single JOIN, but the moment we add a field whose author comes from another data source, N+1 is back. In the next post we catch it with the SQL log and bring it down from 51 queries to 2 with DataLoaders and batch resolvers, and we limit the list with cursor pagination.