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:

Program.cs
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.

Example.cs
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:

Example.cs
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:

Example.cs
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:

graphql
mutation {
  addBook(input: { title: "The Pragmatic Programmer", author: "Hunt & Thomas", year: 1999 }) {
    id
    title
  }
}

When your domain has relationships, Hot Chocolate resolves them efficiently. Consider books with reviews:

Example.cs
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:

DataLoaders/BookReviewCountDataLoader.cs
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:

Example.cs
[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.