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.