Skip to content

GraphQL with HotChocolate

Key Points

  • GraphQL is a query language for APIs: client asks for exactly the fields it wants; server returns just those. One endpoint (POST /graphql); typed schema; introspection built-in.
  • HotChocolate (ChilliCream) is the canonical .NET GraphQL stack. Code-first via attributes/fluent API, schema-first also supported. .NET community heavily favors code-first.
  • DataLoader is the cure for N+1: batch + cache field resolutions per request. Without it, GraphQL trivially issues N database round-trips for a list.
  • Subscriptions over WebSockets (graphql-ws) or SSE. Mutations for writes. Queries for reads.
  • Authorization integrates with ASP.NET Core policies ([Authorize(Policy = "...")]). Errors flow through the GraphQL errors array via IErrornot HTTP status codes.
  • DoS surface: clients can request the universe. Mitigate with query complexity limits, depth limits, persisted queries, and pagination.
  • Federation (Apollo Federation v2) via HotChocolate Fusion — composable subgraphs into a supergraph.
  • vs REST: GraphQL wins for rich clients with varied shapes, federated graphs, mobile apps with bandwidth concerns. REST still better for public APIs, HTTP-cache-friendly endpoints, and simple CRUD.
  • vs gRPC: gRPC is service-to-service typed RPC; GraphQL is client-shaped query. Different problem spaces.

Concepts (deep dive)

Schema-first vs code-first

Schema-first — write SDL (Schema Definition Language), generate/wire resolvers:

type Query {
    user(id: ID!): User
}
type User {
    id: ID!
    name: String!
    orders: [Order!]!
}

Code-first — define types as C# classes; HotChocolate derives the schema:

public class User
{
    public Guid Id { get; set; }
    public string Name { get; set; } = "";
}

public class Query
{
    public User? GetUser(Guid id, [Service] IUserRepo repo) => repo.Find(id);
}

builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>();

.NET prefers code-first because: refactoring tools, nullable reference types map to nullable schema fields, no SDL drift, dependency injection on resolvers feels natural. HotChocolate also supports annotation-based with [QueryType], [ObjectType<T>] extensions, plus a fluent descriptor API for full control.

Resolvers

A resolver is a function that produces a field's value. By default, HotChocolate uses property accessors for simple fields and methods as resolvers for fields needing arguments / DI / async work.

public class Query
{
    // Resolver method — args + DI injected automatically
    public async Task<User?> GetUserAsync(
        Guid id,
        IUserRepo repo,
        CancellationToken ct)
        => await repo.FindAsync(id, ct);
}

[ExtendObjectType<User>]
public class UserResolvers
{
    // Field resolver — runs when client requests `user.orders`
    public async Task<IEnumerable<Order>> GetOrdersAsync(
        [Parent] User user,
        IOrderRepo repo,
        CancellationToken ct)
        => await repo.ListByUserAsync(user.Id, ct);
}

[Parent] injects the parent object. CancellationToken is honored automatically.

The N+1 problem and DataLoader

Naive resolver — fetch each user's orders one query at a time:

{
  users { id name orders { total } }
}

If 100 users → 1 query for users + 100 queries for orders = 101 round-trips. This is the canonical GraphQL cliff.

DataLoader batches keys collected during one execution tick and caches per-request:

public class OrdersByUserDataLoader : BatchDataLoader<Guid, Order[]>
{
    private readonly IDbContextFactory<AppDb> _dbf;

    public OrdersByUserDataLoader(
        IBatchScheduler scheduler,
        IDbContextFactory<AppDb> dbf,
        DataLoaderOptions? options = null)
        : base(scheduler, options) => _dbf = dbf;

    protected override async Task<IReadOnlyDictionary<Guid, Order[]>> LoadBatchAsync(
        IReadOnlyList<Guid> userIds,
        CancellationToken ct)
    {
        await using var db = await _dbf.CreateDbContextAsync(ct);
        return await db.Orders
            .Where(o => userIds.Contains(o.UserId))
            .GroupBy(o => o.UserId)
            .ToDictionaryAsync(g => g.Key, g => g.ToArray(), ct);
    }
}

Resolver consumes it:

public async Task<Order[]> GetOrdersAsync(
    [Parent] User user,
    OrdersByUserDataLoader loader,
    CancellationToken ct)
    => await loader.LoadAsync(user.Id, ct);

