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.
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
dotnet add package HotChocolate.AspNetCore
dotnet add package HotChocolate.Data
dotnet add package HotChocolate.Data.EntityFramework// Program.cs
builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddSubscriptionType<Subscription>()
.AddFiltering()
.AddSorting()
.AddProjections()
.AddInMemorySubscriptions(); // or AddRedisSubscriptions()
app.MapGraphQL(); // /graphql endpoint + Banana Cake Pop IDEQueries (Code-First)
// 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:
query {
orders(
where: { status: { eq: PENDING } }
order: { createdAt: DESC }
first: 10
) {
edges {
node {
id
total
status
customer { name }
}
}
pageInfo { hasNextPage endCursor }
}
}Type Definitions
// 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.
// 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:
builder.Services.AddGraphQLServer()
.AddDataLoader<CustomerDataLoader>();Mutations
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:
mutation {
createOrder(input: {
customerId: "abc-123"
lines: [{ productId: "p1", quantity: 2, unitPrice: 15.00 }]
}) {
orderId
message
}
}Subscriptions (Real-Time)
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;
}// 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:
subscription {
onOrderCreated {
id
total
status
customer { name }
}
}Authorization
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
// 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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.