Skip to main content
SEMastery
ASP.NETbeginner

Getting Started with Hot Chocolate GraphQL in ASP.NET Core

A friendly beginner guide to building a GraphQL API in ASP.NET Core with Hot Chocolate. Learn queries, mutations, resolvers, and DataLoaders with simple examples.

13 min readUpdated December 21, 2025

Ordering food the smart way

Imagine you go to a small restaurant with your family. There are two ways to order.

In the first way, the waiter brings you a fixed thali. You get rice, two sabzis, dal, roti, a sweet, and a pickle — whether you want all of it or not. If you only wanted dal and rice, too bad. You still get the whole plate, and some food goes to waste.

In the second way, you tell the waiter exactly what you want. "One plate rice, one bowl dal, two rotis. That is all." The kitchen makes only that. Nothing extra, nothing missing.

A normal REST API is like the fixed thali. You call /users/5 and the server sends back the whole user object, even fields you do not need. GraphQL is like the second waiter. The client asks for the exact fields it wants, and the server sends back only those.

Hot Chocolate is a tool that helps you build that smart waiter in .NET. It is a free, open-source GraphQL server made by a team called ChilliCream. You write normal C# classes, and Hot Chocolate turns them into a GraphQL API. Let us build one together, step by step.

What is GraphQL, in one minute

GraphQL is a way for a client (like a website or a mobile app) to ask a server for data. It has three big ideas:

  • One endpoint. Instead of many URLs like /users, /users/5, /users/5/orders, there is usually just one URL: /graphql.
  • You ask for exactly what you want. The client writes a small query that lists the fields it needs. The server returns only those fields.
  • The schema is the contract. The server publishes a "schema" that describes every type and field. Tools can read it and help you write correct queries.

Here is the same data request, REST style versus GraphQL style.

NeedREST wayGraphQL way
Get one user's nameGET /users/5, then pick name from the big responseAsk for user(id: 5) { name }
Get a user and their ordersGET /users/5 then GET /users/5/orders (two calls)One query: user(id: 5) { name orders { total } }
Avoid extra fieldsHard — server decides the shapeEasy — client lists only the fields it wants

Now let us see how a GraphQL request travels through your app.

A GraphQL request flows into one endpoint, gets validated against the schema, and resolvers fetch the data.

Setting up the project

You need the .NET SDK installed. Hot Chocolate 15 runs on .NET 8 and .NET 9. Open a terminal and create a fresh web project.

dotnet new web -n FruitShop
cd FruitShop
dotnet add package HotChocolate.AspNetCore

The dotnet new web command makes a tiny empty web app. The dotnet add package command pulls in Hot Chocolate. That single package gives you everything you need to serve GraphQL over HTTP.

Now open Program.cs. We will register the GraphQL server and map its endpoint.

var builder = WebApplication.CreateBuilder(args);
 
// Register the GraphQL server and tell it which type holds our queries.
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>();
 
var app = builder.Build();
 
// Expose the GraphQL endpoint at /graphql.
app.MapGraphQL();
 
app.Run();

Two lines do the magic. AddGraphQLServer() sets up the engine. MapGraphQL() opens the /graphql URL where clients send queries. We pointed it at a Query class that we have not written yet, so let us write it.

Your first query

A query is a read. It asks the server for data without changing anything. In Hot Chocolate, a query is just a C# class with methods. Each method becomes a field clients can ask for.

Make a file called Query.cs.

public record Fruit(int Id, string Name, string Colour, decimal PricePerKg);
 
public class Query
{
    private static readonly List<Fruit> Fruits = new()
    {
        new Fruit(1, "Mango", "Yellow", 120m),
        new Fruit(2, "Banana", "Yellow", 50m),
        new Fruit(3, "Apple", "Red", 180m),
    };
 
    // This becomes a field called "fruits" in the schema.
    public IEnumerable<Fruit> GetFruits() => Fruits;
 
    // This becomes a field called "fruit".
    public Fruit? GetFruit(int id) => Fruits.FirstOrDefault(f => f.Id == id);
}

Notice the method names start with Get. By convention, Hot Chocolate drops the word "Get" and lowercases the first letter. So GetFruits becomes the field fruits, and GetFruit becomes fruit. The id parameter automatically becomes a GraphQL argument.

Run the app with dotnet run. Open your browser at http://localhost:5000/graphql. You will see Nitro, the built-in GraphQL IDE. It is a friendly playground where you type queries and see results. Try this query:

query {
  fruits {
    name
    pricePerKg
  }
}