Result: 1 query for users + 1 batched query for all orders = 2 round-trips. Cached: if the same userId is requested twice in one operation, it's fetched once.

HotChocolate has source-gen for DataLoaders ([DataLoader] attribute) — even less boilerplate.

Pagination — Relay cursor connections

[UsePaging]
public IQueryable<User> GetUsers(AppDb db) => db.Users.OrderBy(u => u.Id);

Schema becomes:

type Query {
    users(first: Int, after: String, last: Int, before: String): UserConnection
}
type UserConnection {
    edges: [UserEdge!]!
    nodes: [User!]!
    pageInfo: PageInfo!
    totalCount: Int!
}

Cursor encodes the position; opaque to the client. Stable under inserts (unlike offset). Also: [UseFiltering], [UseSorting], [UseProjection] — translate GraphQL selection into EF Core query (only fetches columns you ask for).

Mutations + input types

public record CreateUserInput(string Name, string Email);
public record CreateUserPayload(User? User, IReadOnlyList<UserError>? Errors);

public class Mutation
{
    public async Task<CreateUserPayload> CreateUserAsync(
        CreateUserInput input,
        IUserRepo repo,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(input.Email))
            return new(null, [new UserError("Email required", "EMAIL_REQUIRED")]);

        var u = await repo.CreateAsync(input.Name, input.Email, ct);
        return new(u, null);
    }
}

Convention: <Verb><Entity>Input for args, <Verb><Entity>Payload for results, errors as a typed list (not exceptions). HotChocolate has Mutation Conventions (AddMutationConventions()) that generate this shape automatically.

Subscriptions

public class Subscription
{
    [Subscribe]
    [Topic("orders.placed")]
    public Order OnOrderPlaced([EventMessage] Order order) => order;
}

builder.Services
    .AddGraphQLServer()
    .AddInMemorySubscriptions()         // dev; use Redis in prod
    .AddSubscriptionType<Subscription>();

// Publishing:
await topicEventSender.SendAsync("orders.placed", order, ct);

Transports: - graphql-ws over WebSockets (modern; supersedes subscriptions-transport-ws). - SSE for one-way push when WS is awkward.

For multi-instance deployments use AddRedisSubscriptions so events fan out across replicas.

Authorization

[Authorize]                                     // any authenticated user
public IQueryable<Order> GetOrders(...) => ...;

[Authorize(Policy = "AdminOnly")]               // ASP.NET Core policy
public Task<bool> DeleteUser(Guid id) => ...;

Per-field auth — clients see schema entries they can't actually call (you can hide them with [AllowAnonymous] plus introspection rules). HotChocolate hooks into IAuthorizationService, so policies, requirements, and handlers behave exactly like in MVC/Minimal APIs.

Error handling

GraphQL spec: response is always 200 OK with shape { data, errors }. Errors are first-class:

public class DomainErrorFilter : IErrorFilter
{
    public IError OnError(IError error)
    {
        if (error.Exception is NotFoundException nf)
            return error
                .WithMessage(nf.Message)
                .WithCode("NOT_FOUND");
        return error;
    }
}

builder.Services.AddGraphQLServer().AddErrorFilter<DomainErrorFilter>();

Don't leak stack traces. In dev, o.IncludeExceptionDetails = true; in prod, sanitize.

Federation — HotChocolate Fusion

Multiple subgraphs (each an independent service) compose into one supergraph:

[Users service]   [Orders service]   [Catalog service]
        \              |                   /
         \____________ Fusion gateway ____/
                          |
                       Client

Each subgraph publishes a schema; Fusion composes them at build time and routes fields. Replaces older Apollo Federation gateways for .NET shops.

Banana Cake Pop / Nitro

The HotChocolate IDE — query editor, schema browser, request history, document collections. Now branded Nitro. Mounted at /graphql by default in dev.

Persisted queries

Client sends a hash; server has the document mapped from build-time. Benefits: smaller request, whitelist enforcement (only known queries allowed in prod — DoS killed), CDN-cacheable.

.UsePersistedOperationPipeline()
.AddFileSystemOperationDocumentStorage("./persisted-queries")
.ModifyRequestOptions(o => o.OnlyAllowPersistedDocuments = true);

Query complexity & depth limits

