From d954a14564e6dfd1e5a0c2a7ec278c77d9f8f19e Mon Sep 17 00:00:00 2001 From: Dualxyz Date: Sun, 15 Mar 2026 11:29:49 +0100 Subject: [PATCH] README --- README.md | 584 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 584 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7f60b1 --- /dev/null +++ b/README.md @@ -0,0 +1,584 @@ +# Mccn Modular Monolith + +A .NET 10 modular monolith template using CQRS, Domain-Driven Design, Keycloak authentication, and a full observability stack. + +--- + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Prerequisites](#prerequisites) +- [Getting Started](#getting-started) +- [Infrastructure Services](#infrastructure-services) +- [Project Structure](#project-structure) +- [Running the Tests](#running-the-tests) +- [EF Core Migrations](#ef-core-migrations) +- [Adding a New Module](#adding-a-new-module) +- [Adding a Feature to an Existing Module](#adding-a-feature-to-an-existing-module) +- [Key Patterns and Conventions](#key-patterns-and-conventions) + +--- + +## Architecture Overview + +The solution is a **modular monolith**: a single deployable unit composed of fully isolated modules. Each module owns its domain, application logic, database schema, and HTTP endpoints. Modules cannot directly reference each other — this boundary is enforced by architecture tests. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Mccn.Api │ +│ (entry point / host) │ +└────────────────────────┬────────────────────────────────┘ + │ wires up + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌── ─ ─ ─ ─ ┐ + │ Users │ │ Hello │ Your Module + │ Module │ │ Module │ └── ─ ─ ─ ─ ┘ + └────────────┘ └────────────┘ +``` + +### Layers per Module + +Each module is split into four projects following a strict dependency direction: + +``` +Presentation ──► Application ──► Domain + │ +Infrastructure ─────────┘ +``` + +| Layer | Responsibility | +|------------------|-----------------------------------------------------------| +| **Domain** | Entities, value objects, domain errors, repository interfaces | +| **Application** | Use cases (commands & queries), validators, service abstractions | +| **Infrastructure** | EF Core DbContext, repositories, external HTTP clients (e.g. Keycloak) | +| **Presentation** | Minimal API endpoints | + +### Cross-Cutting Concerns + +| Concern | Technology | +|------------------|-----------------------------------------------------------| +| Authentication | Keycloak (JWT Bearer / OIDC) | +| Logging | Serilog → Seq | +| Distributed Tracing | OpenTelemetry → Jaeger | +| Caching | Redis (falls back to in-memory if Redis is unavailable) | +| Validation | FluentValidation via MediatR pipeline behavior | +| Error handling | Global exception handler + `Result` pattern | + +--- + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download) +- [Docker Desktop](https://www.docker.com/products/docker-desktop) +- An IDE that supports `.slnx` solution files (Rider, Visual Studio 2022+) + +--- + +## Getting Started + +### 1. Start the infrastructure + +```bash +docker compose up -d +``` + +This starts five containers. Wait ~30 seconds for Keycloak to finish importing the realm before starting the application. + +### 2. Run the application + +```bash +dotnet run --project src/API/Mccn.Api/Mccn.Api.csproj +``` + +The API is available at `http://localhost:5000`. Swagger UI is at `http://localhost:5000/swagger`. + +On first run in Development mode the database schema is automatically created via EF Core. + +### 3. Test the endpoints + +Open `src/API/Mccn.Api/Mccn.Api.http` in your IDE (JetBrains HTTP Client or VS Code REST Client). The file contains ready-to-run requests for every endpoint, including a login request that acquires a Keycloak token and injects it into subsequent authenticated requests. + +--- + +## Infrastructure Services + +| Service | Purpose | Local URL / Port | +|------------|---------------------|-------------------------------------------| +| PostgreSQL | Database | `localhost:5432` | +| Keycloak | Identity provider | `http://localhost:18080` (admin: `admin` / `admin`) | +| Seq | Log aggregation | `http://localhost:9081` | +| Redis | Distributed cache | `localhost:6379` | +| Jaeger | Distributed tracing | `http://localhost:16686` | + +> **Note:** If Redis, Seq, or Jaeger are not running, the application still starts. Redis gracefully falls back to an in-memory cache. Seq and Jaeger are fire-and-forget sinks. Keycloak is required at startup only when a protected endpoint is first called (the JWT metadata is fetched lazily). The **database is the only hard dependency at startup**. + +--- + +## Project Structure + +``` +mccn-modular-monolith/ +├── docker-compose.yml +├── Directory.Build.props # Global: net10.0, nullable, implicit usings +├── .files/ +│ └── mccn-realm-export.json # Keycloak realm imported on container start +│ +├── src/ +│ ├── API/ +│ │ └── Mccn.Api/ # Host: wires all modules, middleware, migrations +│ │ +│ ├── Common/ +│ │ ├── Mccn.Common.Domain/ # Entity, Result, Error +│ │ ├── Mccn.Common.Application/ # ICommand, IQuery, ValidationBehavior +│ │ ├── Mccn.Common.Infrastructure/ # Auth, caching, observability, exception handling +│ │ └── Mccn.Common.Presentation/ # IEndpoint, EndpointExtensions +│ │ +│ └── Modules/ +│ ├── Hello/ # Example module (no database) +│ │ ├── Mccn.Modules.Hello.Application/ +│ │ ├── Mccn.Modules.Hello.Infrastructure/ +│ │ ├── Mccn.Modules.Hello.Presentation/ +│ │ ├── Mccn.Modules.Hello.ArchitectureTests/ +│ │ └── Mccn.Modules.Hello.IntegrationTests/ +│ │ +│ └── Users/ # Authentication module +│ ├── Mccn.Modules.Users.Domain/ +│ ├── Mccn.Modules.Users.Application/ +│ ├── Mccn.Modules.Users.Infrastructure/ +│ ├── Mccn.Modules.Users.Presentation/ +│ ├── Mccn.Modules.Users.ArchitectureTests/ +│ ├── Mccn.Modules.Users.IntegrationTests/ +│ └── Mccn.Modules.Users.UnitTests/ +│ +└── test/ + └── Mccn.ArchitectureTests/ # Global: verifies modules don't reference each other +``` + +--- + +## Running the Tests + +### All tests + +```bash +dotnet test +``` + +### A specific test project + +```bash +dotnet test src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/ +dotnet test src/Modules/Users/Mccn.Modules.Users.UnitTests/ +dotnet test src/Modules/Users/Mccn.Modules.Users.IntegrationTests/ +``` + +### Notes on integration tests + +Integration tests spin up real Docker containers using **Testcontainers**. Docker Desktop must be running. The first run pulls the container images and may take a few minutes. Subsequent runs use cached layers and are much faster. + +Each integration test module has its own `IntegrationTestWebAppFactory` that starts the containers it actually needs: + +| Test project | Containers started | +|-----------------------|-----------------------------------| +| Users IntegrationTests | PostgreSQL, Redis, Keycloak | +| Hello IntegrationTests | PostgreSQL, Redis | + +--- + +## EF Core Migrations + +Migrations live inside the module's Infrastructure project. Each module has its own `DbContext` and its own schema. + +### Add a migration (Users module) + +Run this from the solution root: + +```bash +dotnet ef migrations add \ + --project src/Modules/Users/Mccn.Modules.Users.Infrastructure/ \ + --startup-project src/API/Mccn.Api/ \ + --context UsersDbContext +``` + +### Apply migrations manually + +```bash +dotnet ef database update \ + --project src/Modules/Users/Mccn.Modules.Users.Infrastructure/ \ + --startup-project src/API/Mccn.Api/ \ + --context UsersDbContext +``` + +In Development mode, `ApplyMigrationsAsync()` in `Program.cs` runs pending migrations automatically on startup. In other environments you are expected to run migrations explicitly (e.g. as part of a deployment pipeline). + +--- + +## Adding a New Module + +Follow these steps to add a module called **Orders** as an example. + +### 1. Create the four layer projects + +```bash +# From the solution root +dotnet new classlib -n Mccn.Modules.Orders.Domain -o src/Modules/Orders/Mccn.Modules.Orders.Domain +dotnet new classlib -n Mccn.Modules.Orders.Application -o src/Modules/Orders/Mccn.Modules.Orders.Application +dotnet new classlib -n Mccn.Modules.Orders.Infrastructure -o src/Modules/Orders/Mccn.Modules.Orders.Infrastructure +dotnet new classlib -n Mccn.Modules.Orders.Presentation -o src/Modules/Orders/Mccn.Modules.Orders.Presentation +``` + +### 2. Wire the project references + +```bash +# Domain depends on Common.Domain +dotnet add src/Modules/Orders/Mccn.Modules.Orders.Domain/ reference \ + src/Common/Mccn.Common.Domain/ + +# Application depends on Common.Application + Domain +dotnet add src/Modules/Orders/Mccn.Modules.Orders.Application/ reference \ + src/Common/Mccn.Common.Application/ \ + src/Modules/Orders/Mccn.Modules.Orders.Domain/ + +# Infrastructure depends on Common.Infrastructure + Application + Domain +dotnet add src/Modules/Orders/Mccn.Modules.Orders.Infrastructure/ reference \ + src/Common/Mccn.Common.Infrastructure/ \ + src/Modules/Orders/Mccn.Modules.Orders.Application/ \ + src/Modules/Orders/Mccn.Modules.Orders.Domain/ + +# Presentation depends on Common.Presentation + Application +dotnet add src/Modules/Orders/Mccn.Modules.Orders.Presentation/ reference \ + src/Common/Mccn.Common.Presentation/ \ + src/Modules/Orders/Mccn.Modules.Orders.Application/ +``` + +### 3. Add projects to the solution + +```bash +dotnet sln add \ + src/Modules/Orders/Mccn.Modules.Orders.Domain/ \ + src/Modules/Orders/Mccn.Modules.Orders.Application/ \ + src/Modules/Orders/Mccn.Modules.Orders.Infrastructure/ \ + src/Modules/Orders/Mccn.Modules.Orders.Presentation/ +``` + +### 4. Reference the module from the API host + +```bash +dotnet add src/API/Mccn.Api/ reference \ + src/Modules/Orders/Mccn.Modules.Orders.Infrastructure/ \ + src/Modules/Orders/Mccn.Modules.Orders.Presentation/ +``` + +### 5. Create the DependencyInjection extension methods + +**Infrastructure** (`DependencyInjection.cs`): + +```csharp +namespace Mccn.Modules.Orders.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddOrdersModule( + this IServiceCollection services, + IConfiguration configuration) + { + // Register DbContext, repositories, etc. + return services; + } +} +``` + +**Presentation** (`DependencyInjection.cs`): + +```csharp +namespace Mccn.Modules.Orders.Presentation; + +public static class DependencyInjection +{ + public static IServiceCollection AddOrdersPresentationServices( + this IServiceCollection services) + { + return services; + } +} +``` + +### 6. Register the module in Program.cs + +```csharp +// Register modules. +builder.Services.AddUsersModule(builder.Configuration); +builder.Services.AddHelloModule(builder.Configuration); +builder.Services.AddOrdersModule(builder.Configuration); // add this + +// Register endpoints. +builder.Services.AddUsersPresentationServices(); +builder.Services.AddHelloPresentationServices(); +builder.Services.AddOrdersPresentationServices(); // add this +``` + +Also register the module's Application assembly with MediatR in `Program.cs`: + +```csharp +builder.Services.AddApplication( + typeof(RegisterUserCommand).Assembly, + typeof(SayHelloQuery).Assembly, + typeof(YourOrderCommand).Assembly); // add this +``` + +### 7. Add a DbContext (if the module has a database) + +Create `OrdersDbContext.cs` inside Infrastructure: + +```csharp +using Microsoft.EntityFrameworkCore; + +namespace Mccn.Modules.Orders.Infrastructure.Database; + +public sealed class OrdersDbContext(DbContextOptions options) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(Schemas.Orders); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly); + } +} +``` + +```csharp +// Schemas.cs +namespace Mccn.Modules.Orders.Infrastructure.Database; + +internal static class Schemas +{ + internal const string Orders = "orders"; +} +``` + +Register it in `DependencyInjection.cs`: + +```csharp +services.AddDbContext(options => + options + .UseNpgsql(configuration.GetConnectionString("Database")) + .UseSnakeCaseNamingConvention()); +``` + +Then add `OrdersDbContext` to `MigrationExtensions.cs` in `Mccn.Api`: + +```csharp +internal static async Task ApplyMigrationsAsync(this WebApplication app) +{ + using IServiceScope scope = app.Services.CreateScope(); + await MigrateAsync(scope.ServiceProvider); + await MigrateAsync(scope.ServiceProvider); // add this +} +``` + +### 8. Create the architecture test project + +```bash +dotnet new xunit -n Mccn.Modules.Orders.ArchitectureTests \ + -o src/Modules/Orders/Mccn.Modules.Orders.ArchitectureTests +dotnet sln add src/Modules/Orders/Mccn.Modules.Orders.ArchitectureTests/ +``` + +Copy the `BaseTest.cs`, `TestResultExtensions.cs`, `LayerTests.cs`, and `ApplicationTests.cs` files from an existing architecture test project (e.g. Hello) and update the namespaces and assembly references to point to the Orders module assemblies. + +Also add the new module to the global architecture tests in `test/Mccn.ArchitectureTests/Layers/ModuleTests.cs` to enforce that Orders does not depend on Users or Hello. + +--- + +## Adding a Feature to an Existing Module + +As an example, adding a **Get Order by ID** feature to the Orders module. + +### 1. Define the domain error (Domain layer) + +```csharp +// OrderErrors.cs +public static class OrderErrors +{ + public static Error NotFound(Guid id) => + Error.NotFound("Orders.NotFound", $"Order with ID '{id}' was not found."); +} +``` + +### 2. Create the query (Application layer) + +```csharp +// GetOrder/GetOrderQuery.cs +public sealed record GetOrderQuery(Guid OrderId) : IQuery; +``` + +```csharp +// GetOrder/OrderResponse.cs +public sealed record OrderResponse(Guid Id, string Status, decimal Total); +``` + +```csharp +// GetOrder/GetOrderQueryHandler.cs +internal sealed class GetOrderQueryHandler(IOrderRepository orderRepository) + : IQueryHandler +{ + public async Task> Handle( + GetOrderQuery query, + CancellationToken cancellationToken) + { + Order? order = await orderRepository.GetByIdAsync(query.OrderId, cancellationToken); + + if (order is null) + return Result.Failure(OrderErrors.NotFound(query.OrderId)); + + return new OrderResponse(order.Id, order.Status, order.Total); + } +} +``` + +### 3. Create the endpoint (Presentation layer) + +```csharp +// Orders/GetOrder.cs +internal sealed class GetOrder : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("orders/{orderId:guid}", async (Guid orderId, ISender sender) => + { + var query = new GetOrderQuery(orderId); + Result result = await sender.Send(query); + + return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); + }) + .WithTags("Orders") + .RequireAuthorization(); + } +} +``` + +The endpoint is discovered and registered automatically — no manual wiring needed. + +--- + +## Key Patterns and Conventions + +### Result + +Handlers never throw exceptions for expected failures. They return `Result`: + +```csharp +// Returning success +return new OrderResponse(order.Id, order.Status); + +// Returning failure +return Result.Failure(OrderErrors.NotFound(id)); +``` + +At the endpoint level, map the result to an HTTP response: + +```csharp +return result.IsSuccess + ? Results.Ok(result.Value) + : Results.NotFound(); +``` + +### Commands and Queries + +Use `ICommand` for operations that change state, `IQuery` for reads: + +```csharp +public sealed record CreateOrderCommand(Guid UserId, decimal Total) : ICommand; +public sealed record GetOrderQuery(Guid OrderId) : IQuery; +``` + +Implement `ICommandHandler` or `IQueryHandler` accordingly. + +### Validation + +Add a `FluentValidation` validator next to any command that requires input validation: + +```csharp +// CreateOrderCommandValidator.cs +public sealed class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() + { + RuleFor(c => c.UserId).NotEmpty(); + RuleFor(c => c.Total).GreaterThan(0); + } +} +``` + +The `ValidationBehavior` MediatR pipeline behavior runs all validators automatically before the handler. Invalid requests return a `400 Bad Request` with field-level error details — no handler code needed. + +### Domain Entities + +Every entity must: + +1. Inherit from `Entity` +2. Have a **private** parameterless constructor (required by EF Core) +3. Expose a **static `Create()` factory method** for all other construction + +```csharp +public sealed class Order : Entity +{ + public Guid UserId { get; private set; } + public decimal Total { get; private set; } + + private Order() { } // EF Core + + public static Order Create(Guid userId, decimal total) + { + return new Order + { + Id = Guid.NewGuid(), + UserId = userId, + Total = total + }; + } +} +``` + +This is enforced by the architecture tests in `DomainTests.cs`. + +### Naming Conventions + +The architecture tests enforce these naming rules: + +| Type | Convention | Example | +|------------------|-----------------------------------------|--------------------------------------| +| Commands | End with `Command` | `CreateOrderCommand` | +| Queries | End with `Query` | `GetOrderQuery` | +| Command handlers | End with `CommandHandler` | `CreateOrderCommandHandler` | +| Query handlers | End with `QueryHandler` | `GetOrderQueryHandler` | +| Validators | End with `Validator` | `CreateOrderCommandValidator` | + +### Endpoints + +Implement `IEndpoint` and the class is discovered and registered automatically at startup: + +```csharp +internal sealed class MyEndpoint : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("resource", Handler).WithTags("Resource"); + } + + private static async Task Handler(...) { ... } +} +``` + +No registration in `DependencyInjection.cs` is needed for individual endpoints — only the `AssemblyReference` marker class in the Presentation project needs to exist so `EndpointExtensions` can scan the assembly. + +### Module Isolation + +Modules must never reference each other directly. Cross-module communication should go through: + +- **Integration events** (publish via a message broker or in-process event bus — add as needed) +- **Shared contracts** in a dedicated `Mccn.Modules..Contracts` project that other modules may reference + +The global architecture tests in `test/Mccn.ArchitectureTests/` will fail the build if a module-to-module reference is introduced.