You asked for only name and pricePerKg. Even though Fruit also has id and colour, the server returns only what you asked for. That is the smart-waiter idea in action.

From C# class to live query

Write Query class
Register with AddQueryType
Map /graphql
Open Nitro
Run a query

Steps

1

Write Query class

Methods become fields

2

Register

AddQueryType<Query>()

3

Map endpoint

MapGraphQL()

4

Open Nitro

Built-in playground

5

Run a query

Ask for exact fields

The steps that turn your plain C# code into a working GraphQL query.

How the schema is built

You did not write any schema by hand. Hot Chocolate looked at your C# types and built the schema for you. This is called the code-first (or annotation-based) approach. Your C# is the single source of truth.

Here is how the pieces map to each other.

C# codeGraphQL schemaMeaning
class Querytype QueryThe root of all reads
GetFruits() methodfruits: [Fruit!]! fieldA list clients can request
record Fruittype FruitAn object type with fields
int id parameterfruit(id: Int!) argumentInput the client passes

This diagram shows how Hot Chocolate reads your types and produces the schema.

Hot Chocolate inspects your C# classes and generates a GraphQL schema at startup.

Adding mutations to change data

Reading is only half the story. A mutation is a write. It creates, updates, or deletes data. Mutations work like queries — a class with methods — but you register them with AddMutationType.

A good habit is to make each mutation take one input object and return a payload object. The input groups all the arguments. The payload lets the client read back the result. Let us add a mutation that adds a new fruit.

public record AddFruitInput(string Name, string Colour, decimal PricePerKg);
public record AddFruitPayload(Fruit Fruit);
 
public class Mutation
{
    public AddFruitPayload AddFruit(AddFruitInput input)
    {
        var newFruit = new Fruit(
            Id: Random.Shared.Next(1000, 9999),
            Name: input.Name,
            Colour: input.Colour,
            PricePerKg: input.PricePerKg);
 
        // In a real app you would save to a database here.
        return new AddFruitPayload(newFruit);
    }
}

Now wire it up in Program.cs.

builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>();

Back in Nitro, run this mutation:

mutation {
  addFruit(input: { name: "Guava", colour: "Green", pricePerKg: 60 }) {
    fruit {
      id
      name
    }
  }
}

The server creates the fruit and returns its new id and name. Notice you still pick which fields come back. The payload pattern keeps things tidy and lets you add error info later without breaking clients.

The mutation lifecycle

Client sends mutation
Bind input object
Run mutation method
Save changes
Return payload

Steps

1

Send mutation

With input object

2

Bind input

Map JSON to C# record

3

Run method

Business logic here

4

Save

DB or store

5

Return payload

Client picks fields

What happens when a client sends a mutation to change data.

Resolvers and dependency injection

So far our methods just returned data from a static list. In a real app you need a database, an HTTP client, or some service. The methods that fetch data for a field are called resolvers. A resolver can ask for services through normal constructor-style injection, right in the method parameters.

Hot Chocolate needs to know which parameters are services and which are user arguments. Modern Hot Chocolate figures most of this out automatically, but you can be explicit with attributes when needed.

public class Query
{
    // Hot Chocolate injects the DbContext for us.
    public async Task<List<Fruit>> GetFruits(
        AppDbContext db,
        CancellationToken cancellationToken)
        => await db.Fruits.ToListAsync(cancellationToken);
 
    public async Task<Fruit?> GetFruit(
        int id,                       // user argument
        AppDbContext db,              // injected service
        CancellationToken ct)
        => await db.Fruits.FindAsync(new object[] { id }, ct);
}

Here id is something the client passes. AppDbContext and CancellationToken are provided by the framework. You did not write any plumbing. This makes resolvers feel like plain C# methods, which is one reason Hot Chocolate is pleasant to use.

To make the DbContext available per request, register it with the GraphQL server using a pooled factory. This keeps each request isolated and fast.

builder.Services.AddDbContextPool<AppDbContext>(
    o => o.UseSqlite("Data Source=fruits.db"));
 
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .RegisterDbContextFactory<AppDbContext>();

The N+1 problem and DataLoaders

GraphQL makes it very easy to ask for connected data. Picture a query that asks for every fruit and the farmer who grew each one.

query {
  fruits {
    name
    farmer { name }
  }
}

If you write the naive resolver, the server fetches the list of fruits (1 query), then loops and fetches each farmer one by one. With 100 fruits, that is 1 + 100 = 101 database trips. This is the famous N+1 problem, and it makes APIs slow.

