Files
Mccn/README.md
2026-03-15 11:29:49 +01:00

585 lines
20 KiB
Markdown

# 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<T>` 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<T>, Result<T>, 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 <MigrationName> \
--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<OrdersDbContext> 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<OrdersDbContext>(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<UsersDbContext>(scope.ServiceProvider);
await MigrateAsync<OrdersDbContext>(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<OrderResponse>;
```
```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<GetOrderQuery, OrderResponse>
{
public async Task<Result<OrderResponse>> Handle(
GetOrderQuery query,
CancellationToken cancellationToken)
{
Order? order = await orderRepository.GetByIdAsync(query.OrderId, cancellationToken);
if (order is null)
return Result.Failure<OrderResponse>(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<OrderResponse> 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<T>
Handlers never throw exceptions for expected failures. They return `Result<T>`:
```csharp
// Returning success
return new OrderResponse(order.Id, order.Status);
// Returning failure
return Result.Failure<OrderResponse>(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<TResponse>` for operations that change state, `IQuery<TResponse>` for reads:
```csharp
public sealed record CreateOrderCommand(Guid UserId, decimal Total) : ICommand<Guid>;
public sealed record GetOrderQuery(Guid OrderId) : IQuery<OrderResponse>;
```
Implement `ICommandHandler<TCommand, TResponse>` or `IQueryHandler<TQuery, TResponse>` accordingly.
### Validation
Add a `FluentValidation` validator next to any command that requires input validation:
```csharp
// CreateOrderCommandValidator.cs
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
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<IResult> 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.<Name>.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.