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