The fix is a DataLoader. It collects all the farmer IDs the resolvers ask for, fetches them in a single batch, and hands each result back to the right place. Hot Chocolate uses a library called GreenDonut for this, and it was rewritten to be faster in Hot Chocolate 15.

Without a DataLoader you get many small queries; with one you get a single batched query.

Here is a simple batch DataLoader. It takes a set of IDs and returns a dictionary keyed by ID.

public class FarmerByIdDataLoader : BatchDataLoader<int, Farmer>
{
    private readonly IDbContextFactory<AppDbContext> _factory;
 
    public FarmerByIdDataLoader(
        IDbContextFactory<AppDbContext> factory,
        IBatchScheduler scheduler,
        DataLoaderOptions options)
        : base(scheduler, options) => _factory = factory;
 
    protected override async Task<IReadOnlyDictionary<int, Farmer>> LoadBatchAsync(
        IReadOnlyList<int> keys, CancellationToken ct)
    {
        await using var db = await _factory.CreateDbContextAsync(ct);
        return await db.Farmers
            .Where(f => keys.Contains(f.Id))
            .ToDictionaryAsync(f => f.Id, ct);
    }
}

Then use it inside a resolver. When many fruits ask for their farmer, the loader batches all those calls into one database trip.

public async Task<Farmer> GetFarmer(
    [Parent] Fruit fruit,
    FarmerByIdDataLoader loader,
    CancellationToken ct)
    => await loader.LoadAsync(fruit.FarmerId, ct);

The [Parent] attribute gives the resolver the fruit it belongs to, so it knows which FarmerId to load. With this in place, 100 fruits cause just 2 database queries total instead of 101.

Useful built-in features

Once your basic API works, Hot Chocolate gives you powerful extras with almost no code. You add small attributes and the engine does the rest.

FeatureWhat it doesHow to enable
FilteringLets clients filter a list, like where: { colour: { eq: "Red" } }[UseFiltering] on the resolver
SortingLets clients sort a list by any field[UseSorting] on the resolver
PagingReturns data in pages with cursors[UsePaging] on the resolver
ProjectionsSelects only the database columns the client asked for[UseProjection] on the resolver

For example, this one resolver supports filtering, sorting, and paging at the same time:

[UsePaging]
[UseFiltering]
[UseSorting]
public IQueryable<Fruit> GetFruits(AppDbContext db) => db.Fruits;

The order of the attributes matters — paging wraps the outside, then filtering and sorting run on the query before the page is taken. Because you return IQueryable, the filters and sorts are translated into efficient SQL by Entity Framework Core. Clients get a flexible API and your database stays happy.

When should you choose GraphQL?

GraphQL is a great tool, but it is not always the right one. Use this quick guide.

Good fit for GraphQL:

  • Your data is highly connected (users, orders, products, reviews all linked).
  • Different clients need very different shapes of the same data.
  • Mobile apps on slow networks need to avoid extra round trips and extra bytes.

Maybe stick with REST:

  • Your API is simple and mostly returns flat resources.
  • You rely heavily on HTTP caching and CDNs (REST caches more easily).
  • Your team is small and already comfortable with REST.

The good news is you do not have to pick forever. Many teams run a REST API and a GraphQL API side by side over the same services. You can start small and grow.

A quick note on the ecosystem

If you read older tutorials, you might see libraries like MediatR and MassTransit used alongside GraphQL for handling commands and messaging. As of recent versions, both of those have moved to a commercial license for larger companies, so check their terms before adding them to a new project. Hot Chocolate itself remains free and open-source under the MIT license, so you can build a full GraphQL API without those extras.

Quick recap

  • GraphQL lets clients ask for exactly the fields they want from a single endpoint, like a waiter who brings only what you order.
  • Hot Chocolate is ChilliCream's free, open-source GraphQL server for .NET. It runs on .NET 8 and .NET 9, with Hot Chocolate 15 being the current major version.
  • Add the HotChocolate.AspNetCore package, call AddGraphQLServer(), and map the endpoint with MapGraphQL().
  • A query is a read; register it with AddQueryType<Query>(). Method names lose their Get prefix to form field names.
  • A mutation is a write; register it with AddMutationType<Mutation>(). Prefer one input object and a payload return.
  • Resolvers are the methods that fetch data. They can take user arguments and injected services in the same parameter list.
  • Use DataLoaders (GreenDonut) to batch lookups and avoid the slow N+1 problem.
  • Add [UseFiltering], [UseSorting], and [UsePaging] to give clients powerful list features with little code.
  • Choose GraphQL when data is connected and clients vary; REST is still great for simple, cache-heavy APIs.

References and further reading

Related Posts