2026-03-15 17:11:31 +01:00
2026-03-15 17:11:31 +01:00
2026-03-15 17:11:31 +01:00
2026-03-15 11:22:01 +01:00
2026-03-15 11:22:01 +01:00
2026-03-15 17:11:31 +01:00
2026-03-15 17:11:31 +01:00
2026-03-15 14:10:44 +01:00

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

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


Getting Started

1. Start the infrastructure

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

dotnet run --project src/API/Mccn.Api/Mccn.Api.csproj

The API is available at https://localhost:8081 or http://localhost:8080. Swagger UI is at https://localhost:8081/swagger or http://localhost:8080/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

dotnet test

A specific test project

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:

dotnet ef migrations add <MigrationName> \
  --project src/Modules/Users/Mccn.Modules.Users.Infrastructure/ \
  --startup-project src/API/Mccn.Api/ \
  --context UsersDbContext

Apply migrations manually

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

# 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

# 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

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

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

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

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

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

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:

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);
    }
}
// Schemas.cs
namespace Mccn.Modules.Orders.Infrastructure.Database;

internal static class Schemas
{
    internal const string Orders = "orders";
}

Register it in DependencyInjection.cs:

services.AddDbContext<OrdersDbContext>(options =>
    options
        .UseNpgsql(configuration.GetConnectionString("Database"))
        .UseSnakeCaseNamingConvention());

Then add OrdersDbContext to MigrationExtensions.cs in Mccn.Api:

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

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)

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

// GetOrder/GetOrderQuery.cs
public sealed record GetOrderQuery(Guid OrderId) : IQuery<OrderResponse>;
// GetOrder/OrderResponse.cs
public sealed record OrderResponse(Guid Id, string Status, decimal Total);
// 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)

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

Handlers never throw exceptions for expected failures. They return Result<T>:

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

return result.IsSuccess
    ? Results.Ok(result.Value)
    : Results.NotFound();

Commands and Queries

Use ICommand<TResponse> for operations that change state, IQuery<TResponse> for reads:

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:

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

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.

Description
No description provided
Readme 117 KiB
Languages
C# 100%