README
This commit is contained in:
584
README.md
Normal file
584
README.md
Normal file
@@ -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<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.
|
||||||
Reference in New Issue
Block a user