Learnixo
Back to blog
Backend Systemsintermediate

GraphQL in .NET with Hot Chocolate: Flexible APIs Beyond REST

Build GraphQL APIs in ASP.NET Core with Hot Chocolate. Covers schema-first vs code-first, queries, mutations, subscriptions, DataLoader, filtering, sorting, pagination, and when GraphQL beats REST.

LearnixoJune 4, 20266 min read
.NETC#GraphQLHot ChocolateAPIASP.NET Core
Share:𝕏

GraphQL vs REST

GraphQL is not a replacement for REST — it solves a specific problem: over-fetching and under-fetching.

REST: multiple round trips, fixed shapes
GET /orders/123        → entire order object (even unused fields)
GET /orders/123/items  → second request
GET /users/456         → third request

GraphQL: one request, exact shape
query {
  order(id: "123") {
    id
    total
    items { name quantity }
    customer { name email }
  }
}

Use GraphQL when:

  • Mobile clients need to minimise data transfer
  • Many consumers (web, mobile, partners) need different shapes of the same data
  • Rapid frontend iteration — frontend picks exactly what it needs without backend changes
  • Real-time subscriptions are needed alongside queries

Stick with REST when:

  • Simple CRUD with known, stable shapes
  • Public APIs that non-GraphQL clients will consume
  • File uploads are the primary use case

Setup

Bash
dotnet add package HotChocolate.AspNetCore
dotnet add package HotChocolate.Data
dotnet add package HotChocolate.Data.EntityFramework
C#
// Program.cs
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddMutationType<Mutation>()
    .AddSubscriptionType<Subscription>()
    .AddFiltering()
    .AddSorting()
    .AddProjections()
    .AddInMemorySubscriptions();  // or AddRedisSubscriptions()

app.MapGraphQL();  // /graphql endpoint + Banana Cake Pop IDE

Queries (Code-First)

C#
// GraphQL/Query.cs
public class Query
{
    // Single entity
    public async Task<Order?> GetOrderAsync(
        Guid id,
        [Service] IOrderRepository repo,
        CancellationToken ct)
        => await repo.GetByIdAsync(id, ct);

    // List with filtering, sorting, and pagination
    [UsePaging]          // cursor-based pagination
    [UseProjection]      // SELECT only requested fields
    [UseFiltering]       // WHERE clauses from query args
    [UseSorting]         // ORDER BY from query args
    public IQueryable<Order> GetOrders([Service] AppDbContext db)
        => db.Orders.Include(o => o.Lines);
}

Client query:

