GraphQL (Hot Chocolate)
Overview
Hot Chocolate is the most popular GraphQL server library for .NET. It enables building type-safe GraphQL APIs with support for queries, mutations, subscriptions, filtering, sorting, pagination, data loaders (for N+1 prevention), and schema stitching. Hot Chocolate integrates with ASP.NET Core, Entity Framework Core, and the standard .NET dependency injection system. It supports both code-first and schema-first approaches and generates a fully introspectable GraphQL schema.
Server Setup
Configure Hot Chocolate in an ASP.NET Core application.
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddSubscriptionType<Subscription>()
.AddFiltering()
.AddSorting()
.AddProjections()
.AddInMemorySubscriptions();
var app = builder.Build();
app.UseWebSockets();
app.MapGraphQL();
app.Run();
Query Type with Filtering, Sorting, and Pagination
Define queries that expose data with built-in filtering and cursor-based pagination.
using HotChocolate;
using HotChocolate.Data;
using HotChocolate.Types;
using Microsoft.EntityFrameworkCore;
namespace MyApp.GraphQL;
public class Query
{
[UsePaging(IncludeTotalCount = true)]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<Product> GetProducts(AppDbContext context)
=> context.Products.AsNoTracking();
public async Task<Product?> GetProductById(
int id,
AppDbContext context)
=> await context.Products
.Include(p => p.Category)
.FirstOrDefaultAsync(p => p.Id == id);
[UseFiltering]
[UseSorting]
public IQueryable<Customer> GetCustomers(AppDbContext context)
=> context.Customers.AsNoTracking();
}
Mutation Type
Define mutations for data modification operations.
using HotChocolate;
using HotChocolate.Types;
namespace MyApp.GraphQL;
public class Mutation
{
public async Task<CreateProductPayload> CreateProduct(
CreateProductInput input,
AppDbContext context)
{
var product = new Product
{
Name = input.Name,
Price = input.Price,
CategoryId = input.CategoryId,
Description = input.Description
};
context.Products.Add(product);
await context.SaveChangesAsync();
return new CreateProductPayload(product);
}
public async Task<UpdateProductPayload> UpdateProduct(
int id,
UpdateProductInput input,
AppDbContext context)
{
var product = await context.Products.FindAsync(id);
if (product is null)
{
return new UpdateProductPayload(
product: null,
error: new UserError("Product not found.", "PRODUCT_NOT_FOUND"));
}
if (input.Name is not null) product.Name = input.Name;
if (input.Price.HasValue) product.Price = input.Price.Value;
if (input.Description is not null) product.Description = input.Description;
await context.SaveChangesAsync();
return new UpdateProductPayload(product, error: null);
}
public async Task<bool> DeleteProduct(int id, AppDbContext context)
{
var product = await context.Products.FindAsync(id);
if (product is null) return false;
context.Products.Remove(product);
await context.SaveChangesAsync();
return true;
}
}
// Input and Payload types
public record CreateProductInput(string Name, decimal Price, int CategoryId, string? Description);
public record UpdateProductInput(string? Name, decimal? Price, string? Description);
public record CreateProductPayload(Product Product);
public record UpdateProductPayload(Product? Product, UserError? Error);
public record UserError(string Message, string Code);
DataLoader for N+1 Prevention
Use DataLoaders to batch database queries and prevent the N+1 query problem.
using GreenDonut;
using HotChocolate;
using Microsoft.EntityFrameworkCore;
namespace MyApp.GraphQL;
public class CategoryBatchDataLoader : BatchDataLoader<int, Category>
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public CategoryBatchDataLoader(
IDbContextFactory<AppDbContext> contextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options)
{
_contextFactory = contextFactory;
}
protected override async Task<IReadOnlyDictionary<int, Category>> LoadBatchAsync(
IReadOnlyList<int> keys,
CancellationToken cancellationToken)
{
await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken);
return await context.Categories
.Where(c => keys.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, cancellationToken);
}
}
// Usage in a type extension
[ExtendObjectType(typeof(Product))]
public class ProductExtensions
{
public async Task<Category?> GetCategory(
[Parent] Product product,
CategoryBatchDataLoader loader)
=> await loader.LoadAsync(product.CategoryId);
}
GraphQL vs REST Comparison
| Feature | GraphQL (Hot Chocolate) | REST (ASP.NET Core) |
|---|---|---|
| Data fetching | Client specifies fields | Server defines response shape |
| Over-fetching | Eliminated | Common |
| Under-fetching | Eliminated | Requires multiple calls |
| Versioning | Schema evolution | URL or header versioning |
| Caching | Per-field, complex | HTTP caching, simple |
| File upload | Multipart spec (complex) | Native support |
| Real-time | Subscriptions (WebSocket) | SignalR / SSE |
| Tooling | GraphQL Playground, Banana Cake Pop | Swagger, Postman |
Best Practices
Use
[UseProjection]on every query that returnsIQueryable<T>so that Hot Chocolate generates SQLSELECTstatements containing only the fields requested by the client, preventing full-table reads when the client queries onlyidandnamefrom an entity with 20 columns.Implement
BatchDataLoader<TKey, TValue>for every foreign key relationship (e.g.,Product.CategoryIdtoCategory) and register it via type extensions using[ExtendObjectType], so that resolving the category for 50 products results in oneWHERE Id IN (...)query instead of 50 individual queries.Use
AddDbContextFactory<T>()instead ofAddDbContext<T>()when using DataLoaders because DataLoaders outlive the HTTP request scope and hold references to scoped services;IDbContextFactorycreates short-livedDbContextinstances per batch that are disposed after use.Define input types for mutations (
CreateProductInput) and return payload types (CreateProductPayload) with optionalUserErrorfields rather than throwing exceptions, following the GraphQL convention where errors are part of the response payload, not transport-level failures.Enable
AddFiltering()andAddSorting()on list queries but restrict the filterable/sortable fields using[UseFiltering(typeof(ProductFilterType))]with a custom filter type, to prevent clients from filtering on expensive computed columns or sensitive fields likePasswordHash.Use cursor-based pagination with
[UsePaging]instead of offset-based pagination for lists that may change between page loads, because cursor pagination guarantees no items are skipped or duplicated when new items are inserted; offset pagination shifts results when the underlying data changes.Register GraphQL services using
AddGraphQLServer()and callMapGraphQL()at a dedicated path (default/graphql) rather than embedding GraphQL resolution inside REST controllers, keeping the two API styles isolated and independently configurable.Apply
[Authorize]attributes on query/mutation resolvers that require authentication and use[GraphQLAuthorize(Policy = "AdminOnly")]for role-based access control, rather than checkingClaimsPrincipalmanually in every resolver method.Use Hot Chocolate's
ITopicEventSenderandITopicEventReceiverfor subscriptions rather than implementing custom WebSocket handlers, because Hot Chocolate manages subscription lifecycle, serialization, and concurrent subscriber tracking through the configured subscription provider (in-memory or Redis).Monitor resolver execution times using Hot Chocolate's built-in instrumentation events (
IExecutionDiagnosticEvents) and log queries that exceed a threshold (e.g., 500ms), focusing optimization on the slowest resolvers; avoid premature optimization of resolvers that complete in single-digit milliseconds.