# The Ultimate .NET 10 Clean Architecture Boilerplate: Step-by-Step Implementation Guide
This guide is an exhaustive, detail-oriented manual for recreating a .NET 10 Clean Architecture and CQRS boilerplate from scratch. It documents every file in the repository in strict chronological order—you will build the core first, and layer dependencies outward, so you never have to reference a file that doesn't exist yet.
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Prerequisites & Environment Setup](#2-prerequisites--environment-setup)
3. [Solution & Project Scaffolding](#3-solution--project-scaffolding)
4. [Root Configuration Files](#4-root-configuration-files)
5. [Layer 1: Domain](#5-layer-1-domain)
6. [Layer 2: Application](#6-layer-2-application)
7. [Layer 3: Infrastructure](#7-layer-3-infrastructure)
8. [Layer 4: API / Presentation](#8-layer-4-api--presentation)
9. [Docker & Dev Environment](#9-docker--dev-environment)
10. [Test Projects](#10-test-projects)
11. [CI/CD Pipeline](#11-cicd-pipeline)
12. [AI-Assisted Development](#12-ai-assisted-development)
13. [Running the Project](#13-running-the-project)
14. [Appendix A: Outbox Pattern (Optional)](#appendix-a-outbox-pattern-optional)
---
## 1. Architecture Overview
Clean Architecture organises code into concentric layers where dependencies **always point inward**.
- **Domain** references nothing.
- **Application** references Domain only.
- **Infrastructure** references Application (and transitively, Domain).
- **API** references Application and Infrastructure.
This guarantees the Domain and Application layers are fully testable without a database, web server, or framework.
---
## 2. Prerequisites & Environment Setup
Ensure you have the **.NET 10 SDK**, **Docker**, and your preferred IDE installed.
To make this guide copy-paste friendly, open your terminal and set your project variables. We use two variables: one for C# PascalCase naming, and one for lowercase systems (Docker/PostgreSQL).
```bash
export PROJECT="Inventory"
export PROJECT_LOWER="inventory"
```
---
## 3. Solution & Project Scaffolding
Create the root directory and initialize the solution and projects using your variables:
```bash
mkdir ${PROJECT_LOWER}-api
cd ${PROJECT_LOWER}-api
# Create the solution (slnx = lightweight XML format)
dotnet new slnx -n ${PROJECT}
# Create the source projects
dotnet new webapi -n ${PROJECT}.Api -o src/${PROJECT}.Api
dotnet new classlib -n ${PROJECT}.Application -o src/${PROJECT}.Application
dotnet new classlib -n ${PROJECT}.Domain -o src/${PROJECT}.Domain
dotnet new classlib -n ${PROJECT}.Infrastructure -o src/${PROJECT}.Infrastructure
# Create the test projects
dotnet new xunit -n ${PROJECT}.Application.Tests -o tests/${PROJECT}.Application.Tests
dotnet new xunit -n ${PROJECT}.Domain.Tests -o tests/${PROJECT}.Domain.Tests
dotnet new xunit -n ${PROJECT}.IntegrationTests -o tests/${PROJECT}.IntegrationTests
# Add source projects to the solution
dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Api/${PROJECT}.Api.csproj --solution-folder src
dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Application/${PROJECT}.Application.csproj --solution-folder src
dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Domain/${PROJECT}.Domain.csproj --solution-folder src
dotnet sln ${PROJECT}.slnx add src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj --solution-folder src
# Add test projects to the solution
dotnet sln ${PROJECT}.slnx add tests/${PROJECT}.Application.Tests/${PROJECT}.Application.Tests.csproj --solution-folder tests
dotnet sln ${PROJECT}.slnx add tests/${PROJECT}.Domain.Tests/${PROJECT}.Domain.Tests.csproj --solution-folder tests
dotnet sln ${PROJECT}.slnx add tests/${PROJECT}.IntegrationTests/${PROJECT}.IntegrationTests.csproj --solution-folder tests
# Configure Clean Architecture Dependencies
dotnet add src/${PROJECT}.Application/${PROJECT}.Application.csproj reference src/${PROJECT}.Domain/${PROJECT}.Domain.csproj
dotnet add src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj reference src/${PROJECT}.Application/${PROJECT}.Application.csproj
dotnet add src/${PROJECT}.Api/${PROJECT}.Api.csproj reference src/${PROJECT}.Application/${PROJECT}.Application.csproj
dotnet add src/${PROJECT}.Api/${PROJECT}.Api.csproj reference src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj
```
---
## 4. Root Configuration Files
These files lock SDK versions and manage NuGet packages centrally. Create them at the repository root.
### `global.json`
```json
{
"sdk": {
"rollForward": "latestFeature",
"version": "10.0.103"
}
}
```
### `Directory.Build.props`
```xml
net10.0
enable
enable
true
```
### `Directory.Packages.props`
```xml
true
```
### Apply NuGet Packages to Projects
Now that versioning is centralized, add the packages to your projects.
```bash
# Application Layer
dotnet add src/${PROJECT}.Application package MediatR
dotnet add src/${PROJECT}.Application package FluentValidation
dotnet add src/${PROJECT}.Application package FluentValidation.DependencyInjectionExtensions
dotnet add src/${PROJECT}.Application package Microsoft.Extensions.Logging.Abstractions
# Infrastructure Layer
dotnet add src/${PROJECT}.Infrastructure package Microsoft.EntityFrameworkCore
dotnet add src/${PROJECT}.Infrastructure package Microsoft.EntityFrameworkCore.Design
dotnet add src/${PROJECT}.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add src/${PROJECT}.Infrastructure package Newtonsoft.Json
dotnet add src/${PROJECT}.Infrastructure package Quartz.Extensions.Hosting
# API Layer
dotnet add src/${PROJECT}.Api package Serilog.AspNetCore
dotnet add src/${PROJECT}.Api package Serilog.Enrichers.Environment
dotnet add src/${PROJECT}.Api package Serilog.Enrichers.Thread
dotnet add src/${PROJECT}.Api package AspNetCore.HealthChecks.NpgSql
dotnet add src/${PROJECT}.Api package Microsoft.AspNetCore.OpenApi
dotnet add src/${PROJECT}.Api package Microsoft.EntityFrameworkCore.Tools
dotnet add src/${PROJECT}.Api package Asp.Versioning.Mvc
dotnet add src/${PROJECT}.Api package Asp.Versioning.Mvc.ApiExplorer
# Test Projects (Domain)
dotnet add tests/${PROJECT}.Domain.Tests package Microsoft.NET.Test.Sdk
dotnet add tests/${PROJECT}.Domain.Tests package xunit
dotnet add tests/${PROJECT}.Domain.Tests package xunit.runner.visualstudio
dotnet add tests/${PROJECT}.Domain.Tests package coverlet.collector
dotnet add tests/${PROJECT}.Domain.Tests package FluentAssertions
dotnet add tests/${PROJECT}.Domain.Tests package Moq
# Test Projects (Application)
dotnet add tests/${PROJECT}.Application.Tests package Microsoft.NET.Test.Sdk
dotnet add tests/${PROJECT}.Application.Tests package xunit
dotnet add tests/${PROJECT}.Application.Tests package xunit.runner.visualstudio
dotnet add tests/${PROJECT}.Application.Tests package coverlet.collector
dotnet add tests/${PROJECT}.Application.Tests package FluentAssertions
dotnet add tests/${PROJECT}.Application.Tests package Moq
# Test Projects (Integration)
dotnet add tests/${PROJECT}.IntegrationTests package Microsoft.NET.Test.Sdk
dotnet add tests/${PROJECT}.IntegrationTests package xunit
dotnet add tests/${PROJECT}.IntegrationTests package xunit.runner.visualstudio
dotnet add tests/${PROJECT}.IntegrationTests package coverlet.collector
dotnet add tests/${PROJECT}.IntegrationTests package FluentAssertions
dotnet add tests/${PROJECT}.IntegrationTests package Moq
dotnet add tests/${PROJECT}.IntegrationTests package Microsoft.AspNetCore.Mvc.Testing
```
---
## 5. Layer 1: Domain
*The innermost layer. Contains entities, value objects, domain events, the Result pattern, and repository abstractions. It has **zero** NuGet dependencies.*
### `src/${PROJECT}.Domain/Common/IDomainEvent.cs`
```csharp
namespace ${PROJECT}.Domain.Common;
public interface IDomainEvent
{
DateTime OccurredOn { get; }
}
```
### `src/${PROJECT}.Domain/Common/BaseEntity.cs`
```csharp
namespace ${PROJECT}.Domain.Common;
public abstract class BaseEntity
{
private readonly List _domainEvents = [];
public Guid Id { get; private init; } = Guid.NewGuid();
public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly();
public void AddDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
public void RemoveDomainEvent(IDomainEvent domainEvent) => _domainEvents.Remove(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
```
### `src/${PROJECT}.Domain/Common/AuditableEntity.cs`
```csharp
namespace ${PROJECT}.Domain.Common;
public abstract class AuditableEntity : BaseEntity
{
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
public void SetCreatedAt(DateTime createdAt) => CreatedAt = createdAt;
public void SetUpdatedAt(DateTime updatedAt) => UpdatedAt = updatedAt;
}
```
### `src/${PROJECT}.Domain/Common/Error.cs`
```csharp
namespace ${PROJECT}.Domain.Common;
public enum ErrorType { None = 0, Failure = 1, Validation = 2, NotFound = 3, Conflict = 4 }
public sealed record Error(string Code, string Description, ErrorType Type)
{
public static readonly Error None = new(string.Empty, string.Empty, ErrorType.None);
public static readonly Error NullValue = new("Error.NullValue", "A null value was provided.", ErrorType.Failure);
public static readonly Error NotFound = new("Error.NotFound", "The requested resource was not found.", ErrorType.NotFound);
public static readonly Error Conflict = new("Error.Conflict", "A conflict occurred with the current state.", ErrorType.Conflict);
public static readonly Error Validation = new("Error.Validation", "A validation error occurred.", ErrorType.Validation);
}
```
### `src/${PROJECT}.Domain/Common/Result.cs`
```csharp
namespace ${PROJECT}.Domain.Common;
public interface IValidationResult
{
static abstract Result Failure(Error error);
}
public class Result : IValidationResult
{
protected Result(bool isSuccess, Error error)
{
if (isSuccess && error != Error.None) throw new InvalidOperationException("A successful result cannot have an error.");
if (!isSuccess && error == Error.None) throw new InvalidOperationException("A failed result must have an error.");
IsSuccess = isSuccess;
Error = error;
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success() => new(true, Error.None);
public static Result Success(T value) => Result.Success(value);
public static Result Failure(Error error) => new(false, error);
public static Result Failure(Error error) => Result.Failure(error);
}
public class Result : Result, IValidationResult
{
private readonly T? _value;
private Result(T? value, bool isSuccess, Error error) : base(isSuccess, error)
{
_value = value;
}
public T Value => IsSuccess ? _value! : throw new InvalidOperationException("Cannot access the value of a failed result.");
public static Result Success(T value) => new(value, true, Error.None);
public new static Result Failure(Error error) => new(default, false, error);
public static implicit operator Result(T value) => Success(value);
}
```
### `src/${PROJECT}.Domain/Abstractions/IUnitOfWork.cs`
```csharp
namespace ${PROJECT}.Domain.Abstractions;
public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
```
---
## 6. Layer 2: Application
*Contains use cases (commands, queries, handlers), validation, mapping, and MediatR pipeline behaviors. References Domain only.*
### `src/${PROJECT}.Application/Abstractions/Messaging/ICommand.cs`
```csharp
using MediatR;
using ${PROJECT}.Domain.Common;
namespace ${PROJECT}.Application.Abstractions.Messaging;
public interface ICommand : IRequest;
public interface ICommand : IRequest>;
```
### `src/${PROJECT}.Application/Abstractions/Messaging/ICommandHandler.cs`
```csharp
using MediatR;
using ${PROJECT}.Domain.Common;
namespace ${PROJECT}.Application.Abstractions.Messaging;
public interface ICommandHandler : IRequestHandler where TCommand : ICommand;
public interface ICommandHandler : IRequestHandler> where TCommand : ICommand;
```
### `src/${PROJECT}.Application/Abstractions/IDomainEventHandler.cs`
```csharp
using MediatR;
using ${PROJECT}.Domain.Common;
namespace ${PROJECT}.Application.Abstractions;
public sealed class DomainEventNotification(TDomainEvent domainEvent) : INotification where TDomainEvent : IDomainEvent
{
public TDomainEvent DomainEvent { get; } = domainEvent;
}
public interface IDomainEventHandler : INotificationHandler> where TDomainEvent : IDomainEvent;
```
### `src/${PROJECT}.Application/Behaviors/LoggingBehavior.cs`
```csharp
using System.Diagnostics;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ${PROJECT}.Application.Behaviors;
public sealed class LoggingBehavior(ILogger> logger) : IPipelineBehavior where TRequest : IRequest
{
public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
{
var requestName = typeof(TRequest).Name;
logger.LogInformation("Handling {RequestName}", requestName);
var stopwatch = Stopwatch.StartNew();
var response = await next(cancellationToken);
stopwatch.Stop();
logger.LogInformation("Handled {RequestName} in {ElapsedMilliseconds}ms", requestName, stopwatch.ElapsedMilliseconds);
return response;
}
}
```
### `src/${PROJECT}.Application/Behaviors/ValidationBehavior.cs`
```csharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Logging;
using ${PROJECT}.Domain.Common;
namespace ${PROJECT}.Application.Behaviors;
public sealed class ValidationBehavior(
IEnumerable> validators,
ILogger> logger)
: IPipelineBehavior
where TRequest : IRequest
where TResponse : Result, IValidationResult
{
public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken)
{
var validatorList = validators as IReadOnlyList> ?? [.. validators];
if (validatorList.Count == 0) return await next(cancellationToken);
var context = new ValidationContext(request);
var validationResults = await Task.WhenAll(validatorList.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f is not null).ToList();
if (failures.Count != 0)
{
var errorMessage = string.Join("; ", failures.Select(f => f.ErrorMessage));
var error = new Error("Validation", errorMessage, ErrorType.Validation);
logger.LogWarning("Validation failed for {RequestName}: {ErrorMessage}", typeof(TRequest).Name, errorMessage);
// Direct call to static abstract Failure method. No reflection!
return (TResponse)TResponse.Failure(error);
}
return await next(cancellationToken);
}
}
```
### `src/${PROJECT}.Application/DependencyInjection.cs`
```csharp
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using ${PROJECT}.Application.Behaviors;
namespace ${PROJECT}.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = typeof(DependencyInjection).Assembly;
services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
services.AddValidatorsFromAssembly(assembly);
return services;
}
}
```
---
## 7. Layer 3: Infrastructure
*Implements the abstractions defined in Domain and Application. Contains the EF Core `DbContext` and interceptors.*
### `src/${PROJECT}.Infrastructure/Persistence/Interceptors/AuditableEntityInterceptor.cs`
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ${PROJECT}.Domain.Common;
namespace ${PROJECT}.Infrastructure.Persistence.Interceptors;
public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
{
public override ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default)
{
UpdateAuditableEntities(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void UpdateAuditableEntities(DbContext? context)
{
if (context is null) return;
var utcNow = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries())
{
if (entry.State == EntityState.Added) entry.Entity.SetCreatedAt(utcNow);
if (entry.State == EntityState.Modified) entry.Entity.SetUpdatedAt(utcNow);
}
}
}
```
### `src/${PROJECT}.Infrastructure/Persistence/Interceptors/DomainEventInterceptor.cs`
```csharp
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ${PROJECT}.Application.Abstractions;
using ${PROJECT}.Domain.Common;
namespace ${PROJECT}.Infrastructure.Persistence.Interceptors;
public sealed class DomainEventInterceptor(IPublisher publisher) : SaveChangesInterceptor
{
public override async ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
await PublishDomainEventsAsync(eventData.Context, cancellationToken);
return await base.SavedChangesAsync(eventData, result, cancellationToken);
}
private async Task PublishDomainEventsAsync(DbContext context, CancellationToken cancellationToken)
{
var entities = context.ChangeTracker.Entries().Where(e => e.Entity.DomainEvents.Count != 0).Select(e => e.Entity).ToList();
var domainEvents = entities.SelectMany(e => e.DomainEvents).ToList();
entities.ForEach(e => e.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
var notificationType = typeof(DomainEventNotification<>).MakeGenericType(domainEvent.GetType());
var notification = Activator.CreateInstance(notificationType, domainEvent)!;
await publisher.Publish(notification, cancellationToken);
}
}
}
```
### `src/${PROJECT}.Infrastructure/Persistence/ApplicationDbContext.cs`
```csharp
using Microsoft.EntityFrameworkCore;
using ${PROJECT}.Domain.Abstractions;
namespace ${PROJECT}.Infrastructure.Persistence;
public sealed class ApplicationDbContext(DbContextOptions options) : DbContext(options), IUnitOfWork
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
```
### `src/${PROJECT}.Infrastructure/DependencyInjection.cs`
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ${PROJECT}.Domain.Abstractions;
using ${PROJECT}.Infrastructure.Persistence;
using ${PROJECT}.Infrastructure.Persistence.Interceptors;
namespace ${PROJECT}.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton();
services.AddScoped();
services.AddDbContext((sp, options) =>
{
var auditableInterceptor = sp.GetRequiredService();
var domainEventInterceptor = sp.GetRequiredService();
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))
.AddInterceptors(auditableInterceptor, domainEventInterceptor);
});
services.AddScoped(sp => sp.GetRequiredService());
return services;
}
}
```
---
## 8. Layer 4: API / Presentation
*The composition root where all layers meet. Controllers, Middleware, and Program.cs.*
### `src/${PROJECT}.Api/appsettings.json`
```json
{
"ConnectionStrings": {
"DefaultConnection": ""
},
"Serilog": {
"Using": ["Serilog.Sinks.Console"],
"MinimumLevel": {
"Default": "Information",
"Override": { "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" }
},
"WriteTo": [
{ "Name": "Console", "Args": { "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}{CorrelationId: [{CorrelationId}]} {Message:lj}{NewLine}{Exception}" } }
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"]
},
"AllowedHosts": "*"
}
```
### `src/${PROJECT}.Api/Extensions/ServiceCollectionExtensions.cs`
```csharp
using Asp.Versioning;
using Serilog;
using ${PROJECT}.Application;
using ${PROJECT}.Infrastructure;
namespace ${PROJECT}.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static WebApplicationBuilder AddServices(this WebApplicationBuilder builder)
{
builder.Host.UseSerilog((context, loggerConfiguration) =>
loggerConfiguration.ReadFrom.Configuration(context.Configuration));
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddProblemDetails();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string 'DefaultConnection' is not configured.");
builder.Services.AddHealthChecks().AddNpgSql(connectionString);
return builder;
}
}
```
### `src/${PROJECT}.Api/Extensions/WebApplicationExtensions.cs`
```csharp
namespace ${PROJECT}.Api.Extensions;
public static class WebApplicationExtensions
{
public static WebApplication ConfigurePipeline(this WebApplication app)
{
if (app.Environment.IsDevelopment())
app.MapOpenApi();
app.UseExceptionHandler();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
return app;
}
}
```
### `src/${PROJECT}.Api/Program.cs`
```csharp
using Serilog;
using ${PROJECT}.Api.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.AddServices();
var app = builder.Build();
app.ConfigurePipeline();
try
{
Log.Information("Starting ${PROJECT} API in {Environment} environment", app.Environment.EnvironmentName);
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
public partial class Program; // Needed for integration tests
```
---
## 9. Docker & Dev Environment
Create these files in your repository root.
### `.env.example`
```bash
DOCKER_IMAGE=your-dockerhub-username/${PROJECT_LOWER}-api
IMAGE_TAG=latest
ASPNETCORE_ENVIRONMENT=Development
API_PORT=5212
POSTGRES_DB=${PROJECT_LOWER}_dev
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change-me-to-a-strong-password
DB_PORT=5432
CONNECTION_STRING=Host=db;Port=5432;Database=${PROJECT_LOWER}_dev;Username=postgres;Password=change-me-to-a-strong-password
```
### `compose.yml`
```yaml
services:
api:
container_name: ${PROJECT_LOWER}-api
image: ${DOCKER_IMAGE:-${PROJECT_LOWER}-api}:${IMAGE_TAG:-latest}
build:
context: .
dockerfile: Dockerfile
ports:
- "${API_PORT:-5212}:8080"
environment:
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Development}
- ConnectionStrings__DefaultConnection=${CONNECTION_STRING:-Host=db;Port=5432;Database=${PROJECT_LOWER}_dev;Username=postgres;Password=postgres}
depends_on:
db:
condition: service_healthy
db:
container_name: ${PROJECT_LOWER}-db
image: postgres:17-alpine
ports:
- "${DB_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-${PROJECT_LOWER}_dev}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
```
### `Dockerfile`
```dockerfile
FROM [mcr.microsoft.com/dotnet/aspnet:10.0](https://mcr.microsoft.com/dotnet/aspnet:10.0) AS base
WORKDIR /app
EXPOSE 8080
FROM [mcr.microsoft.com/dotnet/sdk:10.0](https://mcr.microsoft.com/dotnet/sdk:10.0) AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY global.json .
COPY Directory.Build.props .
COPY Directory.Packages.props .
COPY src/${PROJECT}.Api/${PROJECT}.Api.csproj src/${PROJECT}.Api/
COPY src/${PROJECT}.Application/${PROJECT}.Application.csproj src/${PROJECT}.Application/
COPY src/${PROJECT}.Domain/${PROJECT}.Domain.csproj src/${PROJECT}.Domain/
COPY src/${PROJECT}.Infrastructure/${PROJECT}.Infrastructure.csproj src/${PROJECT}.Infrastructure/
RUN dotnet restore src/${PROJECT}.Api/${PROJECT}.Api.csproj
COPY . .
RUN dotnet build src/${PROJECT}.Api -c $BUILD_CONFIGURATION --no-restore
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish src/${PROJECT}.Api -c $BUILD_CONFIGURATION --no-build -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "${PROJECT}.Api.dll"]
```
---
## 10. Test Projects
### Test Strategy
The boilerplate scaffolds three test projects, each targeting a different layer and testing style:
| Project | Tests | Style |
|---|---|---|
| `${PROJECT}.Domain.Tests` | Entities, value objects, Result pattern, domain logic | Pure unit tests — no mocks needed (Domain has zero dependencies) |
| `${PROJECT}.Application.Tests` | Command/query handlers, validators, pipeline behaviors | Unit tests with mocked `ApplicationDbContext` and `IUnitOfWork` |
| `${PROJECT}.IntegrationTests` | Full HTTP request/response cycle through the API | Integration tests using `WebApplicationFactory` with a real PostgreSQL instance |
**Shared test tooling across all projects:**
- **xUnit** — test framework (`[Fact]`, `[Theory]`)
- **FluentAssertions** — expressive assertions (`result.Should().Be(...)`)
- **Moq** — mocking framework for isolating dependencies
- **coverlet.collector** — code coverage collection (used by CI pipeline)
**Integration test additions:**
- **`Microsoft.AspNetCore.Mvc.Testing`** — provides `WebApplicationFactory` for in-process HTTP testing
### Test File Naming Convention
Test files follow the pattern `{ClassUnderTest}Tests.cs`. For example:
- `ResultTests.cs` tests `Result.cs`
- `ValidationBehaviorTests.cs` tests `ValidationBehavior.cs`
### Project References
Each test project references only the layer it tests:
- `Domain.Tests` → `Domain`
- `Application.Tests` → `Application` (which transitively includes Domain)
- `IntegrationTests` → `Api` (which transitively includes everything)
This mirrors the Clean Architecture dependency rule — test projects never reach across layers.
---
## 11. CI/CD Pipeline
The CI/CD pipeline is split into two conceptual sections:
1. **Standard CI** (test, SonarCloud, Qodana) — reusable as-is across projects. Just update repository variables.
2. **Deployment** (Docker build/push, SSH deploy) — project-specific. Customize the deployment target, Tailscale config, and SSH details.
### `qodana.yaml`
Configuration for JetBrains Qodana static analysis. Points to the `.slnx` solution file and uses the starter inspection profile.
```yaml
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# [https://www.jetbrains.com/help/qodana/qodana-yaml.html](https://www.jetbrains.com/help/qodana/qodana-yaml.html) #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify IDE code to run analysis without container (Applied in CI/CD pipeline)
ide: QDNET
#Specify the .NET solution to analyze
dotnet:
solution: ${PROJECT}.slnx
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name:
#Disable inspections
#exclude:
# - name:
# paths:
# -
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: #(plugin id can be found at [https://plugins.jetbrains.com](https://plugins.jetbrains.com))
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
```
### `.github/workflows/ci.yml`
Full CI/CD pipeline with four jobs:
```yaml
name: CI/CD Pipeline
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
env:
DOTNET_VERSION: "10.0.x"
JAVA_VERSION: "17"
DOCKER_IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/${PROJECT_LOWER}-api
jobs:
# ─────────────────────────────────────────────────────────────────
# Job 1: Build, test, and collect coverage
# ─────────────────────────────────────────────────────────────────
test:
name: Build & Test
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: ${PROJECT_LOWER}_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Run tests
run: >-
dotnet test
--no-build
--configuration Release
--logger "trx;LogFileName=test-results.trx"
--collect:"XPlat Code Coverage"
--results-directory ./TestResults
env:
ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=${PROJECT_LOWER}_test;Username=postgres;Password=postgres"
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: ./TestResults
retention-days: 7
# ─────────────────────────────────────────────────────────────────
# Job 2: SonarCloud analysis
# ─────────────────────────────────────────────────────────────────
sonar:
name: SonarCloud Analysis
runs-on: ubuntu-latest
needs: test
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: ${PROJECT_LOWER}_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Install SonarScanner
run: dotnet tool install --global dotnet-sonarscanner
- name: Begin SonarCloud analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: >-
dotnet sonarscanner begin
/k:"${{ vars.SONAR_PROJECT_KEY }}"
/o:"${{ vars.SONAR_ORGANIZATION_KEY }}"
/d:sonar.token="${{ secrets.SONAR_TOKEN }}"
/d:sonar.host.url="[https://sonarcloud.io](https://sonarcloud.io)"
/d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml"
/d:sonar.exclusions="**/obj/**,**/bin/**"
/d:sonar.coverage.exclusions="**/obj/**,**/bin/**,**/Migrations/**"
- name: Build solution
run: dotnet build --configuration Release
- name: Run tests with coverage
run: >-
dotnet test
--no-build
--configuration Release
--collect:"XPlat Code Coverage;Format=opencover"
--results-directory ./TestResults
env:
ConnectionStrings__DefaultConnection: "Host=localhost;Port=5432;Database=${PROJECT_LOWER}_test;Username=postgres;Password=postgres"
- name: End SonarCloud analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
- name: Check SonarCloud Quality Gate
uses: sonarsource/sonarqube-quality-gate-action@v1.2.0
timeout-minutes: 5
continue-on-error: true
with:
scanMetadataReportFile: .sonarqube/out/.sonar/report-task.txt
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# ─────────────────────────────────────────────────────────────────
# Job 3: Qodana analysis (parallel with SonarCloud)
# ─────────────────────────────────────────────────────────────────
qodana:
name: Qodana Analysis
runs-on: ubuntu-latest
needs: test
permissions:
contents: write
pull-requests: write
checks: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Qodana Scan
uses: JetBrains/qodana-action@v2025.1
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
# ─────────────────────────────────────────────────────────────────
# Job 4: Build, push, and deploy
# ─────────────────────────────────────────────────────────────────
deploy:
name: Deploy to Server
runs-on: ubuntu-latest
needs: [sonar, qodana]
if: github.ref == 'refs/heads/dev' && github.event_name == 'push'
environment: Development
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.DOCKER_IMAGE }}:latest
${{ env.DOCKER_IMAGE }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Connect to Tailscale
uses: tailscale/github-action@v4
with:
oauth-client-id: ${{ vars.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:ci
- name: Copy compose file to server
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ vars.SSH_HOST }}
username: ${{ vars.SSH_USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ vars.SSH_PORT }}
source: "compose.yml,.env.example"
target: ${{ vars.DEPLOY_PATH }}
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ vars.SSH_HOST }}
username: ${{ vars.SSH_USERNAME }}
key: ${{ secrets.SSH_KEY }}
port: ${{ vars.SSH_PORT }}
envs: DOCKER_IMAGE,IMAGE_TAG
script: |
cd ${{ vars.DEPLOY_PATH }}
if [ ! -f .env ]; then
cp .env.example .env
chmod 600 .env
fi
# Update DOCKER_IMAGE and IMAGE_TAG in .env with values from CI
sed -i "s|^DOCKER_IMAGE=.*|DOCKER_IMAGE=${DOCKER_IMAGE}|" .env
sed -i "s|^IMAGE_TAG=.*|IMAGE_TAG=${IMAGE_TAG}|" .env
docker compose pull
docker compose up -d
sleep 10
# Verify all expected services are running
EXPECTED=2
RUNNING=$(docker compose ps --status running --quiet | wc -l)
if [ "$RUNNING" -lt "$EXPECTED" ]; then
echo "Expected $EXPECTED services, found $RUNNING running"
docker compose logs --tail=50
exit 1
fi
env:
DOCKER_IMAGE: ${{ env.DOCKER_IMAGE }}
IMAGE_TAG: ${{ github.sha }}
```
### Required GitHub Secrets and Variables
| Type | Name | Description |
|---|---|---|
| **Secret** | `SONAR_TOKEN` | SonarCloud authentication token |
| **Secret** | `QODANA_TOKEN` | Qodana Cloud authentication token |
| **Secret** | `DOCKERHUB_TOKEN` | Docker Hub access token |
| **Secret** | `SSH_KEY` | Private SSH key for deployment server |
| **Secret** | `TS_OAUTH_SECRET` | Tailscale OAuth secret |
| **Variable** | `DOCKERHUB_USERNAME` | Docker Hub username |
| **Variable** | `SONAR_PROJECT_KEY` | SonarCloud project key |
| **Variable** | `SONAR_ORGANIZATION_KEY` | SonarCloud organization key |
| **Variable** | `SSH_HOST` | Deployment server hostname (Tailscale IP) |
| **Variable** | `SSH_USERNAME` | SSH username on deployment server |
| **Variable** | `SSH_PORT` | SSH port on deployment server |
| **Variable** | `TS_OAUTH_CLIENT_ID` | Tailscale OAuth client ID |
| **Variable** | `DEPLOY_PATH` | Path on server where app is deployed |
---
## 12. AI-Assisted Development
### `.github/copilot-instructions.md`
This file provides project-specific context to GitHub Copilot (and other AI assistants that read it). It documents:
- **Git commit conventions** — execute commits directly, no `Co-authored-by` trailers
- **Build & run commands** — `dotnet build`, `dotnet run`, `dotnet test`, EF Core migrations
- **Architecture** — Clean Architecture dependency flow, layer responsibilities
- **Key conventions** — CQRS with MediatR, Result pattern, Domain layer rules
- **Testing conventions** — xUnit, Moq, FluentAssertions, `WebApplicationFactory`
- **Tech stack** — .NET 10, PostgreSQL, MediatR 14, FluentValidation 12, Serilog
This file is automatically picked up by Copilot in VS Code and GitHub.com, providing contextual suggestions that align with the project's architecture and conventions.
---
## 13. Running the Project
### Local Development (without Docker)
```bash
# Start PostgreSQL (via Docker or locally)
docker compose up db -d
# Run the API
dotnet run --project src/${PROJECT}.Api
# API available at http://localhost:5212
# Health check: http://localhost:5212/health
# OpenAPI spec: http://localhost:5212/openapi/v1.json (dev only)
```
### Docker Compose (full stack)
```bash
# Build and start everything
docker compose up --build -d
# API available at http://localhost:5212
# Health check: http://localhost:5212/health
```
### Running Tests
```bash
# All tests
dotnet test
# Specific test project
dotnet test tests/${PROJECT}.Domain.Tests
dotnet test tests/${PROJECT}.Application.Tests
dotnet test tests/${PROJECT}.IntegrationTests
# Single test by name
dotnet test --filter "FullyQualifiedName~MyTestMethod"
```
### EF Core Migrations
```bash
# Add a new migration
dotnet ef migrations add \
--project src/${PROJECT}.Infrastructure \
--startup-project src/${PROJECT}.Api
# Apply migrations
dotnet ef database update \
--project src/${PROJECT}.Infrastructure \
--startup-project src/${PROJECT}.Api
```
---
## Appendix A: Outbox Pattern (Optional)
> **This section is an optional enhancement.** The boilerplate uses EF Core's `DomainEventInterceptor` to dispatch domain events in-process during `SaveChanges`. The outbox pattern is the next evolution — use it when you need **guaranteed delivery** of domain events to external systems (message brokers, other microservices) with at-least-once semantics.
### The Problem
The current `DomainEventInterceptor` dispatches events via MediatR *inside* the `SaveChanges` pipeline. This works well for in-process handlers (updating read models, sending notifications, etc.), but has two limitations:
1. **No guarantee of delivery to external systems** — if the application crashes after `SaveChanges` but before an external message is sent, the event is lost.
2. **Coupling to the transaction boundary** — if an event handler calls an external API or publishes to a message broker, you're mixing I/O with the database transaction.
### The Outbox Pattern Solution
Instead of dispatching events immediately, write them as rows in an `OutboxMessages` table within the **same database transaction** as the business data. A background job (Quartz.NET, which is already included in this boilerplate) polls the table and publishes events to external consumers.
**Flow:**
1. Handler modifies entity + raises domain event
2. `SaveChanges` interceptor serializes domain events into `OutboxMessages` table (same transaction)
3. Transaction commits — business data and outbox messages are atomically consistent
4. Quartz.NET background job polls `OutboxMessages`, publishes to message broker, marks as processed
### Implementation Sketch
**1. Outbox entity (Domain or Infrastructure layer):**
```csharp
public sealed class OutboxMessage
{
public Guid Id { get; init; }
public string Type { get; init; } = string.Empty; // Event CLR type name
public string Content { get; init; } = string.Empty; // Serialized event payload (JSON)
public DateTime OccurredOnUtc { get; init; }
public DateTime? ProcessedOnUtc { get; set; }
public string? Error { get; set; }
}
```
**2. Modified interceptor (writes to outbox instead of dispatching):**
```csharp
// In SaveChangesInterceptor, replace MediatR Publish with:
var outboxMessages = domainEvents.Select(e => new OutboxMessage
{
Id = Guid.NewGuid(),
Type = e.GetType().Name,
Content = JsonConvert.SerializeObject(e, new JsonSerializerSettings
{
TypeNameHandling = TypeNameHandling.All
}),
OccurredOnUtc = DateTime.UtcNow
});
dbContext.Set().AddRange(outboxMessages);
// Events are persisted in the same SaveChanges transaction
```
**3. Quartz.NET background job:**
```csharp
[DisallowConcurrentExecution]
public sealed class ProcessOutboxMessagesJob(
ApplicationDbContext dbContext,
IPublisher publisher) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var messages = await dbContext.Set()
.Where(m => m.ProcessedOnUtc == null)
.OrderBy(m => m.OccurredOnUtc)
.Take(20)
.ToListAsync(context.CancellationToken);
foreach (var message in messages)
{
try
{
var domainEvent = JsonConvert.DeserializeObject(
message.Content,
new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All });
if (domainEvent is not null)
await publisher.Publish(domainEvent, context.CancellationToken);
message.ProcessedOnUtc = DateTime.UtcNow;
}
catch (Exception ex)
{
message.Error = ex.ToString();
}
}
await dbContext.SaveChangesAsync(context.CancellationToken);
}
}
```
### When to Adopt This
- You're publishing events to a message broker (RabbitMQ, Azure Service Bus, Kafka)
- You need at-least-once delivery guarantees
- You're in a microservices architecture where services communicate via events
For purely in-process event handling (the common case in a monolith), the existing `DomainEventInterceptor` approach is simpler and sufficient.
```