Building GraphQL APIs with Hot Chocolate in .NET
GraphQL gives clients the power to ask for exactly the data they need — no more, no less. In the .NET ecosystem, Hot Chocolate is the most mature and feature-rich GraphQL server. It supports annotation-based types, code-first schemas, filtering, sorting, pagination, subscriptions, and integrates cleanly with Entity Framework Core.
Setting Up
Install the HotChocolate.AspNetCore package and wire up the server:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>();
var app = builder.Build();
app.MapGraphQL(); // Serves at /graphql by default
app.Run();
This gives you a fully functional GraphQL endpoint with Banana Cake Pop — Hot Chocolate's built-in IDE — available at /graphql in the browser.
Defining Types
Hot Chocolate supports annotation-based types where your C# classes directly become GraphQL types. This is the simplest approach and suits most projects.
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Author { get; set; }
public int Year { get; set; }
public string? Isbn { get; set; }
}
public class Query
{
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<Book> GetBooks([Service] AppDbContext context)
=> context.Books;
public async Task<Book?> GetBookById(
int id,
[Service] AppDbContext context,
CancellationToken ct)
=> await context.Books.FindAsync([id], ct);
}
The [UseFiltering] and [UseSorting] attributes generate GraphQL arguments automatically. With [UseProjection], Hot Chocolate translates the GraphQL field selection into a SQL SELECT — only requested columns hit the database.
Register the EF Core integration:
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddProjections()
.AddFiltering()
.AddSorting();
Mutations
Mutations follow the same annotation-based pattern. Use input types to keep the schema clean:
public record AddBookInput(string Title, string Author, int Year, string? Isbn);
public class Mutation
{
public async Task<Book> AddBook(
AddBookInput input,
[Service] AppDbContext context,
CancellationToken ct)
{
var book = new Book
{
Title = input.Title,
Author = input.Author,
Year = input.Year,
Isbn = input.Isbn
};
context.Books.Add(book);
await context.SaveChangesAsync(ct);
return book;
}
}
A client can then call:
mutation {
addBook(input: { title: "The Pragmatic Programmer", author: "Hunt & Thomas", year: 1999 }) {
id
title
}
}
Resolving Related Data
When your domain has relationships, Hot Chocolate resolves them efficiently. Consider books with reviews:
public class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Author { get; set; }
public int Year { get; set; }
public List<Review> Reviews { get; set; } = [];
}
public class Review
{
public int Id { get; set; }
public int BookId { get; set; }
public required string Reviewer { get; set; }
public int Rating { get; set; }
public required string Comment { get; set; }
}
With [UseProjection] on the query resolver, Hot Chocolate generates efficient SQL joins. If a client does not ask for reviews, the join is skipped entirely.
DataLoader for N+1 Prevention
When projection alone is not enough — for example, when resolving data from a separate service — use DataLoaders to batch requests:
public class BookReviewCountDataLoader : BatchDataLoader<int, int>
{
private readonly IDbContextFactory<AppDbContext> _contextFactory;
public BookReviewCountDataLoader(
IDbContextFactory<AppDbContext> contextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options)
{
_contextFactory = contextFactory;
}
protected override async Task<IReadOnlyDictionary<int, int>> LoadBatchAsync(
IReadOnlyList<int> keys, CancellationToken ct)
{
await using var context = await _contextFactory.CreateDbContextAsync(ct);
return await context.Reviews
.Where(r => keys.Contains(r.BookId))
.GroupBy(r => r.BookId)
.ToDictionaryAsync(g => g.Key, g => g.Count(), ct);
}
}
Hot Chocolate automatically batches all LoadAsync calls within a single GraphQL request into one database query.
Error Handling
Hot Chocolate supports structured error handling through mutation conventions. Annotate error types and Hot Chocolate generates union types in the schema:
[Error<BookNotFoundException>]
[Error<ValidationException>]
public class Mutation
{
public async Task<Book> AddBook(AddBookInput input, [Service] AppDbContext context, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Title))
throw new ValidationException("Title is required");
// ...
}
}
Clients receive typed error payloads rather than opaque error messages, enabling proper error handling on the frontend.
When GraphQL Fits
GraphQL shines when clients have diverse data needs — mobile apps that want minimal payloads, dashboards that aggregate across entities, or any scenario where multiple REST calls would be needed for a single view. The overhead is schema complexity and caching difficulty compared to REST. Hot Chocolate removes much of the server-side complexity, making it a strong choice for .NET teams exploring GraphQL.