uest routing, and FluentValidation for boundary enforcement. This approach enforces contract-first development, isolates use cases, and centralizes cross-cutting concerns through pipeline behaviors.
Step-by-Step Implementation
1. Project Structure
Organize by feature, not by technical layer. Each use case lives in a single file or tightly coupled group.
src/
βββ Features/
β βββ Orders/
β β βββ CreateOrder.cs
β β βββ GetOrder.cs
β βββ Users/
β βββ RegisterUser.cs
βββ Core/
β βββ Behaviors/
β β βββ ValidationBehavior.cs
β βββ Contracts/
β βββ ApiResult.cs
βββ Program.cs
2. Define Request and Response Contracts
Explicit contracts prevent implicit assumptions and enable static analysis.
public record CreateOrderRequest(
Guid UserId,
List<OrderItemDto> Items,
ShippingAddressDto Address);
public record OrderItemDto(Guid ProductId, int Quantity);
public record ShippingAddressDto(string Street, string City, string ZipCode);
public record CreateOrderResponse(Guid OrderId, DateTime CreatedAt);
3. Implement Handler with MediatR
Handlers contain only business logic. They do not handle HTTP concerns.
public class CreateOrderHandler : IRequestHandler<CreateOrderRequest, CreateOrderResponse>
{
private readonly IOrderRepository _repository;
private readonly IPaymentGateway _payment;
public CreateOrderHandler(IOrderRepository repository, IPaymentGateway payment)
{
_repository = repository;
_payment = payment;
}
public async Task<CreateOrderResponse> Handle(CreateOrderRequest request, CancellationToken ct)
{
var total = request.Items.Sum(i => i.Quantity * await _repository.GetPriceAsync(i.ProductId, ct));
var paymentResult = await _payment.AuthorizeAsync(request.UserId, total, ct);
if (!paymentResult.Success)
throw new PaymentException(paymentResult.Reason);
var order = new Order
{
UserId = request.UserId,
Items = request.Items,
Address = request.Address,
CreatedAt = DateTimeOffset.UtcNow
};
await _repository.SaveAsync(order, ct);
return new CreateOrderResponse(order.Id, order.CreatedAt);
}
}
4. Add Validation Boundary
FluentValidation enforces constraints before handlers execute.
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.Items).NotEmpty().Must(items => items.All(i => i.Quantity > 0));
RuleFor(x => x.Address).NotNull().SetValidator(new ShippingAddressValidator());
}
}
5. Map Endpoint to Handler
Minimal APIs route directly to MediatR. No controllers required.
app.MapPost("/api/orders", async (
CreateOrderRequest request,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(request, ct);
return Results.Created($"/api/orders/{result.OrderId}", result);
})
.WithName("CreateOrder")
.Produces<CreateOrderResponse>(StatusCodes.Status201Created)
.ProducesProblem(StatusCodes.Status400BadRequest);
6. Register Cross-Cutting Pipeline
Validation, logging, and error handling are injected as behaviors.
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(CreateOrderHandler).Assembly));
builder.Services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(ValidationBehavior<,>));
builder.Services.AddTransient(
typeof(IPipelineBehavior<,>),
typeof(ExceptionHandlingBehavior<,>));
Architecture Decisions and Rationale
- Vertical Slices over Layers: Layered architectures force changes to ripple across controllers, services, and repositories. Vertical slices isolate use cases, reducing merge conflicts and dependency churn.
- MediatR for Routing: Decouples HTTP transport from business logic. Handlers remain framework-agnostic, enabling reuse in background workers, CLI tools, or gRPC services.
- Pipeline Behaviors over Middleware: Middleware operates at the HTTP pipeline level, making it difficult to scope validation or logging to specific endpoints. Pipeline behaviors attach to requests, providing explicit, testable boundaries.
- Explicit Contracts: Records enforce immutability and structural equality. They integrate cleanly with OpenAPI generation, client SDKs, and static analyzers.
Pitfall Guide
1. Fat Endpoints
Embedding business logic, data access, and validation directly in MapPost or controller methods creates untestable code. Fix: Extract logic into handlers. Endpoints should only route, map, and return HTTP results.
2. Over-Abstraction
Creating IUserService, UserServiceBase, UserServiceDecorator, and UserServiceFactory for simple CRUD operations adds cognitive overhead without measurable benefit. Fix: Apply abstractions only when multiple implementations exist or when cross-cutting concerns require interception.
3. Silent Validation Failures
Relying on ModelState.IsValid without mapping errors to a consistent response structure leaves clients guessing. Fix: Use FluentValidation with a pipeline behavior that transforms ValidationResult into a standardized ProblemDetails payload.
4. Mixed Concerns in Handlers
Injecting ILogger, IHttpContextAccessor, or IConfiguration into handlers couples business logic to infrastructure. Fix: Keep handlers pure. Move logging, configuration reads, and HTTP context access to pipeline behaviors or endpoint middleware.
5. Inconsistent Error Modeling
Returning raw exceptions, strings, or custom objects across endpoints breaks client SDK generation and monitoring pipelines. Fix: Enforce a single error contract (ProblemDetails or custom ApiError) via a global exception handler pipeline behavior.
6. N+1 Query Patterns
Loading related entities inside loops or without eager loading causes database round-trip explosions. Fix: Use repository methods that project directly to DTOs, or leverage EF Core Include/Select at the query boundary. Profile queries early in development.
7. Versioning Without Backward Compatibility
Breaking changes in v2 without deprecation windows cause client outages. Fix: Implement URL or header-based versioning with explicit compatibility matrices. Mark obsolete endpoints with ObsoleteAttribute and monitor usage via telemetry before removal.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-frequency CRUD operations | Minimal API + Repository | Low overhead, fast implementation, predictable performance | Low initial, medium maintenance |
| Complex business workflows | CQRS + MediatR | Separates read/write models, enables event sourcing, scales independently | High initial, low long-term |
| Multi-tenant SaaS platform | Vertical Slice + Policy-Based Auth | Isolates tenant context, reduces cross-tenant leakage risk | Medium initial, high reliability |
| Legacy migration | Anti-Corruption Layer + Facade | Protects new code from legacy data models, enables incremental rollout | High initial, reduces migration risk |
| Real-time event processing | MediatR + Background Workers | Reuses domain handlers, avoids duplication across HTTP and queue pipelines | Low incremental, high consistency |
Configuration Template
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ExceptionHandlingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TelemetryBehavior<,>));
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.MapOpenApi();
app.MapControllers(); // Only if legacy controllers exist
app.MapGroup("/api/v1")
.WithOpenApi()
.MapCreateOrder()
.MapGetOrder();
app.Run();
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ApiBehavior": {
"SuppressModelStateInvalidFilter": true,
"InvalidModelStateResponseFactory": "ProblemDetails"
},
"Telemetry": {
"EnableCorrelationId": true,
"EnableBusinessMetrics": true
}
}
Quick Start Guide
- Install packages:
dotnet add package MediatR, dotnet add package FluentValidation, dotnet add package FluentValidation.AspNetCore
- Create a
Features/ directory and add a CreateOrder.cs file containing the request record, validator, and handler
- Register MediatR and pipeline behaviors in
Program.cs, then map the endpoint using app.MapPost()
- Run
dotnet run and verify OpenAPI output at /swagger/index.html
- Send a test request with
curl -X POST http://localhost:5000/api/v1/orders -H "Content-Type: application/json" -d '{"userId":"...","items":[],"address":{}}' and observe validation/error handling in action