Skip to content

Latest commit

 

History

History
315 lines (255 loc) · 16.2 KB

AGENTS.md

File metadata and controls

315 lines (255 loc) · 16.2 KB

AGENTS.md — CleanArcStack

Architecture Overview

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 in DependencyInjection.cs.

CQRS: Mediator (NOT MediatR)

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

Key Patterns

Command + Validator in the same file

// 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 Pattern

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ı.");

ConfigureAwait(false) rule

  • Infrastructure layer: add ConfigureAwait(false) on every async call.
  • All other layers: do NOT add ConfigureAwait(false).

Strongly Typed IDs

public sealed record UserId(Guid Value);
// EF Core config requires HasConversion — see Persistence/Configurations/

TimeProvider — never DateTime.UtcNow directly

Domain methods receive TimeProvider as a parameter. Inline defaults in BaseEntity / IdentityUser field initializers are the only exception.

Guid.CreateVersion7() — never Guid.NewGuid()

Repository Pattern — no generic repository

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> or AppDbContext directly — use IUserRepository.
  • IUnitOfWork.SaveChangesAsync dispatches domain events from AggregateRoot entities after each successful save.
  • All Infrastructure services call IUnitOfWork.SaveChangesAsync — never dbContext.SaveChangesAsync directly.
  • ITokenService.GenerateAccessToken accepts IReadOnlyList<string> for roles — never IList<string>.

Auth Flow

  • JWT access token → response body (1h lifetime, ClockSkew = TimeSpan.Zero)
  • Refresh token → HttpOnly; Secure; SameSite=Strict cookie at path /api/v1/auth, 7-day lifetime — never in body; Secure is 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 in CleanArcStack.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.

Secrets Management

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 for docker compose up to succeed.

Apm__SecretToken and Telemetry__ApmSecretToken must contain the same value. Telemetry__ApmSecretToken maps to Telemetry:ApmSecretToken → TelemetrySettings.ApmSecretToken. Using Apm__SecretToken for the API would map to Apm:SecretToken (wrong key — token never sent).

Developer Workflows

# 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.API

On 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>.

Pipeline Behavior Order

Registered in Application/DependencyInjection.cs as AddScoped — outermost first:

  1. LoggingBehavior → 2. ValidationBehavior → 3. PerformanceBehavior → 4. CachingBehavior

Cacheable queries implement ICacheQuery (defines CacheKey + Expiration).

CORS

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.

Middleware Order (Program.cs)

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.

Common Application Interfaces

All interfaces live in src/CleanArcStack.Application/Common/Interfaces/:

  • IApiKeyService — API key validation
  • IAuthService — login / register / refresh / revoke
  • ICacheQuery — marker interface for CachingBehavior
  • ICacheService — Redis abstraction (Get / Set / Remove / RemoveByPrefix)
  • ICurrentUser — current authenticated user context
  • IEmailService — email sending abstraction
  • ISearchService — Elasticsearch abstraction
  • IStorageService — MinIO / S3 abstraction (presigned URLs)
  • ITokenService — JWT generation

External Services (docker-compose)

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.

OpenTelemetry

  • 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:ApmServerUrl is non-empty (TelemetrySettings.Enabled)
  • Log–trace correlation via Serilog.Enrichers.Span (.Enrich.WithSpan()) — TraceId + SpanId in every log entry
  • View traces: Kibana → Observability → APM → Services → CleanArcStack.API

Important File Locations

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

Naming Quick Reference

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