As applications grow from simple prototypes into mission-critical enterprise systems, architectural decay becomes one of the greatest threats to software longevity. Traditional ASP.NET Core templates encourage "fat controllers," tightly coupled database contexts, and scattered validation logic that make systems difficult to test, maintain, and scale.
To build robust, production-ready APIs, elite software engineers combine five powerful patterns into a cohesive stack:
Clean Architecture: Enforces strict boundaries and inward dependencies.
CQRS (Command Query Responsibility Segregation): Separates read operations from write mutations.
MediatR: Decouples controllers from business logic via in-process messaging.
FluentValidation & Pipeline Behaviors: Automates validation seamlessly before execution.
Global Exception Handling (
IExceptionHandler& ProblemDetails): Transforms unhandled errors into standardized, machine-readable JSON responses.
In this comprehensive, end-to-end masterclass, we will tie all these pieces together into a unified architectural blueprint.
Part 1: The Architectural Foundation (Clean Architecture)
Clean Architecture ensures that your core business logic remains independent of external frameworks, databases, and UI concerns. Dependencies always point inward.
Solution Structure
Plaintext
EnterpriseSolution.sln
│
├── src/
│ ├── EnterpriseSolution.Domain/ # Core entities, value objects, & repository interfaces
│ ├── EnterpriseSolution.Application/ # Use cases, CQRS handlers, DTOs, & validators
│ ├── EnterpriseSolution.Infrastructure/ # EF Core DbContext, migrations, & external services
│ └── EnterpriseSolution.API/ # Controllers, middleware, Swagger, & Program.cs
│
└── tests/
├── EnterpriseSolution.UnitTests/
└── EnterpriseSolution.IntegrationTests/
Domain Layer: Zero dependencies. Pure C# business rules.
Application Layer: Depends only on Domain. Contains Commands, Queries, and business use cases.
Infrastructure Layer: Implements Domain/Application interfaces (e.g., SQL Server, EF Core).
API Layer: The entry point. Wires everything together and handles HTTP requests.
Part 2: Decoupling Operations with CQRS & MediatR
Instead of massive repository services handling every operation, CQRS splits actions into Commands (writes) and Queries (reads), dispatched via MediatR.
1. Defining a Command
C#
using MediatR;
namespace EnterpriseSolution.Application.Features.Products.Commands
{
public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;
}
2. The Command Handler
C#
using MediatR;
using EnterpriseSolution.Domain.Entities;
using EnterpriseSolution.Domain.Interfaces;
namespace EnterpriseSolution.Application.Features.Products.Commands
{
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, Guid>
{
private readonly IProductRepository _productRepository;
public CreateProductCommandHandler(IProductRepository productRepository)
{
_productRepository = productRepository;
}
public async Task<Guid> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
var product = new Product(request.Name, request.Price);
await _productRepository.AddAsync(product);
await _productRepository.UnitOfWork.SaveChangesAsync(cancellationToken);
return product.Id;
}
}
}
Part 3: Automating Validation with FluentValidation & Pipeline Behaviors
Rather than cluttering handlers with manual validation checks, we pair FluentValidation with a MediatR Pipeline Behavior to intercept and validate requests automatically.
1. The Validator
C#
using FluentValidation;
using EnterpriseSolution.Application.Features.Products.Commands;
namespace EnterpriseSolution.Application.Features.Products.Validators
{
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
public CreateProductCommandValidator()
{
RuleFor(p => p.Name)
.NotEmpty().WithMessage("Product name is required.")
.MaximumLength(100).WithMessage("Product name cannot exceed 100 characters.");
RuleFor(p => p.Price)
.GreaterThan(0).WithMessage("Product price must be greater than zero.");
}
}
}
2. The Validation Pipeline Behavior
C#
using FluentValidation;
using MediatR;
namespace EnterpriseSolution.Application.Common.Behaviors
{
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();
if (failures.Count != 0)
{
throw new ValidationException(failures);
}
}
return await next();
}
}
}
Part 4: Standardized Error Responses via Global Exception Handling
When exceptions (such as validation failures or missing resources) occur, we catch them globally using .NET's IExceptionHandler and return a standardized RFC 7807 ProblemDetails JSON response.
C#
using FluentValidation;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace EnterpriseSolution.API.Middleware
{
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
var problemDetails = new ProblemDetails
{
Instance = httpContext.Request.Path
};
if (exception is ValidationException validationException)
{
httpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
problemDetails.Status = StatusCodes.Status400BadRequest;
problemDetails.Title = "Validation Error";
problemDetails.Detail = "One or more validation failures have occurred.";
problemDetails.Extensions["errors"] = validationException.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
}
else
{
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
problemDetails.Status = StatusCodes.Status500InternalServerError;
problemDetails.Title = "Internal Server Error";
problemDetails.Detail = "An unexpected error occurred.";
}
httpContext.Response.ContentType = "application/problem+json";
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
}
Part 5: Bringing It All Together (Program.cs)
By encapsulating service registrations inside extension methods for each layer, your API's entry point remains clean, readable, and elegant.
C#
using EnterpriseSolution.API.Middleware;
using EnterpriseSolution.Application;
using EnterpriseSolution.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// 1. Register Layer Services Cleanly
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// 2. Register Global Exception Handling & ProblemDetails
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// 3. Configure Middleware Pipeline
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseExceptionHandler(); // Must be early in the pipeline
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Summary
By unifying Clean Architecture, CQRS, MediatR, FluentValidation, and Global Exception Handling, you establish a world-class enterprise foundation:
Razor-Thin Controllers: Controllers only dispatch requests via MediatR.
Isolate Business Rules: Handlers and validators focus purely on business logic.
Predictable Error Contracts: Clients always receive clean, standardized
ProblemDetailsresponses.

Join the conversation! Your thoughts help the community grow.