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 GraphQLerrorsarray viaIError— not 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:
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:
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
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
Becomes a generic GraphQL error with no code; client can't pattern-match.
✅ Correct: typed payload errors
❌ Wrong: no depth/complexity limits
Public endpoint = DoS vector.
✅ Correct: bounded
.AddMaxExecutionDepthRule(8)
.ModifyRequestOptions(o => { o.Complexity.Enable = true; o.Complexity.MaximumAllowed = 1000; });
❌ Wrong: leaking exception detail in prod
✅ Correct: error filter that sanitizes
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.