GRAPHQL
query {
  orders(
    where: { status: { eq: PENDING } }
    order: { createdAt: DESC }
    first: 10
  ) {
    edges {
      node {
        id
        total
        status
        customer { name }
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}

Type Definitions

C#
// GraphQL/Types/OrderType.cs
public class OrderType : ObjectType<Order>
{
    protected override void Configure(IObjectTypeDescriptor<Order> descriptor)
    {
        descriptor.Description("A customer order");

        descriptor.Field(o => o.Id).Description("Unique order identifier");
        descriptor.Field(o => o.Total).Description("Order total amount");

        // Computed field
        descriptor.Field("itemCount")
            .Type<NonNullType<IntType>>()
            .Resolve(ctx => ctx.Parent<Order>().Lines.Count);

        // Async resolver with DataLoader
        descriptor.Field(o => o.CustomerId)
            .Name("customer")
            .ResolveWith<OrderResolvers>(r => r.GetCustomerAsync(default!, default!, default));
    }
}

public class OrderResolvers
{
    public async Task<Customer?> GetCustomerAsync(
        [Parent] Order order,
        CustomerDataLoader loader,
        CancellationToken ct)
        => await loader.LoadAsync(order.CustomerId, ct);
}

DataLoader (N+1 Prevention)

Without DataLoader, loading 100 orders and their customers = 101 queries. DataLoader batches them into 1.

C#
// GraphQL/DataLoaders/CustomerDataLoader.cs
public class CustomerDataLoader : BatchDataLoader<Guid, Customer>
{
    private readonly IServiceScopeFactory _scopeFactory;

    public CustomerDataLoader(
        IServiceScopeFactory scopeFactory,
        IBatchScheduler scheduler,
        DataLoaderOptions options) : base(scheduler, options)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task<IReadOnlyDictionary<Guid, Customer>> LoadBatchAsync(
        IReadOnlyList<Guid> keys,
        CancellationToken ct)
    {
        using var scope = _scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        // One query for all requested customers
        return await db.Customers
            .Where(c => keys.Contains(c.Id))
            .ToDictionaryAsync(c => c.Id, ct);
    }
}

Register:

C#
builder.Services.AddGraphQLServer()
    .AddDataLoader<CustomerDataLoader>();

Mutations

C#
public class Mutation
{
    public async Task<CreateOrderPayload> CreateOrderAsync(
        CreateOrderInput input,
        [Service] IMediator mediator,
        CancellationToken ct)
    {
        var id = await mediator.Send(new CreateOrderCommand(
            input.CustomerId, input.Lines), ct);

        return new CreateOrderPayload(id, "Order created successfully");
    }

    public async Task<MutationResult<SubmitOrderPayload, OrderError>> SubmitOrderAsync(
        Guid orderId,
        [Service] IOrderRepository repo,
        [Service] IUnitOfWork uow,
        CancellationToken ct)
    {
        var order = await repo.GetByIdAsync(orderId, ct);
        if (order is null)
            return new OrderError("ORDER_NOT_FOUND", $"Order {orderId} not found");

        try
        {
            order.Submit();
            await uow.SaveChangesAsync(ct);
            return new SubmitOrderPayload(orderId, order.Status.ToString());
        }
        catch (DomainException ex)
        {
            return new OrderError("DOMAIN_ERROR", ex.Message);
        }
    }
}

// Input / payload types
public record CreateOrderInput(Guid CustomerId, List<OrderLineInput> Lines);
public record OrderLineInput(Guid ProductId, int Quantity, decimal UnitPrice);
public record CreateOrderPayload(Guid OrderId, string Message);
public record SubmitOrderPayload(Guid OrderId, string Status);
public record OrderError(string Code, string Message);

GraphQL client:

GRAPHQL
mutation {
  createOrder(input: {
    customerId: "abc-123"
    lines: [{ productId: "p1", quantity: 2, unitPrice: 15.00 }]
  }) {
    orderId
    message
  }
}

Subscriptions (Real-Time)

C#
public class Subscription
{
    [Subscribe]
    [Topic("OrderCreated")]
    public Order OnOrderCreated([EventMessage] Order order) => order;

    [Subscribe]
    [Topic("{customerId}")]  // per-customer topic
    public Order OnCustomerOrderCreated(
        string customerId,
        [EventMessage] Order order) => order;
}
C#
// Publish from a domain event handler
public class OrderCreatedHandler : INotificationHandler<OrderCreatedEvent>
{
    private readonly ITopicEventSender _sender;

    public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
    {
        var order = await _repo.GetByIdAsync(notification.OrderId, ct);
        await _sender.SendAsync("OrderCreated", order!, ct);
        await _sender.SendAsync($"{order!.CustomerId}", order, ct);  // per-customer
    }
}

Client subscription:

GRAPHQL
subscription {
  onOrderCreated {
    id
    total
    status
    customer { name }
  }
}

Authorization

C#
builder.Services.AddGraphQLServer()
    .AddAuthorization();

// On type
[Authorize]
public class Mutation { }

// On field
public class Query
{
    [Authorize(Roles = new[] { "Admin" })]
    public IQueryable<User> GetUsers([Service] AppDbContext db) => db.Users;

    [Authorize(Policy = "CanViewOrders")]
    public IQueryable<Order> GetOrders([Service] AppDbContext db) => db.Orders;
}

Error Handling

C#
// Register custom error filter
builder.Services.AddGraphQLServer()
    .AddErrorFilter<AppErrorFilter>();

public class AppErrorFilter : IErrorFilter
{
    public IError OnError(IError error)
    {
        if (error.Exception is DomainException ex)
            return error.WithMessage(ex.Message).WithCode("DOMAIN_ERROR");

        if (error.Exception is NotFoundException nfe)
            return error.WithMessage(nfe.Message).WithCode("NOT_FOUND");

        // Don't expose internal details in production
        return error.WithMessage("An unexpected error occurred");
    }
}

Interview Questions

Q: What problem does GraphQL solve that REST doesn't? Over-fetching (REST returns more data than needed) and under-fetching (REST requires multiple requests for related data). GraphQL lets the client specify exactly which fields it needs in a single request. This is especially valuable for mobile clients with bandwidth constraints and for scenarios where many clients need different data shapes.

Q: What is the N+1 problem in GraphQL and how does DataLoader solve it? When resolving a list of N orders, each with a customer reference, a naive implementation fires 1 query for orders + N queries for customers. DataLoader batches all customer IDs from the current execution tick into a single WHERE id IN (...) query — reducing N+1 to 2 queries.

Q: What is the difference between a Query, Mutation, and Subscription in GraphQL? Query: read-only data fetch. Mutation: state-changing operation (create, update, delete) — semantically equivalent to POST/PUT/DELETE. Subscription: real-time event stream — the server pushes updates to the client over WebSocket when matching events occur.

Q: When would you choose GraphQL over REST? When clients have diverse data needs and over/under-fetching is a real problem, when rapid frontend iteration requires frequent data shape changes, or when real-time subscriptions are needed alongside queries. REST is still better for simple CRUD, file uploads, HTTP caching, and public APIs consumed by non-GraphQL clients.

GraphQL Knowledge Check

5 questions · Test what you just learned · Instant explanations

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.