.AddMaxExecutionDepthRule(8)
.ModifyRequestOptions(o =>
{
    o.Complexity.Enable = true;
    o.Complexity.MaximumAllowed = 1000;
});

A malicious client can request user { friends { friends { friends { ... } } } } recursively. Without limits, one request walks your whole graph. Always set both.

vs REST

Scenario Pick
Many client variants needing different shapes GraphQL
One backend, many heterogeneous frontends GraphQL
Federated graph across teams GraphQL (Fusion)
Public API for unknown clients REST
HTTP caching is critical REST
Simple CRUD, single team, single client REST
Streaming uploads / large binaries REST

vs gRPC

gRPC is service-to-service typed RPC with predefined contracts. GraphQL is client-shaped query with flexible projection. Use gRPC behind the wall; GraphQL at the edge. They coexist: BFF speaks GraphQL to clients, gRPC to internal services.

REST vs gRPC vs GraphQL — three-way comparison

The pairwise sections above each cover one boundary. Here's the explicit three-way at a glance — the question that actually shows up in interviews and architecture reviews.

Aspect REST gRPC GraphQL
Wire format JSON (text) Protobuf (binary) JSON over HTTP, single endpoint
Schema language OpenAPI (optional, after the fact) .proto (mandatory, source of truth) SDL (mandatory, code-first or schema-first)
Browser-native Yes No (needs gRPC-Web proxy) Yes
Streaming SSE / WebSockets bolted on First-class (uni + bi-directional) Subscriptions over WS / SSE
Caching layer HTTP caches everywhere (CDN, proxies, browser) None native — POSTs aren't cacheable Hard — single endpoint, POSTs; needs persisted-query CDN tricks
Versioning approach URL/header version, additive evolution Field numbers, never reuse, never rename Schema evolution: deprecate fields, never break
Code-gen story OpenAPI → Kiota / NSwag (optional) Protoc → mandatory client + server stubs StrawberryShake / GraphQL-Codegen (optional)
Best for Public APIs, browser clients, HTTP cache leverage Service-to-service, low latency, polyglot mesh Many client shapes, federation, BFF

Decision matrix: - Public API for browsers + caching + versioning maturity → REST. - Service-to-service in a polyglot mesh, low latency, schema strictness → gRPC. - Multiple client shapes (web, mobile, partner), federation, the client picks the response shape → GraphQL. - Hybrid is normal and usually correct: REST for public, gRPC for internal east-west, GraphQL when you genuinely have N clients × M services and a BFF makes sense.

⚠️ Anti-patterns to avoid: - gRPC for browser-direct without gRPC-Web — wire-incompatible; browsers can't speak HTTP/2 trailers. - GraphQL with N+1 fields and no DataLoader — performance disaster. See the DataLoader section above. - REST that's actually REST-y but spelled "GraphQL" — single Query type with one field per old endpoint defeats the point. If clients always ask for the same shape, you didn't need GraphQL. - Versioning gRPC by renaming fields — break wire compatibility silently. Field numbers are forever.


How it works under the hood

Request
  |
  v
[Parser]      -> AST
  |
  v
[Validator]   -> schema check, depth/complexity rules
  |
  v
[Operation compiler] -> selection set tree
  |
  v
[Executor]    -> walks tree, invokes resolvers
  |             - field resolvers run in parallel where safe
  |             - DataLoader batches collected keys at scheduler tick
  |             - per-request cache de-dups same key
  v
[Result]      -> JSON shape mirroring the query

HotChocolate compiles operations to a delegate tree on first execution and caches them by document hash, so subsequent identical queries skip parsing and validation. DataLoader uses IBatchScheduler to dispatch when the executor yields — the magic that makes "1 + N round-trips" become "1 + 1".


Code: correct vs wrong

❌ Wrong: N+1 resolver

public async Task<Order[]> GetOrdersAsync([Parent] User user, IOrderRepo repo)
    => (await repo.ListByUserAsync(user.Id)).ToArray();   // fires per user

✅ Correct: DataLoader

public async Task<Order[]> GetOrdersAsync(
    [Parent] User user, OrdersByUserDataLoader loader, CancellationToken ct)
    => await loader.LoadAsync(user.Id, ct);

❌ Wrong: throw exceptions for domain errors

if (user is null) throw new InvalidOperationException("not found");

Becomes a generic GraphQL error with no code; client can't pattern-match.

✅ Correct: typed payload errors

return new GetUserPayload(null, [new UserError("Not found", "NOT_FOUND")]);

❌ Wrong: no depth/complexity limits

builder.Services.AddGraphQLServer().AddQueryType<Query>();

Public endpoint = DoS vector.

✅ Correct: bounded

.AddMaxExecutionDepthRule(8)
.ModifyRequestOptions(o => { o.Complexity.Enable = true; o.Complexity.MaximumAllowed = 1000; });

❌ Wrong: leaking exception detail in prod

.ModifyRequestOptions(o => o.IncludeExceptionDetails = true);

✅ Correct: error filter that sanitizes

.AddErrorFilter<DomainErrorFilter>();

Design patterns for this topic

Pattern 1 — "Code-first schema"

  • Intent: schema derived from C# types; refactoring-safe.

Pattern 2 — "DataLoader for every list field"

  • Intent: kill N+1 by default.

Pattern 3 — "Mutation conventions"

  • Intent: consistent Input / Payload / typed errors.

Pattern 4 — "Persisted operations in prod"

  • Intent: allowlist queries; smaller payloads; no ad-hoc DoS.

Pattern 5 — "Fusion supergraph"

  • Intent: one client-facing graph, many service-owned subgraphs.

Pros & cons / trade-offs

Aspect Pros Cons
GraphQL Client-shaped; one endpoint; introspection Caching harder; DoS surface
HotChocolate Mature; .NET-native; DataLoader source-gen Learning curve; opinionated
Code-first Refactor-safe; DI-friendly SDL not the source of truth
Federation/Fusion Team autonomy Composition tooling complexity
Subscriptions Push without polling Stateful; needs Redis at scale

When to use / when to avoid

  • Use for rich clients with diverse shape needs.
  • Use for federated graphs across teams.
  • Use when mobile bandwidth matters (only fetch what you render).
  • Avoid for simple internal CRUD.
  • Avoid for public APIs where HTTP cache is the win.
  • Avoid as a primary service-to-service transport (use gRPC).

Interview Q&A

Q1. What's GraphQL? Query language for APIs; client picks fields; one endpoint; typed schema.

Q2. HotChocolate code-first vs schema-first? Code-first defines C# types and derives the schema. .NET community defaults to code-first for refactoring and DI ergonomics.

Q3. What's N+1 in GraphQL? Naive resolvers fetch each child individually. 100 parents → 101 queries. DataLoader batches per request.

Q4. DataLoader caching scope? Per-request (per execution context). Same key in the same operation = one fetch.

Q5. Pagination style? Relay-style cursor connections. first/after / last/before. Stable under inserts.

Q6. Subscriptions transport? graphql-ws over WebSockets (modern). SSE supported. For multi-instance, Redis backplane.

Q7. Errors in GraphQL? Always 200 OK; errors live in the errors array. Use typed payloads with error codes for domain errors; reserve top-level errors for system failures.

Q8. Authorization? [Authorize] + ASP.NET Core policies; integrates with IAuthorizationService.

Q9. Persisted queries? Client sends a hash; server resolves to a known document. Reduces payload, allowlists queries, blocks ad-hoc DoS.

Q10. Query complexity / depth limits? Cap recursion (MaxExecutionDepth) and field cost (Complexity.MaximumAllowed). Mandatory for public endpoints.

Q11. HotChocolate Fusion? Apollo Federation v2-compatible composition for .NET. Multiple subgraphs into one supergraph.

Q12. GraphQL vs REST vs gRPC? GraphQL: client-shaped at the edge. REST: cacheable public APIs. gRPC: service-to-service typed RPC. Pick per boundary.


Gotchas / common mistakes

  • ⚠️ No DataLoader — N+1 by default.
  • ⚠️ No depth/complexity limits — DoS vector.
  • ⚠️ Exposing exception detail in prod.
  • ⚠️ Throwing for domain errors — clients can't pattern-match.
  • ⚠️ Forgetting subscription backplane — events lost across replicas.
  • ⚠️ Returning HTTP non-200 for GraphQL errors — breaks spec-compliant clients.
  • ⚠️ Putting auth into resolvers instead of policies — inconsistent.

Further reading