Purpose

This post shows how to build a small GraphQL API in .NET 7 with GraphQL.NET, using a restaurant app with menus and categories as the example. It covers the basics (types, queries, mutations) and then nested queries, which most tutorials skip.

By the end you'll know how to:

  • Expose data through a single GraphQL endpoint
  • Let clients fetch related data (categories → menu items) in one request
  • Avoid the two most common nested-query mistakes

Background

A REST API has many endpoints, and each one returns a fixed shape:

GET /categories          → all fields of every category
GET /categories/1/menus  → all fields of every menu item in category 1

GraphQL has one endpoint. The client describes the shape it wants, and the server returns exactly that:

{
  categories {
    name
    menus { name price }
  }
}

On the server side there are only three ideas to hold on to. A type describes which fields of a model the client is allowed to see. A resolver is a small function that fetches the data for one field. The schema ties it all together: every query you can read with, and every mutation you can write with.

We'll use two models. One category has many menu items:

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ICollection<Menu> Menus { get; set; }
}

public class Menu
{
    public int Id { get; set; }
    public string Name { get; set; }
    public double Price { get; set; }
    public int CategoryId { get; set; }
}

Problem Statement

A restaurant front-end needs to show every category with its menu items: names and prices, nothing else.

With REST, that leads to one of two problems:

  • Under-fetching: call /categories, then /categories/{id}/menus for each one. That's many round trips.
  • Over-fetching: build a custom /categories-with-menus endpoint that returns everything, including fields the screen never uses. Every new screen then needs a new endpoint.

GraphQL solves this, but nested data introduces its own challenges on the server:

  1. Scoping: each category's menus must contain only its items, not every item in the database.
  2. N+1 queries: the nested resolver runs once per parent. 500 categories means 501 database calls.

Possible solutions

Option A: Stay with REST

Add endpoints as screens need them. This is simple and familiar, but the endpoint list grows with every new UI need, and clients still get fixed shapes.

Option B: GraphQL in .NET

Here is the minimal setup, step by step.

1. Packages

dotnet add package GraphQL.Server.Transports.AspNetCore --version 7.6.0
dotnet add package graphiql --version 2.0.0
dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 7.0.10

2. Types

public class MenuType : ObjectGraphType<Menu>
{
    public MenuType()
    {
        Field(m => m.Id);
        Field(m => m.Name);
        Field(m => m.Price);
        Field(m => m.CategoryId);
    }
}

Field(m => m.Name) maps a C# property to a GraphQL field. A property you leave out stays invisible to clients.

3. Queries with arguments

public class MenuQuery : ObjectGraphType
{
    public MenuQuery(IMenuRepository menuRepository)
    {
        Field<ListGraphType<MenuType>>("menus")
            .Resolve(context => menuRepository.GetAllMenu());

        Field<MenuType>("menu")
            .Arguments(new QueryArguments(
                new QueryArgument<NonNullGraphType<IntGraphType>> { Name = "menuId" }))
            .Resolve(context =>
                menuRepository.GetMenuById(context.GetArgument<int>("menuId")));
    }
}

NonNullGraphType makes the argument required. Without it, a missing menuId silently becomes 0.

4. Mutations with input types

GraphQL keeps input shapes and output shapes separate, so writes need an InputObjectGraphType:

public class MenuInputType : InputObjectGraphType
{
    public MenuInputType()
    {
        Field<StringGraphType>("name");
        Field<FloatGraphType>("price");
        Field<IntGraphType>("categoryId");
    }
}

public class MenuMutation : ObjectGraphType
{
    public MenuMutation(IMenuRepository menuRepository)
    {
        Field<MenuType>("createMenu")
            .Arguments(new QueryArguments(
                new QueryArgument<NonNullGraphType<MenuInputType>> { Name = "menu" }))
            .Resolve(context =>
                menuRepository.AddMenu(context.GetArgument<Menu>("menu")));
    }
}

5. Schema and Program.cs

