Clean Architecture: Domain → Application → Infrastructure → API (strict one-way dependency).
- Domain (
CleanArcStack.Domain): entities, value objects, domain events, repository interfaces — zero NuGet dependencies. - Application (
CleanArcStack.Application): CQRS handlers, validators, pipeline behaviors, application interfaces — no EF Core, Redis, or RabbitMQ. - Infrastructure (
CleanArcStack.Infrastructure): EF Core + PostgreSQL, Redis, MassTransit/RabbitMQ, Elasticsearch, MinIO, Identity/JWT, OpenTelemetry. - API (
CleanArcStack.API): thin controllers dispatching to Mediator; all DI wiring inDependencyInjection.cs.
This project uses Martinothamar/Mediator (source-generator based) — API is similar to MediatR but with critical differences:
| MediatR | This project |
|---|---|
using MediatR; |
using Mediator; |
ISender |
IMediator |
Task<T> in handlers |
ValueTask<T> in handlers |
services.AddMediatR(...) |
services.AddMediator(...) |
AddMediator(...) is registered in API's DependencyInjection.cs (not Application):
// src/CleanArcStack.API/DependencyInjection.cs
services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped);Application's DependencyInjection.cs only registers pipeline behaviors (as AddScoped) + validators via AddValidatorsFromAssembly:
// src/CleanArcStack.Application/DependencyInjection.cs
services.AddValidatorsFromAssembly(typeof(AssemblyMarker).Assembly, includeInternalTypes: true);
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));Controllers inherit ApiControllerBase which exposes Sender (typed IMediator) via lazy service locator — do not inject IMediator via constructor in standard controllers:
// src/CleanArcStack.API/Controllers/ApiControllerBase.cs
private IMediator? _mediator;
protected IMediator Sender =>
_mediator ??= HttpContext.RequestServices.GetRequiredService<IMediator>();Exception: AuthController is the only controller that uses constructor injection — it needs IHostEnvironment and TimeProvider for cookie handling:
public sealed class AuthController(IHostEnvironment env, TimeProvider timeProvider) : ApiControllerBase// src/Application/Features/Auth/Commands/LoginCommand.cs
public sealed record LoginCommand(string Email, string Password) : IRequest<Result<AuthResponse>>;
public sealed class LoginCommandValidator : AbstractValidator<LoginCommand>
{
public LoginCommandValidator() { RuleFor(x => x.Email).NotEmpty().EmailAddress(); }
}Every Command/Query must have a paired validator — no exceptions.
Result<T> / Result — never throw for expected failures. Error codes in English, messages in Turkish:
public static readonly Error NotFound = new("Auth.UserNotFound", "Kullanıcı bulunamadı.");- Infrastructure layer: add
ConfigureAwait(false)on every async call. - All other layers: do NOT add
ConfigureAwait(false).
public sealed record UserId(Guid Value);
// EF Core config requires HasConversion — see Persistence/Configurations/Domain methods receive TimeProvider as a parameter. Inline defaults in BaseEntity / IdentityUser field initializers are the only exception.
This project uses entity-specific repository interfaces — there is no IRepository<TEntity>.
Each aggregate gets its own interface in Domain/Repositories/, implemented in Infrastructure/Persistence/Repositories/.
Domain/Repositories/
├── IUnitOfWork.cs — SaveChangesAsync (dispatches domain events)
├── IUserRepository.cs — AppUser CRUD + roles + refresh tokens
└── IApiKeyRepository.cs — API key lookup / management
- Application handlers must not inject
UserManager<AppUser>orAppDbContextdirectly — useIUserRepository. IUnitOfWork.SaveChangesAsyncdispatches domain events fromAggregateRootentities after each successful save.- All Infrastructure services call
IUnitOfWork.SaveChangesAsync— neverdbContext.SaveChangesAsyncdirectly. ITokenService.GenerateAccessTokenacceptsIReadOnlyList<string>for roles — neverIList<string>.
- JWT access token → response body (1h lifetime,
ClockSkew = TimeSpan.Zero) - Refresh token →
HttpOnly; Secure; SameSite=Strictcookie at path/api/v1/auth, 7-day lifetime — never in body;Secureis relaxed in Development so HTTP (localhost) works - API keys → stored as SHA-256 hex hash; validated via
ApiKeyMiddleware(runs before authentication) - Two auth schemes:
Bearer(default) +ApiKey— scheme name defined inCleanArcStack.Infrastructure.Identity.ApiKeyAuthHandler.SchemeName = "ApiKey"
Auth endpoints (/api/v1/auth):
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /login |
Anonymous | Returns token body + sets refreshToken cookie |
| POST | /register |
Anonymous | Returns { userId } with 201 |
| POST | /refresh |
Anonymous | Reads refreshToken cookie — no body param |
| POST | /revoke |
Bearer | Deletes refreshToken cookie |
| GET | /me |
Bearer | Returns current user profile |
refresh and revoke read the refreshToken cookie directly — no CancellationToken-bearing body parameters.
Sensitive values live in .secrets/ (one value per file, no trailing newline) — never in .env or appsettings.json.
Quickest setup: chmod +x setup-secrets.sh && ./setup-secrets.sh — generates all files with random values.
Full list of required .secrets/ files:
# PostgreSQL
.secrets/postgres_password # used by postgres container
# Connection strings
.secrets/ConnectionStrings__DefaultConnection # local dev (Host=localhost;...)
.secrets/ConnectionStrings__DefaultConnection.docker # API container (Host=postgres;...)
# JWT
.secrets/Jwt__Secret # min 32 chars
# RabbitMQ
.secrets/RabbitMQ__Password
# Elasticsearch / Kibana
.secrets/Elasticsearch__Password # elastic superuser password
.secrets/kibana_system_password # kibana_system built-in user
.secrets/kibana_encryption_key # xpack.encryptedSavedObjects.encryptionKey
.secrets/kibana_security_encryption_key # xpack.security.encryptionKey
.secrets/kibana_reporting_encryption_key # xpack.reporting.encryptionKey
# MinIO
.secrets/MinIO__AccessKey
.secrets/MinIO__SecretKey
# APM — both files must contain the same value
.secrets/Apm__SecretToken # APM Server container reads via shell export
.secrets/Telemetry__ApmSecretToken # API container reads via AddKeyPerFile
Program.cs loads .secrets/ by walking up from the working directory (FindSecretsDirectory) for local dev, then /run/secrets for Docker (AddKeyPerFile).
Kibana secrets (
kibana_system_password,kibana_encryption_key,kibana_security_encryption_key,kibana_reporting_encryption_key) are consumed exclusively by Docker containers — the API never reads them directly. They are still required fordocker compose upto succeed.
Apm__SecretTokenandTelemetry__ApmSecretTokenmust contain the same value.Telemetry__ApmSecretTokenmaps toTelemetry:ApmSecretToken→TelemetrySettings.ApmSecretToken. UsingApm__SecretTokenfor the API would map toApm:SecretToken(wrong key — token never sent).
# One-time secrets setup (generates all .secrets/ files with random values)
chmod +x setup-secrets.sh && ./setup-secrets.sh
# Build
dotnet build CleanArcStack.sln
# Run all tests
dotnet test CleanArcStack.sln
# Run only unit tests
dotnet test tests/CleanArcStack.Unit.Tests/
# Run only integration tests
dotnet test tests/CleanArcStack.Integration.Tests/
# Add EF Core migration (from repo root)
dotnet ef migrations add YYYYMMDD_Description \
--project src/CleanArcStack.Infrastructure \
--startup-project src/CleanArcStack.API
# Start full infrastructure (PostgreSQL, Redis, RabbitMQ, ES, Kibana, APM, MinIO, Nginx)
docker compose up -d
# Rebuild and restart only the API (after code changes)
docker compose up -d --build api
# API available at http://localhost:5001 (via Docker) or https://localhost:PORT (local)
# Scalar API docs: http://localhost:PORT/scalar (Development only)
# Health: /health/live /health/ready /health-ui
# APM traces: Kibana → Observability → APM → Services → CleanArcStack.APIOn startup, the API automatically runs db.Database.MigrateAsync(), then RoleSeeder.SeedAsync(...) and ApiKeySeeder.SeedAsync(...).
Program.cs ends with public partial class Program; — required for integration tests using WebApplicationFactory<Program>.
Registered in Application/DependencyInjection.cs as AddScoped — outermost first:
LoggingBehavior→ 2.ValidationBehavior→ 3.PerformanceBehavior→ 4.CachingBehavior
Cacheable queries implement ICacheQuery (defines CacheKey + Expiration).
Named policy "ReactApp" (constant CleanArcStack.API.DependencyInjection.CorsPolicyName). Origins read from AllowedOrigins config array; AllowCredentials() is enabled (required for the refreshToken cookie).
// appsettings.json
"AllowedOrigins": ["http://localhost:3000"]UseCors(...) runs before ApiKeyMiddleware so CORS preflight (OPTIONS) passes without an API key.
Exact order — do not reorder:
app.UseExceptionHandler();
app.UseForwardedHeaders(...);
app.UseCors("ReactApp");
app.UseMiddleware<CorrelationIdMiddleware>();
// inline security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
app.UseMiddleware<ApiKeyMiddleware>();
if (app.Environment.IsDevelopment())
{
if (httpsEnabled) app.UseHttpsRedirection();
app.MapScalarApiReference(...);
app.MapOpenApi();
}
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<IdempotencyMiddleware>();
app.MapControllers();Rate limiting: global IP-partitioned limiter (100 req/min) + named "auth" policy (10 req/min) applied via [EnableRateLimiting("auth")] on auth endpoints.
All interfaces live in src/CleanArcStack.Application/Common/Interfaces/:
IApiKeyService— API key validationIAuthService— login / register / refresh / revokeICacheQuery— marker interface forCachingBehaviorICacheService— Redis abstraction (Get / Set / Remove / RemoveByPrefix)ICurrentUser— current authenticated user contextIEmailService— email sending abstractionISearchService— Elasticsearch abstractionIStorageService— MinIO / S3 abstraction (presigned URLs)ITokenService— JWT generation
| Service | Port | Purpose |
|---|---|---|
| PostgreSQL 16 | 5432 | Primary DB |
| Redis 7 | 6379 | Cache + idempotency keys |
| RabbitMQ 3 | 5672 / 15672 | Messaging (management UI at 15672) |
| Elasticsearch 8.12 | 9200 | Search + Serilog log sink |
| Kibana 8.12 | 5601 | Log visualization + APM UI |
| APM Server 8.12 | 8200 | Receives OTLP traces from the API |
| MinIO | 9000 (S3) / 9001 (Console) | Object storage |
| Nginx | 80 (API) / 9080 (MinIO CDN) | Reverse proxy |
Startup dependency order: kibana-setup (one-shot: sets kibana_system password + installs APM Fleet integration) must complete before apm-server starts. The API waits for apm-server: service_healthy.
MassTransit Outbox (PostgreSQL-backed) is mandatory — never publish integration events directly from handlers.
- Traces sent to Elastic APM Server via OTLP/HTTP (
http://apm-server:8200/v1/traces) - Auto-instruments: ASP.NET Core, EF Core, HttpClient, MassTransit, Redis
- Enabled only when
Telemetry:ApmServerUrlis non-empty (TelemetrySettings.Enabled) - Log–trace correlation via
Serilog.Enrichers.Span(.Enrich.WithSpan()) —TraceId+SpanIdin every log entry - View traces: Kibana → Observability → APM → Services → CleanArcStack.API
| File | Purpose |
|---|---|
src/CleanArcStack.API/Program.cs |
Startup · FindSecretsDirectory · middleware order · public partial class Program; |
src/CleanArcStack.API/DependencyInjection.cs |
AddMediator(ServiceLifetime.Scoped) · CORS · rate limiter · health-checks UI |
src/CleanArcStack.Infrastructure/DependencyInjection.cs |
All infrastructure registrations (persistence, identity, auth, messaging, caching, search, storage, OTel, health) |
src/CleanArcStack.Application/DependencyInjection.cs |
Pipeline behaviors (AddScoped) · AddValidatorsFromAssembly |
src/CleanArcStack.Infrastructure/Settings/TelemetrySettings.cs |
OTel/APM settings · Enabled = !string.IsNullOrWhiteSpace(ApmServerUrl) |
src/CleanArcStack.Application/Common/Behaviors/ |
Pipeline behaviors |
src/CleanArcStack.Application/Common/Models/Result.cs |
Result pattern (Result<T> + non-generic Result) |
src/CleanArcStack.Application/Common/Interfaces/ |
All application-level service interfaces |
src/CleanArcStack.API/Controllers/ApiControllerBase.cs |
Sender (lazy IMediator) · OkResponse · ErrorResponse · FromResult · PagedResponse |
src/CleanArcStack.API/Controllers/AuthController.cs |
Cookie handling pattern for refresh/revoke · constructor injection of IHostEnvironment + TimeProvider |
src/CleanArcStack.API/Common/ApiResponse.cs |
ApiResponse<T> · ApiError · PaginationMeta |
src/CleanArcStack.Infrastructure/Identity/ApiKeyAuthHandler.cs |
SchemeName = "ApiKey" — used in DI registration |
src/CleanArcStack.Infrastructure/Identity/AuthService.cs |
Uses IUserRepository + IUnitOfWork — no direct AppDbContext |
src/CleanArcStack.Infrastructure/Identity/ApiKeyService.cs |
Uses IApiKeyRepository — no direct AppDbContext |
src/CleanArcStack.Infrastructure/Persistence/UnitOfWork.cs |
SaveChangesAsync + domain event dispatch via IPublisher |
src/CleanArcStack.Infrastructure/Persistence/Repositories/ |
UserRepository, ApiKeyRepository — internal sealed implementations |
src/CleanArcStack.Domain/Repositories/ |
IUnitOfWork, IUserRepository, IApiKeyRepository interfaces |
src/CleanArcStack.Infrastructure/Persistence/Configurations/ |
EF Core entity configs (strongly-typed ID conversions, indexes) |
src/CleanArcStack.Infrastructure/Persistence/Seeders/ |
RoleSeeder + ApiKeySeeder — run on every startup; use IApiKeyRepository + IUnitOfWork |
src/CleanArcStack.Application/Features/Auth/ |
Auth commands · errors · DTOs |
tests/CleanArcStack.Unit.Tests/Common/ArchitectureTests.cs |
Architecture validation tests (NetArchTest) |
tests/CleanArcStack.Integration.Tests/Infrastructure/IntegrationTestFixture.cs |
Testcontainers fixture |
| 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, in Application/Features/{Feature}/) |
OrderErrors |
| Response DTO | {Entity}Response |
OrderResponse |
| EF Config | {Entity}Configuration : IEntityTypeConfiguration<{Entity}> |
OrderConfiguration |