Skip to content

Latest commit

 

History

History
506 lines (388 loc) · 20.3 KB

CLAUDE.md

File metadata and controls

506 lines (388 loc) · 20.3 KB

CLAUDE.md — CleanArcStack

Stack

.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


Architecture — Layer Rules (strict one-way)

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

⚠️ Critical Gotchas

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:

// src/CleanArcStack.API/DependencyInjection.cs
services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped);

Application's DependencyInjection.cs only registers IPipelineBehavior<,> (as AddScoped) + AddValidatorsFromAssembly.

2 — ConfigureAwait(false)

  • Infrastructure layer: required on every async call.
  • Application, Domain, API layers: do NOT add — ASP.NET Core has no SynchronizationContext.

3 — Result pattern — never throw for expected failures

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

4 — TimeProvider, never DateTime.UtcNow

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

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

6 — MassTransit Outbox is mandatory

Never publish integration events directly from command handlers. Always go through the outbox.

7 — AuthErrors messages are currently in English (known inconsistency)

The rule says messages must be in Turkish. New error classes must follow the Turkish rule.


Command / Query Pattern

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


Domain Entity Pattern

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))

Controller Pattern

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) : ApiControllerBase

Rules:

  • No try/catch in controllers — GlobalExceptionHandler handles 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.

Auth Flow

  • JWT access token → response body, 1h lifetime, ClockSkew = TimeSpan.Zero
  • Refresh token → HttpOnly; Secure; SameSite=Strict cookie, path /api/v1/auth, 7-day lifetime — never in response body; Secure relaxed in Development
  • API keys → stored as SHA-256 hex hash; ApiKeyMiddleware validates before JWT auth
  • Two schemes: Bearer (default) + ApiKey (ApiKeyAuthHandler.SchemeName = "ApiKey" — defined in CleanArcStack.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

Middleware Order (Program.cs)

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.


CORS

Named policy "ReactApp" (constant CleanArcStack.API.DependencyInjection.CorsPolicyName).

"AllowedOrigins": ["http://localhost:3000"]

AllowCredentials() is enabled — required for the refreshToken cookie.


Pipeline Behaviors (Application layer — AddScoped)

Outermost → innermost execution order:

  1. LoggingBehavior
  2. ValidationBehavior
  3. PerformanceBehavior
  4. CachingBehavior — only applies to queries implementing ICacheQuery

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);
}

EF Core Configuration

  • Always IEntityTypeConfiguration<T> — never data annotations on domain entities.
  • All configs auto-discovered: modelBuilder.ApplyConfigurationsFromAssembly(...) in OnModelCreating.
  • Strongly typed IDs need HasConversion.
  • Read queries: AsNoTracking() — no exceptions.
  • IUnitOfWork.SaveChangesAsync dispatches domain events from AggregateRoot entities after each successful save.

Repository Pattern

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> or AppDbContext directly — use IUserRepository.
  • All Infrastructure services call IUnitOfWork.SaveChangesAsync — never dbContext.SaveChangesAsync directly.
  • ITokenService.GenerateAccessToken accepts IReadOnlyList<string> — never IList<string>.
  • New aggregate → new I{Entity}Repository in Domain + implementation in Infrastructure/Persistence/Repositories/ + registration in AddPersistence.

Common Interfaces (Application layer)

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

Naming Conventions

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

Code Style (universal)

  • sealed on all Commands, Queries, Handlers, Validators, Services (unless inheritance needed)
  • record for Commands, Queries, DTOs, Domain Events, Value Objects, Integration Events
  • Primary constructors for DI — no manual field assignment
  • required on mandatory DTO/settings properties
  • File-scoped namespaces — namespace MyApp.Domain.Entities;
  • Collection expressions [...] — never new List<T> { }
  • IReadOnlyList<T> from public methods — never expose List<T> directly
  • ArgumentNullException.ThrowIfNull / ArgumentException.ThrowIfNullOrWhiteSpace for guards
  • Settings injection: always IOptions<TSettings> — never IConfiguration in service classes
  • Error codes: "{Entity}.{Reason}" (English) — descriptions in Turkish
  • Mapster mapping in Application layer only (static extension methods next to DTOs)

Secrets Management

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

# 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 → APM

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


Testing Rules

  • Unit tests: NSubstitute mocks, FakeTimeProvider, no real I/O.
  • Integration tests: Testcontainers always — never UseInMemoryDatabase in integration tests.
  • UseInMemoryDatabase is 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}
  • AssemblyMarker must exist in every project — required by architecture tests in tests/CleanArcStack.Unit.Tests/Common/ArchitectureTests.cs.
  • Guid.CreateVersion7() in test data — never Guid.NewGuid().

Important File Locations

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

External Services

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