public class RootSchema : GraphQL.Types.Schema
{
    public RootSchema(IServiceProvider sp) : base(sp)
    {
        Query = sp.GetRequiredService<RootQuery>();
        Mutation = sp.GetRequiredService<RootMutation>();
    }
}
builder.Services.AddTransient<IMenuRepository, MenuRepository>();
builder.Services.AddTransient<ICategoryRepository, CategoryRepository>();

builder.Services.AddTransient<MenuType>();
builder.Services.AddTransient<CategoryType>();
builder.Services.AddTransient<MenuInputType>();
builder.Services.AddTransient<MenuQuery>();
builder.Services.AddTransient<CategoryQuery>();
builder.Services.AddTransient<RootQuery>();
builder.Services.AddTransient<MenuMutation>();
builder.Services.AddTransient<RootMutation>();
builder.Services.AddTransient<ISchema, RootSchema>();

builder.Services.AddGraphQL(b => b.AddAutoSchema<ISchema>().AddSystemTextJson());

var app = builder.Build();
app.UseGraphiQl("/graphql");   // playground UI
app.UseGraphQL<ISchema>();     // the endpoint
app.Run();

Every type must be registered in DI. If you forget one, you get a runtime error, not a compile error.

Solving the nested-query problem

The nested menus field lives on CategoryType with its own resolver. There are three ways to write it.

B1. Return all menus (wrong)

Field<ListGraphType<MenuType>>("menus")
    .Resolve(context => menuRepository.GetAllMenu());

The query runs without errors, but every category shows every menu item. This mistake is easy to miss.

B2. Filter by the parent with context.Source (correct, simple)

Field<ListGraphType<MenuType>>("menus")
    .Resolve(context =>
    {
        // context.Source is the parent Category being resolved
        var categoryId = context.Source.Id;
        return menuRepository.GetAllMenu()
            .Where(m => m.CategoryId == categoryId);
    });

How it runs:

  1. The categories resolver runs once and returns N categories.
  2. The menus resolver runs once per category, with context.Source set to that category.
  3. If the client doesn't ask for menus, that resolver never runs.

This fixes scoping, but it is N+1: one database call per category.

B3. Batch with DataLoader (correct, scales)

GraphQL.NET's DataLoader collects every parent id first, then fetches all the children in one query:

Field<ListGraphType<MenuType>>("menus")
    .Resolve(context =>
    {
        var loader = accessor.Context.GetOrAddCollectionBatchLoader<int, Menu>(
            "GetMenusByCategoryIds",
            menuRepository.GetMenusByCategoryIds); // returns ILookup<int, Menu>
        return loader.LoadAsync(context.Source.Id);
    });

It needs a bit more setup (IDataLoaderContextAccessor and a repository method that returns an ILookup), but it turns 501 queries into 2.

Trying it

Open https://localhost:<port>/graphql:

{
  categoryQuery {
    categories {
      name
      menus { name price }
    }
  }
}

Two extras worth knowing. Variables keep input out of the query string:

mutation AddMenu($menu: MenuInput!) {
  menuMutation { createMenu(menu: $menu) { id name } }
}

Aliases let you query the same field twice in one request:

{
  menuQuery {
    wings: menu(menuId: 1) { name }
    steak: menu(menuId: 2) { name }
  }
}

Conclusion

REST still works fine here. The cost shows up over time: every new screen tends to ask for a new endpoint, or for more data than it needs. GraphQL moves that decision to the client, and for an app with related data like categories and menus, that's a good trade.

Inside GraphQL, the nested field is where you have to be careful. Returning everything without a filter (B1) looks fine in the playground and is still wrong. Filtering on context.Source (B2) gives correct results in a single request, and for a small menu it's all you need. It only starts to hurt when the parent list grows, because each parent costs one more database call. That's the point to switch to DataLoader (B3), which brings it back down to two queries no matter how many categories there are.

So my recommendation: use GraphQL when clients need flexible, related data. Write nested fields with context.Source first, and add DataLoader when the number of database calls starts to show up in your logs.

A few small things that caught me along the way: GraphQL.NET camelCases field names ("Menus" becomes menus), input and output types can't be shared, EF Core's Remove() does nothing until you call SaveChanges(), and any GraphQL type missing from DI only fails at runtime.

Tagged in: