CleanApiStarter is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests.
The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks.
- Clean Architecture solution structure with separate
Api,Application,Domain,Infrastructure,Configuration, and shared ASP.NET Core defaults projects. - Minimal APIs organized by endpoint group and API version folders.
- Header-based API versioning with optional
X-Api-Version; missing versions default to v1. - Versioned OpenAPI documents shown with Scalar.
- Google ID token sign-in that issues the API's own JWT.
- ASP.NET Core Identity for local users, roles, and external login storage.
- Real JWT bearer authentication for protected APIs.
- EF Core with PostgreSQL for application data and Identity storage.
- PostgreSQL local development through Aspire or Docker Compose.
- Database schema scripts under
database/migrations. - OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults.
- HTTP request logging with authenticated user id.
X-Request-IDresponse header containing the current trace id.- Problem Details exception responses.
- Response compression with Brotli and gzip.
- FluentValidation endpoint validation returning
422 Unprocessable Entity. - Result wrappers for collection endpoints:
PaginatedResult<T>andArrayResult<T>. - xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly.
- MSTest API integration tests with Testcontainers for PostgreSQL.
- Local coverage script that generates an HTML report with ReportGenerator.
- GitHub Actions CI with build, test, template verification, and CodeQL security scanning.
CleanApiStarter
├── database
│ └── migrations
├── scripts
├── src
│ ├── CleanApiStarter.Api
│ ├── CleanApiStarter.Application
│ ├── CleanApiStarter.Domain
│ ├── CleanApiStarter.Infrastructure
│ └── Common
│ ├── CleanApiStarter.AppHost
│ ├── CleanApiStarter.AspNetCore
│ └── CleanApiStarter.Configuration
└── tests
├── CleanApiStarter.Api.IntegrationTests
├── CleanApiStarter.Application.UnitTests
└── CleanApiStarter.Tests
Layer dependencies point inward:
Domainreferences nothing.ApplicationreferencesDomain.InfrastructurereferencesApplicationandConfiguration.ApicomposesApplication,Infrastructure,Configuration, andAspNetCore.Configurationcontains plain settings classes and options registration.AspNetCorecontains reusable web/runtime defaults.AppHostcontains Aspire orchestration.
- .NET SDK
10.0.203or compatible latest feature SDK - Docker Desktop
- Google Chrome, only for the local coverage script opening the HTML report
The SDK is pinned in global.json:
{
"sdk": {
"version": "10.0.203",
"rollForward": "latestFeature"
}
}Install the template package from NuGet:
dotnet new install CleanApiStarter.TemplateCreate a new solution:
dotnet new clean-api-starter -n MyProductThe template replaces CleanApiStarter in solution, project, file, and namespace names. For example, CleanApiStarter.Api becomes MyProduct.Api.
While developing the template locally, run:
scripts/install-template.shThat script:
- uninstalls the previous local template package
- packs the current repo with
CleanApiStarter.Template.csproj - installs the generated local
.nupkg - deletes the temporary package from
artifacts
Then create a local test solution:
dotnet new clean-api-starter -n DemoProductdotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csprojAspire starts:
- the API
- a PostgreSQL container using
postgres:latest - pgAdmin
- the Aspire dashboard
Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity.
The AppHost configures PostgreSQL like this:
- server resource:
postgres-server - database resource:
postgres - data volume:
clean-api-starter-postgres-data - volume mount:
/var/lib/postgresql - init scripts:
database/migrations
PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume.
If you want only PostgreSQL without Aspire:
docker compose up -d
dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csprojdocker-compose.yml starts one database named postgres and mounts database/migrations into the Postgres init directory.
Database scripts live here:
database/migrations
├── V001__create_projects_and_tasks_tables.sql
└── V002__create_identity_tables.sql
The application uses EF Core through ApplicationDbContext, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers.
ApplicationDbContext lives in:
src/CleanApiStarter.Infrastructure/Persistence
EF Core mappings live in:
src/CleanApiStarter.Infrastructure/Persistence/Configuration
The API uses Minimal APIs. Program.cs stays small and delegates endpoint registration to endpoint group classes.
Endpoint groups live under version folders:
src/CleanApiStarter.Api/Endpoints/V1
src/CleanApiStarter.Api/Endpoints/V2
Each endpoint group implements IEndpointGroup from CleanApiStarter.AspNetCore and is discovered through:
app.MapEndpoints(Assembly.GetExecutingAssembly());Endpoint names are globally unique across versions, for example:
GetProjectsV1
GetProjectsV2
API versioning uses header-based version selection:
X-Api-Version: 1.0The header is optional. Requests without X-Api-Version use v1.
The project uses the .NET 10 API versioning/OpenAPI package setup:
Asp.Versioning.HttpAsp.Versioning.Mvc.ApiExplorerAsp.Versioning.OpenApiMicrosoft.AspNetCore.OpenApi
OpenAPI documents are generated per version and displayed in Scalar.
This project uses Scalar instead of Swagger/Swashbuckle.
When running in Development, OpenAPI and Scalar are mapped by the API:
- versioned OpenAPI documents
- Scalar API reference with version selection
Authentication is API-first.
The intended production flow is:
- A client obtains a Google ID token.
- The client sends it to
POST /api/auth/google. - The API validates the Google ID token.
- The API creates or updates the local ASP.NET Core Identity user.
- The API issues its own JWT.
- Protected API calls use:
Authorization: Bearer <api-jwt>The API does not use cookies as the default authentication mechanism.
Create an OAuth client in Google Cloud Console and set the client id with user secrets:
dotnet user-secrets set "Authentication:Google:ClientId" "<google-client-id>" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csprojFor local browser testing, open the development helper page:
https://localhost:7285/auth/google-login
The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button.
Protected endpoint groups call RequireAuthorization() once at the group level, so individual project/task endpoints do not need repeated authorization declarations.
Project authorization rules are implemented in the application service and repository queries:
- users can only see projects they belong to
- users can only see and manage tasks inside projects they belong to
- project deletion is owner-only
The reference feature is project and task management.
Projects:
- create project
- list current user's projects
- get project by id
- delete project as owner
Tasks:
- create task under a project
- list tasks with
limitandoffset - filter tasks by status
- get task by id
- update task
- complete task
- delete task
The application project is organized by feature:
src/CleanApiStarter.Application/Features/Auth
src/CleanApiStarter.Application/Features/Projects
Cross-cutting abstractions live under:
src/CleanApiStarter.Application/Common
Single-resource endpoints return the resource DTO directly.
Paginated endpoints return PaginatedResult<T>:
{
"items": [],
"limit": 20,
"offset": 0,
"totalCount": 0,
"hasPreviousPage": false,
"hasNextPage": false
}Non-paginated collection endpoints should return ArrayResult<T>:
{
"items": [],
"count": 0
}Request validation uses FluentValidation, not DataAnnotations.
Validators live beside request models in the Application project. The shared Minimal API validation filter lives in CleanApiStarter.AspNetCore.
Validation failures return:
422 Unprocessable EntityConfiguration classes live in CleanApiStarter.Configuration.
The root configuration object is:
AppSettingsIt includes:
ConnectionStrings.PostgresAuthentication.Google.ClientIdAuthentication.Jwt.IssuerAuthentication.Jwt.AudienceAuthentication.Jwt.SigningKeyAuthentication.Jwt.ExpirationMinutes
The API registers settings once:
builder.Services.AddAppSettings(builder.Configuration);Services can inject AppSettings directly.
CleanApiStarter.AspNetCore centralizes runtime defaults:
- OpenTelemetry traces
- OpenTelemetry metrics
- OpenTelemetry logs
- OTLP export for Aspire
- structured log formatting
- HTTP request logging
- health checks
- service discovery
- HTTP client resilience
- problem details
- response compression
- request id header
- Kestrel
Serverheader removal
When the API runs under Aspire, inspect the dashboard for:
- API request traces
- Npgsql database traces
- structured logs
- request logs with
UserId - runtime metrics
- resource health
Every response includes:
X-Request-ID: <trace-id>Use this value to correlate client responses with logs and traces.
The shared ASP.NET Core defaults map:
/health
/alive
/version
/version exposes the running application version.
Application unit tests use:
- xUnit v3
- AutoFixture.xUnit3
- AutoFixture.AutoNSubstitute
- NSubstitute
- Shouldly
Common test helpers live in:
tests/CleanApiStarter.Tests
Current reusable helpers:
AutoNSubstituteDataAttributeApiApplicationFactory<TProgram>
Test names follow:
UnitOfWork_StateUnderTest_ExpectedBehavior
Tests use AAA sections:
// Arrange
// Act
// AssertRun unit tests:
dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csprojAPI integration tests use:
- MSTest
- Microsoft.AspNetCore.Mvc.Testing
- Testcontainers for PostgreSQL
- Shouldly
The integration test factory starts a real Postgres container, applies scripts from database/migrations, boots the API through WebApplicationFactory<Program>, and creates real JWTs from appsettings.Testing.json.
The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme.
Run integration tests:
dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csprojRun all tests:
dotnet test CleanApiStarter.slnxGenerate coverage and open the HTML report in Chrome:
scripts/test-coverage.shThe script:
- deletes the previous
artifacts/coveragefolder - restores local .NET tools
- runs tests with
XPlat Code Coverage - generates an HTML report with ReportGenerator
- opens the report in Google Chrome
Coverage output:
artifacts/coverage/report/index.html
Restore and build:
dotnet restore CleanApiStarter.slnx
dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimalGitHub Actions workflows live in .github/workflows:
build.ymlrestores, builds, and tests the repository on pull requests and pushes tomain.codeql.ymlruns CodeQL static security analysis on pull requests, pushes tomain, and weekly on Monday.template.ymlpacks the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output.release.ymlpublishes the template to NuGet when a GitHub Release is published with a tag such asv1.0.0.
Package versions are managed centrally in:
Directory.Packages.props
Keep package versions sorted alphabetically by Include. Do not add package versions directly to individual project files.
- Use explicit local types.
- Use project-level
GlobalUsings.cs. - Keep cancellation tokens explicit. Do not use
CancellationToken cancellationToken = defaultin service or repository contracts. - Use
requiredandinitfor required non-null DTO and domain properties. - Use nullable types only for genuinely optional values.
- Use structured logging message templates instead of interpolated log strings.
- Keep repository interfaces in
Application. - Keep repository implementations and EF Core details in
Infrastructure. - Keep domain models persistence-agnostic.
- Do not reintroduce Dapper for the default data access path.
- Do not reintroduce MVC controllers unless the template intentionally changes direction.
- Do not add Swashbuckle; use Scalar.
If Aspire logs an UntrustedRoot error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate:
dotnet dev-certs https --check --trustIf needed, reset and trust again:
dotnet dev-certs https --clean
dotnet dev-certs https --trust
dotnet dev-certs https --check --trustOn macOS, accept the Keychain prompt and enter your password if requested.
Postgres init scripts only run when the data directory is created. Delete the existing volume and start again.
For Docker Compose:
docker compose down -v
docker compose up -dFor Aspire, delete the clean-api-starter-postgres-data Docker volume.
This template uses postgres:latest. PostgreSQL 18+ expects data mounted at:
/var/lib/postgresql
Do not mount the volume at:
/var/lib/postgresql/data
This project is licensed under the GNU General Public License v3.0. See LICENSE for details.