Production-ready .NET 10 service template with Clean Architecture, Docker, Helm, and GitHub Actions CI/CD.
- Architecture
- Prerequisites
- Quick Start
- Project Structure
- Development Workflow
- Testing
- Docker
- Kubernetes / Helm
- CI/CD
- Renaming the Template
- Configuration Reference
This template follows Clean Architecture (also known as Onion Architecture):
┌────────────────────────────────────────┐
│ Api (Entry Point) │ Minimal APIs, middleware, DI wiring
├────────────────────────────────────────┤
│ Application Layer │ CQRS (ISender/IRequestHandler), validators, DTOs
├────────────────────────────────────────┤
│ Domain Layer │ Entities, value objects, domain errors
├────────────────────────────────────────┤
│ Infrastructure Layer │ EF Core, PostgreSQL, repositories
└────────────────────────────────────────┘
Key design decisions:
- Custom CQRS — commands and queries dispatched via
ISender.SendAsync()/IRequestHandler.HandleAsync(), with pipeline behaviors for logging and validation (MediatR is commercial — not used) - Result pattern — no exceptions for expected failure paths; errors flow as values
- Testcontainers — integration and acceptance tests spin up real PostgreSQL containers
- OpenTelemetry — traces, metrics, and logs exported via OTLP
- Serilog — structured logging with Seq support for local dev
| Tool | Minimum Version | Install |
|---|---|---|
| .NET SDK | 10.0.x | dotnet.microsoft.com |
| Docker Desktop | 4.x | docker.com |
mockly |
latest | go install github.com/dever-labs/mockly@latest |
make |
any | winget install GnuWin32.Make (Windows) |
git |
2.x | git-scm.com |
Optional (for Kubernetes / Helm workflows):
helm3.x — helm.shkind0.20+ — kind.sigs.k8s.iokubectl— kubernetes.io
Optional (editor):
- VS Code with the C# Dev Kit extension
- Dev Containers extension for a fully containerised dev environment
# 1. Clone and enter the project
git clone https://github.com/dever-labs/dotnet10-service-template.git && cd dotnet10-service-template
# 2. One-command setup: installs tools, restores packages, adds Helm repos, downloads chart dependencies
make setup
# 3. In one terminal: start the mockly fake server (mocks the Todo API dependency)
make fake
# 4. In another terminal: run the API with hot reload
make runThe API is now available at http://localhost:5000.
- Scalar API docs:
http://localhost:5000/scalar - Health check:
http://localhost:5000/health - Mockly UI:
http://localhost:9090
- Open the repository in VS Code
- When prompted, click "Reopen in Container"
- Wait for the container to build (~2 min on first run)
- Run
make setupin the integrated terminal
.
├── .devcontainer/ # VS Code Dev Container configuration
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Build, test, Docker build on push/PR
│ │ ├── release.yml # Push Docker image + Helm chart on tag
│ │ └── pr.yml # PR title validation, format check
│ ├── CODEOWNERS
│ └── pull_request_template.md
├── deploy/
│ └── helm/ # Helm chart (deployment, HPA, PDB, ingress...)
├── docs/ # Architecture Decision Records and org policies
├── fake/
│ └── mockly.yaml # Mockly config — mock Todo API for local development
├── src/
│ ├── Api/ # ASP.NET Core Minimal API, Program.cs
│ ├── Application/ # Custom CQRS (ISender/IRequestHandler), validators, DTOs
│ ├── Domain/ # Entities, domain errors, Result<T>
│ ├── Infrastructure/ # EF Core, PostgreSQL, repositories
│ └── McpServer/ # MCP server exposing Todo API as Copilot agent tools
├── tests/
│ ├── UnitTests/ # Fast, isolated, no I/O
│ ├── IntegrationTests/ # Real DB via Testcontainers + Respawn
│ └── AcceptanceTests/ # BDD-style HTTP API tests
├── scripts/ # Helper scripts (kind cluster, migrations)
├── Dockerfile # Multi-stage production build
├── Directory.Build.props # Shared MSBuild settings for all projects
├── Directory.Packages.props # Central NuGet package version management
├── global.json # Pins .NET SDK version
├── Makefile # Developer task runner
├── values.local.yaml # Local Helm overrides
├── values.staging.yaml # Staging Helm overrides
└── ServiceTemplate.sln
make help # Show all available commands
make build # Build the solution
make run # Run API with hot reload
make fmt # Auto-format code
make lint # Verify formatting (CI-safe)
make clean # Remove bin/obj/TestResults
make outdated # List outdated NuGet packages# Apply pending migrations
make migrate
# Create a new migration
make migration NAME=AddUserTable
# Roll back last migration
make migration-rollbackmake fake # Start mockly (http://localhost:8080, UI at http://localhost:9090)The fake/mockly.yaml file configures mock responses for all Todo API endpoints. Edit it to add new routes or change response shapes as the real API evolves.
make cluster-create # Create local kind cluster with Docker registry
make cluster-delete # Tear down the kind cluster
make cluster-status # Show nodes, registry and Helm release status| Test Suite | Purpose | Command |
|---|---|---|
| Unit | Business logic in isolation (NSubstitute mocks) | make test |
| Integration | Full app + real PostgreSQL (Testcontainers) | make test-integration |
| Acceptance | BDD-style HTTP API tests | make test-acceptance |
make test-all # Run every test suite
make coverage # Unit tests + open HTML coverage reportCoverage is automatically uploaded to Codecov in CI.
# Build the image and push to local registry
make docker-buildThe multi-stage Dockerfile produces a minimal, non-root runtime image.
# Lint the chart
make helm-lint
# Render templates (dry-run)
make helm-template
# Package
make helm-package
# Deploy to staging
helm upgrade --install my-service deploy \
-f deploy/values.yaml \
-f values.staging.yaml \
--set image.tag=1.2.3 \
--namespace my-namespace --create-namespaceProduction values are not stored in this repo.
Production deployments are managed via a dedicated GitOps repository that references the Helm chart and container image released from here. Supply your production values there.
The chart includes:
- Deployment with rolling updates and
maxUnavailable: 0 - Horizontal Pod Autoscaler (CPU + memory)
- PodDisruptionBudget (
minAvailable: 1) - Non-root pod security context with
readOnlyRootFilesystem - Topology spread constraints for zone-level HA
- Liveness / readiness probes
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
Push to main/develop, PR |
Build → Unit Tests → Integration Tests → Acceptance Tests → Docker build |
release.yml |
Push tag v*.*.* |
All tests → Push Docker image to GHCR → Package & push Helm chart → GitHub Release |
pr.yml |
PR opened/updated | Validate PR title (Conventional Commits), check code formatting |
git tag v1.2.3
git push origin v1.2.3This triggers the release pipeline which will:
- Run all tests
- Build and push
ghcr.io/dever-labs/dotnet10-service-template:1.2.3 - Push the Helm chart to
ghcr.io/dever-labs/helm-charts - Create a GitHub Release with auto-generated notes
To use this as a starting point for a new service named PaymentService:
# 1. Clone
git clone https://github.com/dever-labs/dotnet10-service-template.git payment-service
cd payment-service
# 2. Bulk rename (Linux/macOS)
find . -not -path './.git/*' \
\( -name "*.cs" -o -name "*.csproj" -o -name "*.sln" -o -name "*.json" -o -name "*.yaml" -o -name "*.yml" \) \
-exec sed -i 's/ServiceTemplate/PaymentService/g; s/service-template/payment-service/g' {} +
# 3. Rename files and directories
mv ServiceTemplate.sln PaymentService.sln
for d in src tests; do
find $d -name "ServiceTemplate.*" | while read f; do
mv "$f" "${f/ServiceTemplate/PaymentService}"
done
done
# 4. Re-init git history
rm -rf .git && git init && git add . && git commit -m "chore: initial commit from service-template"| Key | Default | Description |
|---|---|---|
ConnectionStrings:DefaultConnection |
Postgres on localhost:5432 | PostgreSQL connection string |
OpenTelemetry:ServiceName |
service-template |
Service name reported to OTEL |
OTEL_EXPORTER_OTLP_ENDPOINT |
http://localhost:4317 |
OTLP gRPC endpoint |
Serilog:MinimumLevel:Default |
Information |
Minimum log level |
Secrets (passwords, API keys) must never be committed. Use:
- Kubernetes Secrets (referenced in Helm chart)
- GitHub Actions secrets
.envfiles locally (already in.gitignore)