hotchocolate-best-practices
HotChocolate Best Practices
You are implementing or reviewing a HotChocolate GraphQL server in .NET. All guidance targets HotChocolate 16.6 or newer — do not apply it to v15-or-older codebases without checking the migration guides. This skill defines how ChilliCream expects that code to look. It covers implementation — resolvers, data access, DataLoaders, pagination. Schema design (SDL shape, naming, nullability) belongs to the sibling skill graphql-schema-design; use both when a task spans design and implementation.
Core principles
-
Implementation-first. Write plain C# — static resolver classes with attributes like
[QueryType],[MutationType],[ObjectType<T>],[DataLoader]— and let HotChocolate infer the schema. Do not use schema-first SDL binding, and reach for descriptor/Configureoverrides only for what attributes cannot express. -
All data access flows through DataLoaders. A resolver for a relation (any non-root field that fetches data) never queries a
DbContextor repository directly — it calls a DataLoader. Direct queries create N+1 loads and bypass request-scoped caching. -
Top-level
byIdfields use DataLoaders too. Root lookups are batchable: clients batch them with field aliases in a single operation and with request/variable batching across operations. A root resolver that hits the database directly forfeits that coalescing. -
DataLoaders are source-generated. Write a static method annotated with
[DataLoader]; never hand-write a class deriving fromBatchDataLoader/GroupedDataLoader. The generator emits the class, interface, and registration. -
Projections flow through
QueryContext<T>. Every DataLoader accepts aQueryContext<TValue>parameter so consumers can pass selections down to the database, and always pins its key into the projection (query.Include(x => x.Id)) so the key survives narrowing. The flip side: every resolver that reads parent properties declares them with[Parent(requires: nameof(...))]so projections keep those fields available. -
Pagination is cursor-based and ordered. Paginated fields use
PagingArguments+ToPageAsync/ToBatchPageAsync; every paginated query has a deterministic order ending in the key, and paging on non-root types goes through a batch-paging DataLoader. Never offset pagination — relative cursors cover jump-to-page needs.