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
- Prerequisites
- Getting Started
- Infrastructure Services
- Project Structure
- Running the Tests
- EF Core Migrations
- Adding a New Module
- Adding a Feature to an Existing Module
- 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
- Docker Desktop
- An IDE that supports
.slnxsolution files (Rider, Visual Studio 2022+)
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:
- Inherit from
Entity - Have a private parameterless constructor (required by EF Core)
- 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>.Contractsproject 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.