.NET GraphQL Service Basic
A production-ready .NET GraphQL service archetype that provides flexible, efficient data querying with Entity Framework Core, comprehensive schema design, and modern GraphQL features.
Overview
This archetype generates a fully-functional .NET GraphQL service optimized for modern web applications. It includes Hot Chocolate GraphQL server, Entity Framework Core for data persistence, and comprehensive tooling for schema-first development.
Technology Stack
- .NET 8: Latest long-term support version
- Hot Chocolate: .NET GraphQL server
- GraphQL: Query language for APIs
- Entity Framework Core: Object-relational mapping
- PostgreSQL: Primary database
- Testcontainers .NET: Integration testing
- Docker: Containerization
- Strawberry Shake: .NET GraphQL client
- Tilt: Local Kubernetes development
Key Features
Core Functionality
- GraphQL Schema: Strongly-typed schema definition
- Query & Mutation Support: Complete CRUD operations via GraphQL
- Subscription Support: Real-time updates via GraphQL subscriptions
- Entity Framework Integration: Code-first database approach
- Data Loader: Efficient N+1 query prevention
Production Features
- Health Checks: GraphQL endpoint health monitoring
- Logging: Structured logging with Serilog
- Configuration: Environment-based configuration management
- Metrics: GraphQL query performance monitoring
- Docker Support: Optimized containerization
GraphQL Features
- Schema Stitching: Combine multiple schemas
- Filtering & Sorting: Advanced query capabilities
- Pagination: Cursor-based and offset pagination
- Schema Introspection: Development-time schema exploration
- GraphQL Playground: Interactive query interface
Project Structure
dotnet-graphql-service/
├── src/
│ ├── ServiceName.Api/ # GraphQL service implementation
│ │ ├── Schema/ # GraphQL schema definitions
│ │ │ ├── Types/ # GraphQL types
│ │ │ ├── Queries/ # Query resolvers
│ │ │ ├── Mutations/ # Mutation resolvers
│ │ │ └── Subscriptions/ # Subscription resolvers
│ │ ├── DataLoaders/ # Data loading optimization
│ │ ├── Extensions/ # GraphQL extensions
│ │ ├── Configuration/ # Service configuration
│ │ └── Program.cs # Application entry point
│ ├── ServiceName.Core/ # Business logic
│ │ ├── Entities/ # Domain entities
│ │ ├── Services/ # Business services
│ │ ├── Interfaces/ # Service contracts
│ │ └── Validators/ # Input validation
│ ├── ServiceName.Infrastructure/ # Data access layer
│ │ ├── Data/ # Entity Framework context
│ │ ├── Repositories/ # Data repositories
│ │ ├── Migrations/ # Database migrations
│ │ └── Configuration/ # EF configuration
│ └── ServiceName.Client/ # GraphQL client library
│ ├── Generated/ # Generated client code
│ └── Extensions/ # Client extensions
├── tests/
│ ├── ServiceName.Api.Tests/ # GraphQL integration tests
│ ├── ServiceName.Core.Tests/ # Unit tests
│ └── ServiceName.Schema.Tests/ # Schema validation tests
├── schema/ # GraphQL schema files
├── k8s/ # Kubernetes manifests
├── scripts/ # Build and deployment scripts
├── Dockerfile # Multi-stage Docker build
├── Tiltfile # Local development with Tilt
└── docker-compose.yml # Local development stack
Configuration & Prompts
During archetype generation, you'll be prompted for:
| Property | Description | Example |
|---|---|---|
project | Service domain name for entities and services | Shopping Cart |
suffix | Used with project name for package naming | Service |
group-prefix | Package naming prefix | com.company |
team-name | Team ownership identifier | Growth |
service-port | GraphQL service port | 5000 |
management-port | Health check and metrics port | 5001 |
GraphQL Schema Design
Type Definitions
// Entity class
public class ShoppingCartItem
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public int UserId { get; set; }
public User User { get; set; }
}
// GraphQL Type
public class ShoppingCartItemType : ObjectType<ShoppingCartItem>
{
protected override void Configure(IObjectTypeDescriptor<ShoppingCartItem> descriptor)
{
descriptor.Description("A shopping cart item");
descriptor
.Field(i => i.Id)
.Description("The unique identifier of the shopping cart item");
descriptor
.Field(i => i.Name)
.Description("The name of the item");
descriptor
.Field(i => i.Description)
.Description("The description of the item");
descriptor
.Field(i => i.Price)
.Description("The price of the item")
.Type<NonNullType<DecimalType>>();
descriptor
.Field(i => i.Quantity)
.Description("The quantity of the item")
.Type<NonNullType<IntType>>();
descriptor
.Field(i => i.CreatedAt)
.Description("When the item was created")
.Type<NonNullType<DateTimeType>>();
descriptor
.Field(i => i.UpdatedAt)
.Description("When the item was last updated")
.Type<NonNullType<DateTimeType>>();
descriptor
.Field(i => i.User)
.Description("The user who owns this item")
.ResolveWith<ShoppingCartItemResolvers>(r => r.GetUserAsync(default!, default!));
}
private class ShoppingCartItemResolvers
{
public async Task<User> GetUserAsync(
ShoppingCartItem item,
UserDataLoader userLoader)
{
return await userLoader.LoadAsync(item.UserId);
}
}
}
// Input Types
public record CreateShoppingCartItemInput(
string Name,
string Description,
decimal Price,
int Quantity,
int UserId);
public record UpdateShoppingCartItemInput(
int Id,
string? Name,
string? Description,
decimal? Price,
int? Quantity);
// Filter Types
public class ShoppingCartItemFilterInput
{
public StringOperationFilterInput? Name { get; set; }
public DecimalOperationFilterInput? Price { get; set; }
public IntOperationFilterInput? Quantity { get; set; }
public IntOperationFilterInput? UserId { get; set; }
}
Query Implementation
[ExtendObjectType("Query")]
public class ShoppingCartItemQueries
{
[UseDbContext(typeof(ServiceDbContext))]
[UsePaging(IncludeTotalCount = true)]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<ShoppingCartItem> GetShoppingCartItems(
[ScopedService] ServiceDbContext context)
{
return context.ShoppingCartItems.Include(i => i.User);
}
[UseDbContext(typeof(ServiceDbContext))]
[UseFirstOrDefault]
[UseProjection]
public IQueryable<ShoppingCartItem> GetShoppingCartItem(
[ScopedService] ServiceDbContext context,
int id)
{
return context.ShoppingCartItems
.Include(i => i.User)
.Where(i => i.Id == id);
}
[UseDbContext(typeof(ServiceDbContext))]
[UsePaging]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<ShoppingCartItem> GetShoppingCartItemsByUser(
[ScopedService] ServiceDbContext context,
int userId)
{
return context.ShoppingCartItems
.Include(i => i.User)
.Where(i => i.UserId == userId);
}
}
Mutation Implementation
[ExtendObjectType("Mutation")]
public class ShoppingCartItemMutations
{
[UseDbContext(typeof(ServiceDbContext))]
public async Task<ShoppingCartItem> CreateShoppingCartItemAsync(
CreateShoppingCartItemInput input,
[ScopedService] ServiceDbContext context,
ITopicEventSender eventSender,
CancellationToken cancellationToken)
{
// Validate input
if (string.IsNullOrWhiteSpace(input.Name))
{
throw new GraphQLException("Name is required");
}
if (input.Price <= 0)
{
throw new GraphQLException("Price must be greater than 0");
}
if (input.Quantity <= 0)
{
throw new GraphQLException("Quantity must be greater than 0");
}
// Check if user exists
var userExists = await context.Users.AnyAsync(u => u.Id == input.UserId, cancellationToken);
if (!userExists)
{
throw new GraphQLException($"User with ID {input.UserId} not found");
}
var item = new ShoppingCartItem
{
Name = input.Name,
Description = input.Description,
Price = input.Price,
Quantity = input.Quantity,
UserId = input.UserId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
context.ShoppingCartItems.Add(item);
await context.SaveChangesAsync(cancellationToken);
// Send subscription update
await eventSender.SendAsync(nameof(ShoppingCartItemSubscriptions.OnItemCreated), item, cancellationToken);
return item;
}
[UseDbContext(typeof(ServiceDbContext))]
public async Task<ShoppingCartItem> UpdateShoppingCartItemAsync(
UpdateShoppingCartItemInput input,
[ScopedService] ServiceDbContext context,
ITopicEventSender eventSender,
CancellationToken cancellationToken)
{
var item = await context.ShoppingCartItems.FindAsync(input.Id);
if (item == null)
{
throw new GraphQLException($"Shopping cart item with ID {input.Id} not found");
}
// Update only provided fields
if (!string.IsNullOrWhiteSpace(input.Name))
{
item.Name = input.Name;
}
if (input.Description != null)
{
item.Description = input.Description;
}
if (input.Price.HasValue)
{
if (input.Price <= 0)
{
throw new GraphQLException("Price must be greater than 0");
}
item.Price = input.Price.Value;
}
if (input.Quantity.HasValue)
{
if (input.Quantity <= 0)
{
throw new GraphQLException("Quantity must be greater than 0");
}
item.Quantity = input.Quantity.Value;
}
item.UpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync(cancellationToken);
// Send subscription update
await eventSender.SendAsync(nameof(ShoppingCartItemSubscriptions.OnItemUpdated), item, cancellationToken);
return item;
}
[UseDbContext(typeof(ServiceDbContext))]
public async Task<bool> DeleteShoppingCartItemAsync(
int id,
[ScopedService] ServiceDbContext context,
ITopicEventSender eventSender,
CancellationToken cancellationToken)
{
var item = await context.ShoppingCartItems.FindAsync(id);
if (item == null)
{
return false;
}
context.ShoppingCartItems.Remove(item);
await context.SaveChangesAsync(cancellationToken);
// Send subscription update
await eventSender.SendAsync(nameof(ShoppingCartItemSubscriptions.OnItemDeleted), id, cancellationToken);
return true;
}
}
Subscription Implementation
[ExtendObjectType("Subscription")]
public class ShoppingCartItemSubscriptions
{
[Subscribe]
[Topic]
public ShoppingCartItem OnItemCreated([EventMessage] ShoppingCartItem item) => item;
[Subscribe]
[Topic]
public ShoppingCartItem OnItemUpdated([EventMessage] ShoppingCartItem item) => item;
[Subscribe]
[Topic]
public int OnItemDeleted([EventMessage] int itemId) => itemId;
[Subscribe]
[Topic("item_updates_{userId}")]
public ShoppingCartItem OnUserItemUpdate([EventMessage] ShoppingCartItem item, int userId) => item;
}
Data Loader Implementation
User Data Loader
public class UserDataLoader : BatchDataLoader<int, User>
{
private readonly IDbContextFactory<ServiceDbContext> _dbContextFactory;
public UserDataLoader(
IDbContextFactory<ServiceDbContext> dbContextFactory,
IBatchScheduler batchScheduler,
DataLoaderOptions? options = null)
: base(batchScheduler, options)
{
_dbContextFactory = dbContextFactory;
}
protected override async Task<IReadOnlyDictionary<int, User>> LoadBatchAsync(
IReadOnlyList<int> keys,
CancellationToken cancellationToken)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
var users = await dbContext.Users
.Where(u => keys.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, cancellationToken);
return users;
}
}
// Registration in Program.cs
builder.Services
.AddGraphQLServer()
.AddDataLoader<UserDataLoader>();
GraphQL Client Usage
Client Generation Configuration
<!-- In .csproj file -->
<PackageReference Include="StrawberryShake.Tools" Version="13.5.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="StrawberryShake.Transport.Http" Version="13.5.1" />
<GraphQL Include="schema.graphql" />
<GraphQL Include="**/*.graphql" Exclude="schema.graphql" />
GraphQL Queries
# GetShoppingCartItems.graphql
query GetShoppingCartItems($userId: Int!, $first: Int, $after: String) {
shoppingCartItemsByUser(userId: $userId, first: $first, after: $after) {
nodes {
id
name
description
price
quantity
createdAt
user {
id
name
email
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
# CreateShoppingCartItem.graphql
mutation CreateShoppingCartItem($input: CreateShoppingCartItemInput!) {
createShoppingCartItem(input: $input) {
id
name
description
price
quantity
createdAt
user {
id
name
}
}
}
# ItemUpdates.graphql
subscription ItemUpdates($userId: Int!) {
onUserItemUpdate(userId: $userId) {
id
name
description
price
quantity
updatedAt
}
}
Client Usage
public class ShoppingCartGraphQLClient
{
private readonly IShoppingCartClient _client;
private readonly ILogger<ShoppingCartGraphQLClient> _logger;
public ShoppingCartGraphQLClient(
IShoppingCartClient client,
ILogger<ShoppingCartGraphQLClient> logger)
{
_client = client;
_logger = logger;
}
public async Task<IOperationResult<IGetShoppingCartItemsResult>> GetUserItemsAsync(
int userId,
int? first = null,
string? after = null)
{
try
{
var result = await _client.GetShoppingCartItems.ExecuteAsync(userId, first, after);
if (result.IsErrorResult())
{
foreach (var error in result.Errors)
{
_logger.LogError("GraphQL error: {Message}", error.Message);
}
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting shopping cart items for user {UserId}", userId);
throw;
}
}
public async Task<IOperationResult<ICreateShoppingCartItemResult>> CreateItemAsync(
CreateShoppingCartItemInput input)
{
try
{
var result = await _client.CreateShoppingCartItem.ExecuteAsync(input);
if (result.IsErrorResult())
{
foreach (var error in result.Errors)
{
_logger.LogError("GraphQL error creating item: {Message}", error.Message);
}
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating shopping cart item");
throw;
}
}
public async Task SubscribeToItemUpdatesAsync(
int userId,
CancellationToken cancellationToken = default)
{
try
{
var stream = await _client.ItemUpdates.ExecuteAsync(userId, cancellationToken);
await foreach (var result in stream.ReadEventsAsync().WithCancellation(cancellationToken))
{
if (result.IsErrorResult())
{
foreach (var error in result.Errors)
{
_logger.LogError("GraphQL subscription error: {Message}", error.Message);
}
continue;
}
var item = result.Data?.OnUserItemUpdate;
if (item != null)
{
_logger.LogInformation("Received item update: {ItemId} - {ItemName}",
item.Id, item.Name);
// Handle the update
await HandleItemUpdate(item);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in item updates subscription for user {UserId}", userId);
throw;
}
}
private async Task HandleItemUpdate(IItemUpdates_OnUserItemUpdate item)
{
// Process the item update
_logger.LogInformation("Processing update for item {Id}: {Name} - ${Price}",
item.Id, item.Name, item.Price);
}
}
Testing Strategy
GraphQL Integration Tests
public class ShoppingCartItemQueriesTests : IClassFixture<GraphQLTestFixture>
{
private readonly GraphQLTestFixture _fixture;
public ShoppingCartItemQueriesTests(GraphQLTestFixture fixture)
{
_fixture = fixture;
}
[Fact]
public async Task GetShoppingCartItems_ReturnsEmptyList_WhenNoItemsExist()
{
// Arrange
var query = @"
query {
shoppingCartItems {
nodes {
id
name
}
totalCount
}
}";
// Act
var result = await _fixture.ExecuteRequestAsync(query);
// Assert
result.MatchSnapshot();
Assert.True(result.IsSuccess);
}
[Fact]
public async Task CreateShoppingCartItem_ReturnsCreatedItem_WhenValidInput()
{
// Arrange
await _fixture.SeedUserAsync(1, "Test User", "test@example.com");
var mutation = @"
mutation($input: CreateShoppingCartItemInput!) {
createShoppingCartItem(input: $input) {
id
name
description
price
quantity
user {
id
name
}
}
}";
var variables = new Dictionary<string, object?>
{
["input"] = new Dictionary<string, object?>
{
["name"] = "Test Item",
["description"] = "Test Description",
["price"] = 19.99,
["quantity"] = 2,
["userId"] = 1
}
};
// Act
var result = await _fixture.ExecuteRequestAsync(mutation, variables);
// Assert
result.MatchSnapshot();
Assert.True(result.IsSuccess);
}
[Fact]
public async Task OnItemCreated_ReceivesUpdate_WhenItemIsCreated()
{
// Arrange
var subscription = @"
subscription {
onItemCreated {
id
name
price
}
}";
var mutation = @"
mutation($input: CreateShoppingCartItemInput!) {
createShoppingCartItem(input: $input) {
id
}
}";
// Act
var subscriptionResult = await _fixture.ExecuteSubscriptionAsync(subscription);
// Trigger the subscription by creating an item
await _fixture.ExecuteRequestAsync(mutation, new Dictionary<string, object?>
{
["input"] = new Dictionary<string, object?>
{
["name"] = "Subscription Test Item",
["price"] = 9.99,
["quantity"] = 1,
["userId"] = 1
}
});
// Read the subscription result
var updateResult = await subscriptionResult.ReadAsync();
// Assert
Assert.NotNull(updateResult);
updateResult.MatchSnapshot();
}
}
Schema Validation Tests
public class SchemaValidationTests
{
[Fact]
public async Task Schema_ShouldBeValid()
{
// Arrange
var services = new ServiceCollection();
services.AddDbContext<ServiceDbContext>(options =>
options.UseInMemoryDatabase("TestDb"));
services
.AddGraphQLServer()
.AddQueryType(d => d.Name("Query"))
.AddMutationType(d => d.Name("Mutation"))
.AddSubscriptionType(d => d.Name("Subscription"))
.AddTypeExtension<ShoppingCartItemQueries>()
.AddTypeExtension<ShoppingCartItemMutations>()
.AddTypeExtension<ShoppingCartItemSubscriptions>()
.AddType<ShoppingCartItemType>()
.AddFiltering()
.AddSorting()
.AddProjections();
var serviceProvider = services.BuildServiceProvider();
// Act
var executor = await serviceProvider.GetRequiredService<IRequestExecutorResolver>()
.GetRequestExecutorAsync();
var schema = executor.Schema;
// Assert
Assert.NotNull(schema);
// Validate schema can be printed
var schemaString = schema.Print();
Assert.NotEmpty(schemaString);
// Validate specific types exist
Assert.NotNull(schema.GetType<ObjectType>("ShoppingCartItem"));
Assert.NotNull(schema.GetType<ObjectType>("Query"));
Assert.NotNull(schema.GetType<ObjectType>("Mutation"));
Assert.NotNull(schema.GetType<ObjectType>("Subscription"));
}
}
Deployment
Docker Configuration
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 5000
EXPOSE 5001
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["src/ServiceName.Api/ServiceName.Api.csproj", "src/ServiceName.Api/"]
COPY ["src/ServiceName.Core/ServiceName.Core.csproj", "src/ServiceName.Core/"]
COPY ["src/ServiceName.Infrastructure/ServiceName.Infrastructure.csproj", "src/ServiceName.Infrastructure/"]
RUN dotnet restore "src/ServiceName.Api/ServiceName.Api.csproj"
COPY . .
WORKDIR "/src/src/ServiceName.Api"
RUN dotnet build "ServiceName.Api.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "ServiceName.Api.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ServiceName.Api.dll"]
Kubernetes Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: shopping-cart-graphql-service
spec:
replicas: 3
selector:
matchLabels:
app: shopping-cart-graphql-service
template:
metadata:
labels:
app: shopping-cart-graphql-service
spec:
containers:
- name: shopping-cart-graphql-service
image: your-registry/shopping-cart-graphql-service:latest
ports:
- containerPort: 5000
- containerPort: 5001
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
- name: ConnectionStrings__DefaultConnection
valueFrom:
secretKeyRef:
name: db-secret
key: connection-string
livenessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 5001
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: shopping-cart-graphql-service
spec:
selector:
app: shopping-cart-graphql-service
ports:
- name: graphql
port: 5000
targetPort: 5000
- name: management
port: 5001
targetPort: 5001
Quick Start
-
Generate the service:
archetect render git@github.com:p6m-archetypes/dotnet-graphql-service-basic.archetype.git -
Run locally with Docker Compose:
docker-compose up -d -
Apply database migrations:
dotnet ef database update -
Start the service:
dotnet run --project src/ServiceName.Api -
Access GraphQL Playground:
http://localhost:5000/graphql -
Test a query:
query {
shoppingCartItems {
nodes {
id
name
price
quantity
}
totalCount
}
} -
Run tests:
dotnet test
Best Practices
Schema Design
- Use descriptive names for types and fields
- Implement proper pagination
- Design for client needs, not database structure
- Use input types for mutations
Performance
- Implement DataLoaders for N+1 prevention
- Use projections to fetch only needed data
- Implement proper caching strategies
- Monitor query complexity
Security
- Validate all inputs in resolvers
- Implement authentication and authorization
- Use query depth limiting
- Monitor for malicious queries
Monitoring
- Track GraphQL query performance
- Monitor resolver execution times
- Log slow queries
- Set up alerting for errors