This guide walks through the full workflow for adding a new entity to the GraphQL API using HotChocolate. The example below extends the template with an Order entity, mirroring the pattern already used for Product and ProductReview.
The GraphQL stack is built with HotChocolate 15 and lives inside src/APITemplate/Api/GraphQL/. The pipeline for every entity is:
Domain entity
→ GraphQL Type (Api/GraphQL/Types/)
→ Query class (Api/GraphQL/Queries/)
→ Mutation class (Api/GraphQL/Mutations/)
→ [optional] DataLoader (Api/GraphQL/DataLoaders/)
→ Registration in ServiceCollectionExtensions.cs
A type class maps domain-entity properties to GraphQL field descriptors and adds descriptions visible in the schema.
src/APITemplate/Api/GraphQL/Types/OrderType.cs
using APITemplate.Domain.Entities;
namespace APITemplate.Api.GraphQL.Types;
public sealed class OrderType : ObjectType<Order>
{
protected override void Configure(IObjectTypeDescriptor<Order> descriptor)
{
descriptor.Description("Represents a customer order.");
descriptor.Field(o => o.Id)
.Type<NonNullType<UuidType>>()
.Description("The unique identifier of the order.");
descriptor.Field(o => o.CustomerId)
.Type<NonNullType<UuidType>>()
.Description("The customer who placed the order.");
descriptor.Field(o => o.TotalAmount)
.Type<NonNullType<DecimalType>>()
.Description("The total monetary value of the order.");
descriptor.Field(o => o.CreatedAt)
.Description("The UTC timestamp of when the order was created.");
}
}Tip: Only list fields you want exposed. Omitting a field keeps it out of the public schema.
Queries return data. Use the HotChocolate middleware attributes to add automatic paging, filtering, sorting, and projection (EF Core pushes these into SQL automatically).
src/APITemplate/Api/GraphQL/Queries/OrderQueries.cs
using APITemplate.Domain.Entities;
using APITemplate.Domain.Interfaces;
namespace APITemplate.Api.GraphQL.Queries;
public class OrderQueries
{
// Returns a paged connection — client sends `first`/`after` or `last`/`before`.
[UsePaging(MaxPageSize = 100, DefaultPageSize = 20)]
[UseProjection] // EF Core generates SELECT only for requested fields
[UseFiltering] // Adds `where` argument to the query
[UseSorting] // Adds `order` argument to the query
public IQueryable<Order> GetOrders([Service] IOrderRepository repo)
=> repo.AsQueryable();
// Returns a single item or null.
[UseFirstOrDefault]
[UseProjection]
public IQueryable<Order> GetOrderById(
Guid id,
[Service] IOrderRepository repo)
=> repo.AsQueryable().Where(o => o.Id == id);
}Why
IQueryable? ReturningIQueryablelets HotChocolate translate the client'swhere/order/field-selection into a single optimised SQL query rather than loading everything in memory.
Mutations change state. Inject the relevant application service and delegate all business logic to it. Use [Authorize] to require a valid JWT token.
src/APITemplate/Api/GraphQL/Mutations/OrderMutations.cs
using APITemplate.Application.DTOs;
using APITemplate.Application.Interfaces;
using HotChocolate.Authorization;
namespace APITemplate.Api.GraphQL.Mutations;
[Authorize]
public class OrderMutations
{
public async Task<OrderResponse> CreateOrder(
CreateOrderRequest input,
[Service] IOrderService orderService,
CancellationToken ct)
{
return await orderService.CreateAsync(input, ct);
}
public async Task<bool> DeleteOrder(
Guid id,
[Service] IOrderService orderService,
CancellationToken ct)
{
await orderService.DeleteAsync(id, ct);
return true;
}
}Open src/APITemplate/Extensions/ServiceCollectionExtensions.cs and extend AddGraphQLConfiguration():
public static IServiceCollection AddGraphQLConfiguration(this IServiceCollection services)
{
services
.AddGraphQLServer()
// --- existing registrations ---
.AddQueryType<ProductQueries>()
.AddTypeExtension<ProductReviewQueries>()
.AddMutationType<ProductMutations>()
.AddTypeExtension<ProductReviewMutations>()
.AddType<ProductType>()
.AddType<ProductReviewType>()
// --- new registrations ---
.AddQueryType<OrderQueries>() // use AddTypeExtension<OrderQueries>() if defined as a type extension
.AddMutationType<OrderMutations>() // use AddTypeExtension<OrderMutations>() if defined as a type extension
.AddType<OrderType>()
// --- middleware ---
.AddAuthorization()
.AddProjections()
.AddFiltering()
.AddSorting()
.ModifyPagingOptions(o =>
{
o.MaxPageSize = 100;
o.DefaultPageSize = 20;
o.IncludeTotalCount = true;
})
.AddMaxExecutionDepthRule(5);
return services;
}
AddTypeExtensionvsAddQueryType: UseAddQueryTypefor the first query class; useAddTypeExtensionfor all subsequent ones. The same rule applies to mutations.
A DataLoader prevents the N+1 problem when a query fetches a collection of parents and then loads a child collection for each. Without it, 100 orders would trigger 101 queries (1 for orders + 1 per order for its items).
src/APITemplate/Api/GraphQL/DataLoaders/OrderItemsByOrderDataLoader.cs
using APITemplate.Domain.Entities;
using APITemplate.Domain.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace APITemplate.Api.GraphQL.DataLoaders;
public sealed class OrderItemsByOrderDataLoader : BatchDataLoader<Guid, OrderItem[]>
{
private readonly IOrderItemRepository _repo;
public OrderItemsByOrderDataLoader(
IOrderItemRepository repo,
IBatchScheduler batchScheduler,
DataLoaderOptions options = default!)
: base(batchScheduler, options)
{
_repo = repo;
}
protected override async Task<IReadOnlyDictionary<Guid, OrderItem[]>> LoadBatchAsync(
IReadOnlyList<Guid> orderIds,
CancellationToken ct)
{
// One SQL query for ALL order IDs at once
var items = await _repo.AsQueryable()
.Where(i => orderIds.Contains(i.OrderId))
.ToListAsync(ct);
var lookup = items.ToLookup(i => i.OrderId);
return orderIds
.Distinct()
.ToDictionary(id => id, id => lookup[id].ToArray());
}
}Then wire the DataLoader inside OrderType.cs:
// In OrderType.Configure():
descriptor.Field(o => o.Items)
.ResolveWith<OrderTypeResolvers>(r => r.GetItems(default!, default!))
.Description("The line items belonging to this order.");
// Resolver class (in the same file or a separate one):
internal sealed class OrderTypeResolvers
{
public Task<OrderItem[]> GetItems(
[Parent] Order order,
OrderItemsByOrderDataLoader loader)
=> loader.LoadAsync(order.Id);
}Register the DataLoader in AddGraphQLConfiguration():
.AddDataLoader<OrderItemsByOrderDataLoader>()When is a DataLoader NOT needed? If your query resolver returns
IQueryablewith[UseProjection], EF Core builds a JOIN automatically — no DataLoader required. Use a DataLoader only when the child data comes from a different source or when you use a manual resolver.
Start the application and open the Nitro playground at /graphql/ui.
Query example:
query {
orders(
first: 10
where: { totalAmount: { gte: 100 } }
order: { createdAt: DESC }
) {
nodes {
id
customerId
totalAmount
createdAt
}
totalCount
pageInfo {
hasNextPage
endCursor
}
}
}Mutation example (requires Authorization: Bearer <token> header):
mutation {
createOrder(input: {
customerId: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
totalAmount: 199.99
}) {
id
createdAt
}
}| File | Purpose |
|---|---|
Api/GraphQL/Types/ |
GraphQL schema type definitions |
Api/GraphQL/Queries/ |
Read resolvers |
Api/GraphQL/Mutations/ |
Write resolvers |
Api/GraphQL/DataLoaders/ |
N+1 prevention helpers |
Extensions/ServiceCollectionExtensions.cs |
Central DI / GraphQL registration |
Program.cs |
app.MapGraphQL() and app.MapNitroApp("/graphql/ui") |