Production-ready .NET 9 Clean Architecture boilerplate — batteries included.
A full-stack backend template that wires together the most common enterprise concerns so you can focus on building features, not infrastructure.
| Concern | Solution |
|---|---|
| Architecture | Clean Architecture — Domain → Application → Infrastructure → API |
| Runtime | .NET 9 |
| Database | PostgreSQL 16 via EF Core 9 + Npgsql |
| Messaging | MassTransit 8 + RabbitMQ (Outbox pattern — guaranteed delivery) |
| Caching | Redis 7 (StackExchange.Redis) |
| Search | Elasticsearch 8 (Elastic.Clients.Elasticsearch) |
| Object Storage | MinIO (S3-compatible, presigned URL pattern, dual-client CDN) |
| CQRS | Martinothamar/Mediator — source-generator based, zero reflection |
| Validation | FluentValidation — pipeline behavior, 422 field-level errors |
| Mapping | Mapster — static extension methods in Application layer |
| Auth | ASP.NET Core Identity + JWT Bearer + API Key (custom scheme) |
| Logging | Serilog → Console + Elasticsearch sink, enriched with TraceId & SpanId |
| Tracing | OpenTelemetry → Elastic APM Server 8 (OTLP/HTTP) |
| Log/Trace UI | Kibana 8 (logs dashboard + APM traces) |
| API Docs | Scalar (OpenAPI) — Development only |
| API Versioning | Asp.Versioning.Mvc |
| Health Checks | AspNetCore.HealthChecks.UI — /health/live, /health/ready, /health-ui |
| Resilience | Polly v8 (Microsoft.Extensions.Http.Resilience) |
| Reverse Proxy | Nginx — :80 API proxy, :9080 MinIO CDN with proxy_cache |
| Testing | xUnit + FluentAssertions + NSubstitute + Testcontainers + NetArchTest |
Domain ──► Application ──► Infrastructure ──► API
Strict one-way dependency — inner layers never reference outer layers.
src/
├── CleanArcStack.Domain/ # Entities, Value Objects, Domain Events, Repository interfaces
├── CleanArcStack.Application/ # CQRS handlers, validators, pipeline behaviors, interfaces
├── CleanArcStack.Infrastructure/ # EF Core, Redis, RabbitMQ, Elasticsearch, MinIO, JWT, Identity
└── CleanArcStack.API/ # Controllers (thin dispatchers), middleware, DI wiring
tests/
├── CleanArcStack.Unit.Tests/ # Domain, Application handlers, Architecture tests (NetArchTest)
└── CleanArcStack.Integration.Tests/# Testcontainers-based real infrastructure tests
- Result Pattern —
Result<T>returned from every Application handler; no exceptions for expected failures - MassTransit Outbox — integration events published transactionally via PostgreSQL-backed outbox
- Presigned URLs — MinIO dual-client pattern: management client (internal) + CDN client (public hostname)
- API Key middleware — SHA-256 hashed keys validated before JWT auth; negative cache in Redis
- Refresh token cookie —
HttpOnly; Secure; SameSite=Strict, never in response body - Idempotency middleware —
Idempotency-Keyheader replays cached responses for non-idempotent POSTs
- Docker Desktop (or Docker + Compose)
- .NET 9 SDK (local dev only)
git clone https://github.com/mhmtdev/CleanArcStack.git
cd CleanArcStackAll sensitive values live in .secrets/ — never in .env or appsettings.json.
💡 Quickest way: run
chmod +x setup-secrets.sh && ./setup-secrets.sh— generates all required files with random values in one step. Use the manual commands below only if you need custom values.
mkdir -p .secrets
# PostgreSQL
echo -n "YourStrongPostgresPassword" > .secrets/postgres_password
# Connection strings
echo -n "Host=postgres;Port=5432;Database=cleanarcstack;Username=postgres;Password=YourStrongPostgresPassword" \
> .secrets/ConnectionStrings__DefaultConnection.docker
echo -n "Host=localhost;Port=5432;Database=cleanarcstack;Username=postgres;Password=YourStrongPostgresPassword" \
> .secrets/ConnectionStrings__DefaultConnection
# JWT (min 32 chars)
echo -n "YourSuperSecretJwtKeyAtLeast32Chars!" > .secrets/Jwt__Secret
# RabbitMQ
echo -n "YourRabbitMQPassword" > .secrets/RabbitMQ__Password
# Elasticsearch
echo -n "YourElasticPassword" > .secrets/Elasticsearch__Password
# Kibana
echo -n "YourKibanaSystemPassword" > .secrets/kibana_system_password
echo -n "$(openssl rand -hex 32)" > .secrets/kibana_encryption_key
echo -n "$(openssl rand -hex 32)" > .secrets/kibana_security_encryption_key
echo -n "$(openssl rand -hex 32)" > .secrets/kibana_reporting_encryption_key
# MinIO
echo -n "your-minio-access-key" > .secrets/MinIO__AccessKey
echo -n "your-minio-secret-key" > .secrets/MinIO__SecretKey
# APM (same value for both files)
echo -n "$(openssl rand -hex 20)" > .secrets/Apm__SecretToken
cp .secrets/Apm__SecretToken .secrets/Telemetry__ApmSecretToken
⚠️ Always useecho -n(no trailing newline) — a trailing newline breaksAddKeyPerFile.
docker compose up -dThis starts: PostgreSQL · Redis · RabbitMQ · Elasticsearch · Kibana · APM Server · MinIO · Nginx
The API container auto-runs EF Core migrations and seeds roles + API keys on first boot.
dotnet run --project src/CleanArcStack.API --launch-profile https| URL | Description |
|---|---|
https://localhost:7279/scalar |
Scalar API documentation |
https://localhost:7279/health-ui |
Health Checks dashboard |
https://localhost:7279/health/live |
Liveness probe |
https://localhost:7279/health/ready |
Readiness probe |
Or via Docker (Nginx on :5001):
docker compose up -d --build api
# API: http://localhost:5001
# Health: http://localhost:5001/health/liveOn startup, the seeder creates two API keys (Frontend + Mobile) — hashed in the database.
To retrieve plaintext values, use the dev-only endpoint:
POST https://localhost:7279/api/v1/dev/api-keys/regenerateResponse:
{
"success": true,
"data": [
{ "name": "Frontend Web Client", "clientType": "Frontend", "rawKey": "...", "usage": "X-Api-Key: ..." },
{ "name": "Mobile Client", "clientType": "Mobile", "rawKey": "...", "usage": "X-Api-Key: ..." }
]
}Use the rawKey value in the X-Api-Key header on every request.
This endpoint returns 404 in Production — Development only.
POST /api/v1/auth/register → 201 { userId }
POST /api/v1/auth/login → 200 { accessToken, expiresIn } + Set-Cookie: refreshToken (HttpOnly)
POST /api/v1/auth/refresh → 200 new accessToken (reads cookie, no body needed)
POST /api/v1/auth/revoke → 204 (clears cookie)
GET /api/v1/auth/me → 200 { userId, email, roles }
- Access token — JWT, 1 hour, returned in response body
- Refresh token — 7 days,
HttpOnly; Secure; SameSite=Strictcookie at path/api/v1/auth - API Key —
X-Api-Keyheader, validated before JWT middleware (SHA-256 hashed in DB)
| Service | Host Port | Purpose |
|---|---|---|
| PostgreSQL 16 | 5432 |
Primary database |
| Redis 7 | 6379 |
Cache + idempotency keys |
| RabbitMQ 3 | 5672 / 15672 |
Messaging (management UI) |
| Elasticsearch 8.12 | 9200 |
Search + Serilog log sink |
| Kibana 8.12 | 5601 |
Log visualization + APM UI |
| APM Server 8.12 | 8200 |
Receives OTLP traces |
| MinIO | 9000 (S3) / 9001 (Console) |
Object storage |
| Nginx | 5001 (API) / 9080 (MinIO CDN) |
Reverse proxy |
# All tests
dotnet test CleanArcStack.sln
# Unit tests only (fast — no I/O)
dotnet test tests/CleanArcStack.Unit.Tests/
# Integration tests (Testcontainers — requires Docker)
dotnet test tests/CleanArcStack.Integration.Tests/| Layer | Approach |
|---|---|
| Domain | Pure unit tests — entity logic, domain events, invariants |
| Application | NSubstitute mocks + FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) |
| Infrastructure | Testcontainers — real PostgreSQL, Redis, RabbitMQ containers |
| Architecture | NetArchTest — layer dependency rules enforced on every build |
Non-sensitive defaults live in appsettings.json. Sensitive values come from .secrets/ (local) or Docker secrets (/run/secrets) in production.
{
"ConnectionStrings": { "DefaultConnection": "", "Redis": "localhost:6379" },
"Jwt": { "Issuer": "CleanArcStack API", "Audience": "CleanArcStack", "ExpirySeconds": 3600 },
"RabbitMQ": { "Host": "localhost", "VirtualHost": "/", "Username": "cleanarcstack_app" },
"Elasticsearch": { "Url": "http://localhost:9200", "Username": "elastic" },
"MinIO": { "Endpoint": "localhost:9000", "CdnBaseUrl": "http://localhost:9080", "UseSSL": false },
"Telemetry": { "ServiceName": "CleanArcStack.API", "ApmServerUrl": "" }
}# Add a new migration (run from repo root)
dotnet ef migrations add YYYYMMDD_Description \
--project src/CleanArcStack.Infrastructure \
--startup-project src/CleanArcStack.API
# Migrations run automatically on startup via MigrateAsync()src/CleanArcStack.Domain/
├── Common/ AggregateRoot, BaseEntity, IDomainEvent
├── Entities/ AppUser, ApiKey, RefreshToken
├── Enums/
├── Events/ Domain event definitions
├── Exceptions/ DomainException, typed error classes
├── Repositories/ IRepository<T>, IUnitOfWork interfaces
└── ValueObjects/
src/CleanArcStack.Application/
├── Common/
│ ├── Behaviors/ LoggingBehavior → ValidationBehavior → PerformanceBehavior → CachingBehavior
│ ├── Exceptions/ AppException, ValidationException, NotFoundException, ConflictException
│ ├── Interfaces/ ICacheService, ICurrentUser, IStorageService, IAuthService, ITokenService …
│ └── Models/ Result<T>, Error, PagedResult<T>, ApiResponse<T>
└── Features/
└── Auth/ Login, Register, Refresh, Revoke, GetMe commands/queries + DTOs
src/CleanArcStack.Infrastructure/
├── Caching/ RedisCacheService
├── Identity/ JwtTokenService, AuthService, ApiKeyService, CurrentUser
├── Messaging/ MassTransit consumers, integration events, publishers
├── Persistence/ AppDbContext, EF Core configurations, migrations, seeders
├── Search/ ElasticsearchService, ElasticsearchHealthCheck
├── Settings/ JwtSettings, RabbitMQSettings, ElasticsearchSettings, StorageSettings, TelemetrySettings
└── Storage/ MinioStorageService (dual-client), StorageHealthCheck
src/CleanArcStack.API/
├── Common/ ApiResponse<T>, ApiError, PaginationMeta
├── Controllers/ ApiControllerBase, AuthController, DevController
├── Extensions/ ApiKeyAuthHandler, ClaimsPrincipalExtensions
└── Middleware/ ApiKeyMiddleware, CorrelationIdMiddleware, GlobalExceptionHandler, IdempotencyMiddleware
Registered outermost → innermost:
Request
└── LoggingBehavior (logs request/response, elapsed time)
└── ValidationBehavior (FluentValidation → 422 on failure)
└── PerformanceBehavior (warns on slow handlers)
└── CachingBehavior (Redis — only for ICacheQuery)
└── Handler
ExceptionHandler → ForwardedHeaders → CORS → CorrelationId
→ Security Headers → ApiKeyMiddleware → HttpsRedirection (dev)
→ RateLimiter → Authentication → Authorization → Idempotency
→ MapControllers
- Structured logs → Serilog Console + Elasticsearch sink
- Log correlation →
TraceId+SpanIdenriched viaSerilog.Enrichers.Span - Distributed traces → OpenTelemetry OTLP → Elastic APM Server
- Auto-instruments: ASP.NET Core, EF Core, HttpClient, Redis, MassTransit
- Kibana →
http://localhost:5601- Discover: structured log search
- Observability → APM: distributed traces per service
CleanArcStack is designed to work seamlessly with AI coding assistants. The repository ships with first-class support for GitHub Copilot and is compatible with Claude, Cursor, and any assistant that reads markdown instruction files.
The project architecture, coding conventions, layer rules, naming patterns, and design decisions are codified in machine-readable instruction files. AI assistants pick these up automatically, so every suggestion they make stays consistent with the codebase — no need to repeat context in every chat.
.github/
├── copilot-instructions.md # Global rules — every Copilot Chat session reads this
├── instructions/ # Layer-specific rules loaded by pattern matching
│ ├── domain.instructions.md
│ ├── application.instructions.md
│ ├── infrastructure.instructions.md
│ ├── api.instructions.md
│ └── testing.instructions.md
└── prompts/ # Reusable agent tasks (GitHub Copilot Agent Mode)
├── add-feature.prompt.md # Scaffold a full vertical slice across all layers
├── add-migration.prompt.md # Create an EF Core migration with validation
├── check-arch.prompt.md # Audit Clean Architecture compliance
├── infra-up.prompt.md # Start Docker infra and verify service health
└── run-tests.prompt.md # Run tests and report results
AGENTS.md # OpenAI Codex / GPT-based agents
CLAUDE.md # Claude (Anthropic)
Open Copilot Chat, switch to Agent Mode, then reference any prompt file with #:
| Prompt | What It Does | How to Invoke |
|---|---|---|
add-feature |
Scaffolds a complete vertical slice — Domain entity, Application command/query + validator, Infrastructure EF config + repository + migration, API controller, unit tests | #add-feature.prompt.md → enter feature name |
add-migration |
Builds the project, creates a dated EF Core migration, reviews the generated Up/Down |
#add-migration.prompt.md → enter description |
check-arch |
Scans all layers for Clean Architecture violations (wrong dependencies, missing ConfigureAwait, naked Ok(), etc.) and runs NetArchTest |
#check-arch.prompt.md |
infra-up |
Starts Docker Compose, waits for health checks, reports service status | #infra-up.prompt.md |
run-tests |
Runs unit and/or integration tests, summarises failures with stack traces | #run-tests.prompt.md → optionally enter a filter |
Example — add a new feature in Agent Mode:
- Open Copilot Chat → Agent Mode
- Type:
#add-feature.prompt.md - Enter feature name when prompted, e.g.
Child - Copilot generates all files across Domain → Application → Infrastructure → API → Tests, runs the build, creates the migration, and verifies compliance — without asking for confirmation at each step.
💡 The instruction files also work passively: simply open a file in any layer and start chatting — Copilot automatically applies the correct layer rules without any prompt.
| Assistant | Configuration File | Notes |
|---|---|---|
| GitHub Copilot | .github/copilot-instructions.md + .github/instructions/*.md |
Auto-loaded; pattern-matched per file |
| Claude (Anthropic) | CLAUDE.md + .claude/commands/ |
Repo root — auto-loaded by Claude Code and claude.ai Projects |
| OpenAI / GPT agents | AGENTS.md |
Codex / custom GPT instructions |
| Cursor | .github/copilot-instructions.md |
Cursor reads Copilot instructions natively |
CLAUDE.md at the repository root is automatically picked up by Claude Code and claude.ai Projects. It encodes the full architecture, critical gotchas, and coding conventions so Claude produces consistent, compliant code without requiring repeated context.
The .claude/commands/ directory contains reusable slash commands for Claude Code. Type / in Claude Code to invoke them:
| Command | What It Does |
|---|---|
/add-feature <name> |
Scaffolds a complete vertical slice — Domain entity, Application command/query + validator, Infrastructure EF config + repository + migration, API controller, unit tests |
/add-migration <description> |
Builds the project, creates a dated EF Core migration, reviews the generated Up/Down |
/check-arch |
Scans all layers for Clean Architecture violations (wrong dependencies, missing ConfigureAwait, naked Ok(), etc.) and runs NetArchTest |
/infra-up |
Starts Docker Compose, waits for health checks, reports service status and URLs |
/run-tests <filter?> |
Runs unit and/or integration tests, summarises failures with stack traces |
.claude/
├── commands/
│ ├── add-feature.md # /add-feature <featureName>
│ ├── add-migration.md # /add-migration <description>
│ ├── check-arch.md # /check-arch
│ ├── infra-up.md # /infra-up
│ └── run-tests.md # /run-tests [filter]
└── settings.json
Example — add a new feature with Claude Code:
- Open Claude Code in the project directory
- Type:
/add-feature Child - Claude generates all files across Domain → Application → Infrastructure → API → Tests, runs the build, creates the migration, and verifies compliance — without asking for confirmation at each step.
View full CLAUDE.md reference (architecture rules, patterns, gotchas)
.NET 9 · Clean Architecture · PostgreSQL 16 (EF Core 9) · Redis · RabbitMQ + MassTransit · Elasticsearch 8 · MinIO (S3) · Serilog → ES · OpenTelemetry → Elastic APM · Martinothamar/Mediator (NOT MediatR) · FluentValidation · Mapster · Scalar (OpenAPI) · xUnit + FluentAssertions + NSubstitute + Testcontainers
Domain → Application → Infrastructure → API
| Layer | Project | May NOT reference |
|---|---|---|
| Domain | CleanArcStack.Domain |
Application, Infrastructure, API, EF Core, Redis, RabbitMQ |
| Application | CleanArcStack.Application |
Infrastructure, API, EF Core, Redis, RabbitMQ |
| Infrastructure | CleanArcStack.Infrastructure |
API |
| API | CleanArcStack.API |
Direct DB/cache/queue calls — only DI wiring |
1 — Mediator is NOT MediatR
| MediatR | This project |
|---|---|
using MediatR; |
using Mediator; |
ISender |
IMediator |
Task<T> in handlers |
ValueTask<T> in handlers |
services.AddMediatR(...) |
services.AddMediator(...) |
AddMediator(...) lives in API's DependencyInjection.cs — NOT in Application.
2 — ConfigureAwait(false)
- Infrastructure layer: required on every async call.
- Application, Domain, API layers: do NOT add.
3 — Result pattern — never throw for expected failures
public async ValueTask<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = Order.Create(...);
repository.Add(order);
await unitOfWork.SaveChangesAsync(ct);
return order.Id.Value; // implicit conversion T → Result<T>
}
// Error codes in English, messages in Turkish
public static readonly Error NotFound = new("Order.NotFound", "Sipariş bulunamadı.");4 — TimeProvider, never DateTime.UtcNow — domain methods receive TimeProvider as parameter.
5 — Guid.CreateVersion7(), never Guid.NewGuid()
6 — MassTransit Outbox is mandatory — never publish integration events directly from command handlers.
// Command + Validator in the same file
public sealed record CreateOrderCommand(Guid CustomerId, IReadOnlyList<CreateOrderItemDto> Items)
: IRequest<Result<Guid>>;
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty();
RuleFor(x => x.Items).NotEmpty().WithMessage("Sipariş en az bir kalem içermelidir.");
}
}
public sealed class CreateOrderCommandHandler(
IOrderRepository repository,
IUnitOfWork unitOfWork,
TimeProvider timeProvider
) : IRequestHandler<CreateOrderCommand, Result<Guid>>
{
public async ValueTask<Result<Guid>> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = Order.Create(new CustomerId(request.CustomerId), timeProvider);
repository.Add(order);
await unitOfWork.SaveChangesAsync(ct);
return order.Id.Value;
}
}Every Command/Query must have a paired validator — no exceptions.
public sealed class Order : AggregateRoot
{
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
private Order() { } // EF Core
public static Order Create(CustomerId customerId, TimeProvider time)
{
var order = new Order { Id = Guid.CreateVersion7(), ... };
order.RaiseDomainEvent(new OrderCreatedDomainEvent(order.Id));
return order;
}
}- Private/protected constructor +
static Create(...)factory - No public setters — state via domain methods
- Strongly typed IDs:
public sealed record OrderId(Guid Value); - EF Core config requires
HasConversion(id => id.Value, v => new OrderId(v))
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
public sealed class OrdersController(IMediator mediator) : ApiControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
var result = await mediator.Send(new CreateOrderCommand(request.CustomerId, request.Items), ct);
return result.IsSuccess
? CreatedResponse("GetOrderById", new { id = result.Value }, result.Value)
: ErrorResponse(result.Error);
}
[HttpGet("{id:guid}", Name = "GetOrderById")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct) =>
FromResult(await mediator.Send(new GetOrderByIdQuery(id), ct));
}No try/catch in controllers — GlobalExceptionHandler handles unhandled exceptions.
| Artifact | Pattern | Example |
|---|---|---|
| Command | {Action}{Entity}Command |
CreateOrderCommand |
| Query | {Get|List|Search}{Entity}Query |
GetOrderByIdQuery |
| Handler | {Action}{Entity}CommandHandler |
CreateOrderCommandHandler |
| Domain Event | {Entity}{Action}DomainEvent |
OrderCreatedDomainEvent |
| Integration Event | {Entity}{Action}IntegrationEvent |
OrderCreatedIntegrationEvent |
| Consumer | {Entity}{Action}IntegrationEventConsumer |
OrderCreatedIntegrationEventConsumer |
| Error class | {Entity}Errors (static) |
OrderErrors |
| Response DTO | {Entity}Response |
OrderResponse |
| EF Config | {Entity}Configuration |
OrderConfiguration |
sealedon all Commands, Queries, Handlers, Validators, Services (unless inheritance needed)recordfor Commands, Queries, DTOs, Domain Events, Value Objects, Integration Events- Primary constructors for DI — no manual field assignment
requiredon mandatory DTO/settings properties- File-scoped namespaces —
namespace MyApp.Domain.Entities; - Collection expressions
[...]— nevernew List<T> { } IReadOnlyList<T>from public methods — never exposeList<T>directlyArgumentNullException.ThrowIfNull/ArgumentException.ThrowIfNullOrWhiteSpacefor guards- Settings injection: always
IOptions<TSettings>— neverIConfigurationin service classes - Error codes:
"{Entity}.{Reason}"(English) — descriptions in Turkish - Mapster mapping in Application layer only (static extension methods next to DTOs)
- Unit tests: no real I/O — NSubstitute mocks or concrete fakes;
FakeTimeProviderfor time-dependent logic. - Integration tests: Testcontainers always — never
UseInMemoryDatabasein integration tests. UseInMemoryDatabaseacceptable only in unit tests for Infrastructure services (e.g.AuthServiceTests).- Test method naming:
{Method}_{Scenario}_{ExpectedResult} AssemblyMarkermust exist in every project.Guid.CreateVersion7()in test data — neverGuid.NewGuid().
| File | Purpose |
|---|---|
src/CleanArcStack.API/Program.cs |
Startup · secrets loading · middleware order · public partial class Program; |
src/CleanArcStack.API/DependencyInjection.cs |
AddMediator · CORS · rate limiter · health-checks UI |
src/CleanArcStack.Infrastructure/DependencyInjection.cs |
All infrastructure registrations |
src/CleanArcStack.Application/DependencyInjection.cs |
Pipeline behaviors (AddScoped) · AddValidatorsFromAssembly |
src/CleanArcStack.API/Controllers/ApiControllerBase.cs |
OkResponse · ErrorResponse · FromResult · PagedResponse |
src/CleanArcStack.API/Controllers/AuthController.cs |
Cookie handling pattern for refresh/revoke |
src/CleanArcStack.Application/Common/Models/Result.cs |
Result pattern |
src/CleanArcStack.Infrastructure/Persistence/Configurations/ |
EF Core entity configs |
tests/CleanArcStack.Unit.Tests/Common/ArchitectureTests.cs |
Architecture validation tests |
tests/CleanArcStack.Integration.Tests/Infrastructure/IntegrationTestFixture.cs |
Testcontainers fixture |
- Fork the repository
- Create a feature branch:
git checkout -b feature/my-feature - Follow the architecture and code style rules in
.github/copilot-instructions.md - Add/update tests
- Open a pull request
MIT — see LICENSE for details.