Skip to main content

.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:

PropertyDescriptionExample
projectService domain name for entities and servicesShopping Cart
suffixUsed with project name for package namingService
group-prefixPackage naming prefixcom.company
team-nameTeam ownership identifierGrowth
service-portGraphQL service port5000
management-portHealth check and metrics port5001

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

  1. Generate the service:

    archetect render git@github.com:p6m-archetypes/dotnet-graphql-service-basic.archetype.git
  2. Run locally with Docker Compose:

    docker-compose up -d
  3. Apply database migrations:

    dotnet ef database update
  4. Start the service:

    dotnet run --project src/ServiceName.Api
  5. Access GraphQL Playground:

    http://localhost:5000/graphql
  6. Test a query:

    query {
    shoppingCartItems {
    nodes {
    id
    name
    price
    quantity
    }
    totalCount
    }
    }
  7. 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