.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 | What it may NOT reference |
|---|---|---|
| Domain | CleanArcStack.Domain |
Application, Infrastructure, API, EF Core, Redis, RabbitMQ |
| Application | CleanArcStack.Application |
Infrastructure, API, EF Core, Redis, RabbitMQ — use interfaces only |
| Infrastructure | CleanArcStack.Infrastructure |
API |
| API | CleanArcStack.API |
Direct DB/cache/queue calls — only DI wiring |
| 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:
// src/CleanArcStack.API/DependencyInjection.cs
services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped);Application's DependencyInjection.cs only registers IPipelineBehavior<,> (as AddScoped) + AddValidatorsFromAssembly.
- Infrastructure layer: required on every async call.
- Application, Domain, API layers: do NOT add — ASP.NET Core has no
SynchronizationContext.
// Return Result<T> from all Application handlers
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 definitions — code in English, message in Turkish
public static readonly Error NotFound = new("Order.NotFound", "Sipariş bulunamadı.");// Domain methods receive TimeProvider as parameter
public static Order Create(CustomerId id, TimeProvider time) { ... }
// Allowed only as inline field defaults in BaseEntity / IdentityUser
public DateTime CreatedAt { get; protected set; } = DateTime.UtcNow;Never publish integration events directly from command handlers. Always go through the outbox.
The rule says messages must be in Turkish. New error classes must follow the Turkish rule.
// 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))
Controllers inherit ApiControllerBase which exposes Sender (typed IMediator) via lazy service locator — do not inject IMediator via constructor in standard controllers.
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
public sealed class OrdersController : ApiControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateOrderRequest request, CancellationToken ct)
{
var result = await Sender.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 Sender.Send(new GetOrderByIdQuery(id), ct));
}Exception: Controllers that need TimeProvider (e.g. for cookie expiry) may inject it via constructor:
// AuthController is the only controller with constructor injection
public sealed class AuthController(IHostEnvironment env, TimeProvider timeProvider) : ApiControllerBaseRules:
- No try/catch in controllers —
GlobalExceptionHandlerhandles unhandled exceptions. - Always return
ApiResponse<T>via base class helpers (OkResponse,FromResult,PagedResponse,ErrorResponse). [ApiVersion("1.0")]on every controller.[AllowAnonymous]explicitly on every public endpoint.
- JWT access token → response body, 1h lifetime,
ClockSkew = TimeSpan.Zero - Refresh token →
HttpOnly; Secure; SameSite=Strictcookie, path/api/v1/auth, 7-day lifetime — never in response body;Securerelaxed in Development - API keys → stored as SHA-256 hex hash;
ApiKeyMiddlewarevalidates before JWT auth - Two schemes:
Bearer(default) +ApiKey(ApiKeyAuthHandler.SchemeName = "ApiKey"— defined inCleanArcStack.Infrastructure.Identity.ApiKeyAuthHandler)
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/v1/auth/login |
Anonymous | Token body + refreshToken cookie |
| POST | /api/v1/auth/register |
Anonymous | 201 + { userId } |
| POST | /api/v1/auth/refresh |
Anonymous | Reads cookie — no body param |
| POST | /api/v1/auth/revoke |
Bearer | Reads cookie, deletes it |
| GET | /api/v1/auth/me |
Bearer | Current user profile |
app.UseExceptionHandler();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseCors("ReactApp");
app.UseMiddleware<CorrelationIdMiddleware>();
// Inline security headers
app.Use(async (ctx, next) => {
ctx.Response.Headers.Append("X-Content-Type-Options", "nosniff");
ctx.Response.Headers.Append("X-Frame-Options", "DENY");
ctx.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
await next();
});
app.UseMiddleware<ApiKeyMiddleware>();
if (app.Environment.IsDevelopment())
{
if (httpsEnabled) // httpsEnabled = ASPNETCORE_URLS contains "https://"
app.UseHttpsRedirection();
app.MapScalarApiReference(options => ...);
app.MapOpenApi();
}
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<IdempotencyMiddleware>();
app.MapControllers();Do not reorder. CORS must precede ApiKeyMiddleware so OPTIONS preflight passes without an API key.
Named policy "ReactApp" (constant CleanArcStack.API.DependencyInjection.CorsPolicyName).
"AllowedOrigins": ["http://localhost:3000"]AllowCredentials() is enabled — required for the refreshToken cookie.
Outermost → innermost execution order:
LoggingBehaviorValidationBehaviorPerformanceBehaviorCachingBehavior— only applies to queries implementingICacheQuery
Registration in Application/DependencyInjection.cs:
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>));Cacheable query:
public sealed record GetOrderByIdQuery(Guid OrderId)
: IRequest<Result<OrderResponse>>, ICacheQuery
{
public string CacheKey => $"orders:{OrderId}";
public TimeSpan Expiration => TimeSpan.FromMinutes(10);
}- Always
IEntityTypeConfiguration<T>— never data annotations on domain entities. - All configs auto-discovered:
modelBuilder.ApplyConfigurationsFromAssembly(...)inOnModelCreating. - Strongly typed IDs need
HasConversion. - Read queries:
AsNoTracking()— no exceptions. IUnitOfWork.SaveChangesAsyncdispatches domain events fromAggregateRootentities after each successful save.
No generic IRepository<TEntity> — this project uses entity-specific repository interfaces only.
Domain/Repositories/
├── IUnitOfWork.cs — SaveChangesAsync (collects + dispatches AggregateRoot domain events)
├── IUserRepository.cs — AppUser CRUD + roles + refresh token insertion
└── IApiKeyRepository.cs — API key hash lookup / management
Infrastructure/Persistence/Repositories/
├── UserRepository.cs — wraps UserManager<AppUser> + AppDbContext (internal sealed)
└── ApiKeyRepository.cs — wraps AppDbContext.ApiKeys (internal sealed)
Rules:
- Application handlers must not inject
UserManager<AppUser>orAppDbContextdirectly — useIUserRepository. - All Infrastructure services call
IUnitOfWork.SaveChangesAsync— neverdbContext.SaveChangesAsyncdirectly. ITokenService.GenerateAccessTokenacceptsIReadOnlyList<string>— neverIList<string>.- New aggregate → new
I{Entity}Repositoryin Domain + implementation inInfrastructure/Persistence/Repositories/+ registration inAddPersistence.
src/CleanArcStack.Application/Common/Interfaces/
├── IApiKeyService.cs # API key validation
├── IAuthService.cs # login / register / refresh / revoke
├── ICacheQuery.cs # marker for CachingBehavior
├── ICacheService.cs # Redis abstraction
├── ICurrentUser.cs # current authenticated user context
├── IEmailService.cs # email sending abstraction
├── ISearchService.cs # Elasticsearch abstraction
├── IStorageService.cs # MinIO / S3 abstraction
└── ITokenService.cs # JWT generation
| 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)
Sensitive values 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). Never put secrets in .env or appsettings.json.
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
# Unit tests only
dotnet test tests/CleanArcStack.Unit.Tests/
# Integration tests only
dotnet test tests/CleanArcStack.Integration.Tests/
# Add EF Core migration (run from repo root)
dotnet ef migrations add YYYYMMDD_Description \
--project src/CleanArcStack.Infrastructure \
--startup-project src/CleanArcStack.API
# Start all infrastructure
docker compose up -d
# Rebuild API only (after code changes)
docker compose up -d --build api
# API (Docker): http://localhost:5001
# Scalar docs (dev only): http://localhost:PORT/scalar
# Health: /health/live /health/ready /health-ui
# Kibana APM: http://localhost:5601 → Observability → APMOn startup: db.Database.MigrateAsync() runs automatically, then RoleSeeder.SeedAsync + ApiKeySeeder.SeedAsync.
Program.cs ends with public partial class Program; — required for WebApplicationFactory<Program> in integration tests.
- Unit tests: NSubstitute mocks,
FakeTimeProvider, no real I/O. - Integration tests: Testcontainers always — never
UseInMemoryDatabasein integration tests. UseInMemoryDatabaseis acceptable only in unit tests for Infrastructure services (e.g.AuthServiceTests).- Start multiple containers in parallel via
Task.WhenAll. - Test method naming:
{Method}_{Scenario}_{ExpectedResult} AssemblyMarkermust exist in every project — required by architecture tests intests/CleanArcStack.Unit.Tests/Common/ArchitectureTests.cs.Guid.CreateVersion7()in test data — neverGuid.NewGuid().
| File | Purpose |
|---|---|
src/CleanArcStack.API/Program.cs |
Startup · secrets loading · middleware pipeline · 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.API/Controllers/ApiControllerBase.cs |
Sender (lazy IMediator) · OkResponse · ErrorResponse · FromResult · PagedResponse |
src/CleanArcStack.API/Controllers/AuthController.cs |
Cookie handling pattern for refresh/revoke · IHostEnvironment + TimeProvider injection |
src/CleanArcStack.API/Common/ApiResponse.cs |
ApiResponse<T> · ApiError · PaginationMeta |
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.Application/Features/Auth/ |
Auth commands · errors · DTOs |
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/Identity/ApiKeyAuthHandler.cs |
Auth handler — SchemeName = "ApiKey" used in DI registration |
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 — use IApiKeyRepository + IUnitOfWork |
src/CleanArcStack.Infrastructure/Settings/TelemetrySettings.cs |
OTel/APM settings · Enabled = !string.IsNullOrWhiteSpace(ApmServerUrl) |
tests/CleanArcStack.Unit.Tests/Common/ArchitectureTests.cs |
Architecture validation tests (NetArchTest) |
tests/CleanArcStack.Integration.Tests/Infrastructure/IntegrationTestFixture.cs |
Testcontainers fixture |
| Service | Port | Purpose |
|---|---|---|
| PostgreSQL 16 | 5432 | Primary DB |
| Redis 7 | 6379 | Cache + idempotency keys |
| RabbitMQ 3 | 5672 / 15672 | Messaging (management UI: 15672) |
| Elasticsearch 8.12 | 9200 | Search + Serilog log sink |
| Kibana 8.12 | 5601 | Log visualization + APM UI |
| APM Server 8.12 | 8200 | OTLP traces from the API |
| MinIO | 9000 (S3) / 9001 (Console) | Object storage |
| Nginx | 80 (API) / 9080 (MinIO CDN) | Reverse proxy |
Startup order: kibana-setup → apm-server → API (API has depends_on: apm-server: service_healthy).