Come si implementa Clean Architecture in un progetto .NET reale?
In pratica questo si traduce in una solution con quattro progetti separati:
Domain, Application, Infrastructure e Presentation. Il confine tra Application e Infrastructure è implementato tramite interfacce: l'Application dichiara di cosa ha bisogno, l'Infrastructure lo implementa.Il risultato è codice testabile senza database reali, logica di business che non cambia quando cambia l'ORM, e un'architettura che comunica la sua struttura a chi legge il codice. I dettagli implementativi con struttura reale delle cartelle sono nell'articolo.

La Clean Architecture in C# è uno degli argomenti più citati e meno compresi nel mondo .NET. Se ne parla in ogni conferenza, ogni articolo senior la menziona come standard, ma i progetti reali spesso ne presentano versioni distorte che ne mantengono la complessità senza i benefici. Troppe astrazioni nel posto sbagliato, interfacce inutili, handler da 200 righe con logica di business che non appartiene lì.
Ci sono due errori opposti e ugualmente dannosi. Il primo: ignorare l'architettura completamente, finendo con controller da 500 righe che chiamano direttamente il database, logica di business incastrata nei DTO, e unit test impossibili senza spin-up dell'intero stack. Il secondo: applicare la Clean Architecture meccanicamente, creando layer su layer per ogni applicazione indipendentemente dalla sua complessità, trasformando un semplice CRUD in un sistema con dodici progetti, venti interfacce e zero benefici reali.
Questo articolo mostra come applicarla in modo pragmatico. La struttura concreta di un progetto .NET reale, il confine esatto tra i layer con codice funzionante, come integrare CQRS con MediatR, come testare ogni layer in isolamento, e quando ha senso usarla e quando invece è overkill. Il tutto senza dogmi e con esempi tratti da sistemi in produzione.
Se sei un developer .NET che vuole strutturare i propri progetti in modo che resistano alla scala e al cambiamento nel tempo, questo articolo è scritto per te. Se sei un architect che deve convincere il team ad adottare un'architettura più solida, qui troverai anche gli argomenti per farlo.
La regola fondamentale della Clean Architecture: le dipendenze puntano verso il centro
Tutta la Clean Architecture si riduce a una sola regola, espressa nel modo più semplice possibile: il codice negli strati interni non dipende da quello negli strati esterni. Il dominio non conosce Entity Framework. La logica applicativa non conosce ASP.NET. I dettagli di infrastruttura dipendono dal core, non viceversa.
Perché questa regola è così importante? Perché rompe il problema più comune nei sistemi enterprise che crescono nel tempo: la logica di business che si impasta progressivamente con i dettagli implementativi fino a diventare impossibile da testare, da modificare e da capire. Quando Entity Framework cambia versione, non vuoi dover toccare le tue regole di business. Quando passi da REST a gRPC, non vuoi che la logica di dominio debba essere riscritta.
Quando il tuo domain model importa Microsoft.EntityFrameworkCore, hai già perso la partita. Stai scrivendo logica di business che non puoi testare senza un database reale.
Il modello a layer concentrici di Robert C. Martin è la visualizzazione più chiara di questa regola. Al centro ci sono le Entities (il domain model puro), poi i Use Cases (la logica applicativa), poi gli Interface Adapters (controller, presenter, gateway), e all'esterno i Frameworks and Drivers (database, UI, web framework). Le frecce delle dipendenze puntano tutte verso il centro.
La realizzazione pratica di questa regola in .NET è la Dependency Inversion: gli strati interni dichiarano interfacce che gli strati esterni implementano. L'Application layer non usa direttamente SqlOrdiniRepository: usa IOrdiniRepository, un'interfaccia che vive in Application e che Infrastructure implementa. Questo è il modo in cui il centro "comanda" senza conoscere i dettagli.
La struttura concreta della solution: quattro progetti con dipendenze esplicite
Il modo più chiaro di implementare Clean Architecture in .NET è dividere la solution in quattro progetti con dipendenze esplicite dichiarate nei file .csproj. Non è l'unico modo di strutturarla, ma è il più leggibile e il più facile da mantenere nel tempo.
MiaApp.sln
├── MiaApp.Domain/ → nessuna dipendenza NuGet, zero riferimenti esterni
├── MiaApp.Application/ → dipende solo da Domain
├── MiaApp.Infrastructure/ → dipende da Application (e indirettamente da Domain)
└── MiaApp.Api/ → dipende da Application e Infrastructure (solo per DI)La dipendenza tra i progetti è dichiarata nei file .csproj e verificata dal compilatore: se Infrastructure prova a dipendere direttamente da Api, il progetto non compila. Non è una convenzione che ci si può dimenticare di rispettare: è un vincolo strutturale del build system.
Le cartelle interne di ogni progetto
La struttura interna di ogni progetto segue una convenzione consolidata per chi lavora con questo approccio:
MiaApp.Domain/
├── Entities/ → Ordine.cs, Cliente.cs, Prodotto.cs
├── ValueObjects/ → Prezzo.cs, IndirizzoSpedizione.cs
├── Enums/ → StatoOrdine.cs, TipoCliente.cs
├── Exceptions/ → OrdineNonModificabileException.cs
└── Events/ → OrdinConfermato.cs (domain events)
MiaApp.Application/
├── Ordini/
│ ├── Commands/ → ConfermaOrdineCommand.cs + Handler
│ ├── Queries/ → GetOrdineByIdQuery.cs + Handler
│ └── DTOs/ → OrdineDto.cs, RigaOrdineDto.cs
├── Interfaces/ → IOrdiniRepository.cs, INotificheService.cs
└── Behaviors/ → ValidationBehavior.cs, LoggingBehavior.cs
MiaApp.Infrastructure/
├── Persistence/
│ ├── AppDbContext.cs
│ ├── Configurations/ → OrdineConfiguration.cs (Fluent API)
│ └── Repositories/ → OrdiniRepository.cs
├── Notifications/ → EmailNotificheService.cs
└── DependencyInjection.cs → extension method per registrare i servizi
MiaApp.Api/
├── Controllers/ → OrdiniController.cs
├── Program.cs
└── appsettings.jsonLa cartella Behaviors in Application è dove vivono i cross-cutting concern come la validazione con FluentValidation e il logging delle operazioni. Con MediatR questi comportamenti vengono registrati come pipeline behavior e applicati automaticamente a tutti i command e query.
Il layer Domain: entità, value object e logica pura senza dipendenze
Il progetto Domain è il cuore dell'applicazione. Contiene le entità di business, i value object, le domain exception, gli enum di dominio, e i domain event. Nessuna dipendenza NuGet. Nessun riferimento a framework. Se qualcuno aggiunge una dipendenza esterna al progetto Domain, è una violazione architetturale che deve essere discussa.
La caratteristica più importante delle entità di dominio è che contengono comportamento, non solo dati. Un'entità che espone solo getter e setter e delega tutta la logica ai service è un Anemic Domain Model: tecnicamente non viola la Clean Architecture, ma perde uno dei suoi benefici principali, ovvero la capacità di ragionare sul comportamento del dominio in modo isolato.
// MiaApp.Domain/Entities/Ordine.cs
public class Ordine
{
public Guid Id { get; private set; }
public Guid ClienteId { get; private set; }
public StatoOrdine Stato { get; private set; }
public DateTime CreatedAt { get; private set; }
public IReadOnlyList<RigaOrdine> Righe => _righe.AsReadOnly();
private readonly List<RigaOrdine> _righe = new();
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Ordine() { } // Per EF Core (configurato in Infrastructure)
public static Ordine Crea(Guid clienteId)
{
return new Ordine
{
Id = Guid.NewGuid(),
ClienteId = clienteId,
Stato = StatoOrdine.Bozza,
CreatedAt = DateTime.UtcNow
};
}
public void AggiungiRiga(Guid prodottoId, int quantita, decimal prezzoUnitario)
{
if (Stato != StatoOrdine.Bozza)
throw new OrdineNonModificabileException(Id, Stato);
if (quantita <= 0)
throw new ArgumentException("La quantità deve essere positiva", nameof(quantita));
_righe.Add(new RigaOrdine(prodottoId, quantita, prezzoUnitario));
}
public void Conferma()
{
if (!_righe.Any())
throw new OrdineVuotoException(Id);
if (Stato != StatoOrdine.Bozza)
throw new OrdineNonModificabileException(Id, Stato);
Stato = StatoOrdine.Confermato;
_domainEvents.Add(new OrdineConfermato(Id, ClienteId, _righe.Sum(r => r.Totale)));
}
public decimal TotaleOrdine => _righe.Sum(r => r.Totale);
}Nota come il domain model usi factory method statici invece di costruttori pubblici per controllare le invarianti di creazione. Le eccezioni sono domain-specific e significative per il dominio. Il calcolo del totale vive nell'entità. I Domain Event vengono raccolti nell'entità e pubblicati dall'infrastructure dopo il salvataggio.
I Value Object: quando l'identità non conta
Un Value Object è un oggetto definito dai suoi valori, non da un'identità. Due Prezzo con lo stesso importo e valuta sono identici. Due Ordine con gli stessi dati sono comunque due ordini distinti perché hanno ID diversi.
// MiaApp.Domain/ValueObjects/Prezzo.cs
public record Prezzo
{
public decimal Importo { get; }
public string Valuta { get; }
public Prezzo(decimal importo, string valuta)
{
if (importo < 0)
throw new ArgumentException("Il prezzo non può essere negativo", nameof(importo));
if (string.IsNullOrEmpty(valuta))
throw new ArgumentException("La valuta è obbligatoria", nameof(valuta));
Importo = importo;
Valuta = valuta.ToUpperInvariant();
}
public Prezzo Applica(decimal percentualeSconto)
{
if (percentualeSconto is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(percentualeSconto));
return new Prezzo(Importo * (1 - percentualeSconto / 100), Valuta);
}
}Usare record in C# per i Value Object è quasi sempre la scelta giusta: l'equality semantica è implementata automaticamente, l'immutabilità è naturale, e il codice è più conciso.
Il layer Application: use case come orchestratori con CQRS e MediatR
Il layer Application contiene i casi d'uso dell'applicazione: le operazioni che il sistema espone al mondo esterno. Non contiene logica di business (quella sta nel Domain), non contiene dettagli di infrastruttura (quelli stanno in Infrastructure). Contiene l'orchestrazione: carica il dominio, chiama le operazioni di dominio, salva, notifica.
Il pattern CQRS (Command Query Responsibility Segregation) si integra naturalmente in questo layer. Le operazioni di scrittura diventano Command, quelle di lettura diventano Query, ognuna con il proprio handler. MediatR è la libreria di fatto per implementare questo pattern in .NET: gestisce la registrazione degli handler, i pipeline behavior, e la pubblicazione dei notification.
Command e Handler per le operazioni di scrittura
// MiaApp.Application/Ordini/Commands/ConfermaOrdineCommand.cs
public record ConfermaOrdineCommand(Guid OrdineId, Guid OperatoreId)
: IRequest<ConfermaOrdineResult>;
public record ConfermaOrdineResult(Guid OrdineId, StatoOrdine Stato, DateTime ConfermatoAt);
// MiaApp.Application/Ordini/Commands/ConfermaOrdineHandler.cs
public class ConfermaOrdineHandler
: IRequestHandler<ConfermaOrdineCommand, ConfermaOrdineResult>
{
private readonly IOrdiniRepository _repository;
private readonly INotificheService _notifiche;
private readonly ILogger<ConfermaOrdineHandler> _logger;
public ConfermaOrdineHandler(
IOrdiniRepository repository,
INotificheService notifiche,
ILogger<ConfermaOrdineHandler> logger)
{
_repository = repository;
_notifiche = notifiche;
_logger = logger;
}
public async Task<ConfermaOrdineResult> Handle(
ConfermaOrdineCommand command,
CancellationToken ct)
{
var ordine = await _repository.GetByIdAsync(command.OrdineId, ct)
?? throw new OrdineNotFoundException(command.OrdineId);
ordine.Conferma(); // La logica di business vive nell'entità, non qui
await _repository.SaveAsync(ordine, ct);
await _notifiche.InviaConfermaAsync(ordine.ClienteId, ordine.Id, ct);
_logger.LogInformation(
"Ordine {OrdineId} confermato dall'operatore {OperatoreId}",
ordine.Id, command.OperatoreId);
return new ConfermaOrdineResult(ordine.Id, ordine.Stato, DateTime.UtcNow);
}
}Query e Handler per le operazioni di lettura
Le query spesso non devono passare attraverso il domain model: caricare un aggregato completo solo per proiettarne una parte è inefficiente. Per le query, si può accedere direttamente al database con query ottimizzate, restituendo DTO senza passare per le entità.
// MiaApp.Application/Ordini/Queries/GetOrdineByIdQuery.cs
public record GetOrdineByIdQuery(Guid OrdineId) : IRequest<OrdineDto?>;
// MiaApp.Application/Ordini/Queries/GetOrdineByIdHandler.cs
public class GetOrdineByIdHandler : IRequestHandler<GetOrdineByIdQuery, OrdineDto?>
{
private readonly IOrdiniReadRepository _readRepository;
public GetOrdineByIdHandler(IOrdiniReadRepository readRepository)
=> _readRepository = readRepository;
public async Task<OrdineDto?> Handle(
GetOrdineByIdQuery query,
CancellationToken ct)
=> await _readRepository.GetDtoByIdAsync(query.OrdineId, ct);
}Pipeline Behavior: cross-cutting concern senza boilerplate
I MediatR Pipeline Behavior permettono di aggiungere comportamenti trasversali (validazione, logging, caching, autorizzazione) a tutti i command e query senza toccare ogni singolo handler.
// MiaApp.Application/Behaviors/ValidationBehavior.cs
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
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 ct)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(e => e != null)
.ToList();
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}Il layer Infrastructure: implementazioni concrete di EF Core, email e messaggistica
Infrastructure implementa le interfacce definite in Application con le tecnologie specifiche scelte: Entity Framework Core, Dapper per le query di lettura ottimizzate, SendGrid per le email, Azure Service Bus per la messaggistica asincrona. Questo è il layer dove si configura tutto ciò che il dominio non deve sapere.
La regola fondamentale: Infrastructure dipende da Application (per implementare le interfacce), ma Application non dipende da Infrastructure. In Program.cs i due si connettono tramite la Dependency Injection.
Repository con Entity Framework Core
// MiaApp.Infrastructure/Persistence/Repositories/OrdiniRepository.cs
public class OrdiniRepository : IOrdiniRepository
{
private readonly AppDbContext _context;
public OrdiniRepository(AppDbContext context) => _context = context;
public async Task<Ordine?> GetByIdAsync(Guid id, CancellationToken ct) =>
await _context.Ordini
.Include(o => o.Righe)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task SaveAsync(Ordine ordine, CancellationToken ct)
{
_context.Update(ordine);
await _context.SaveChangesAsync(ct);
}
public async Task AddAsync(Ordine ordine, CancellationToken ct)
{
await _context.Ordini.AddAsync(ordine, ct);
await _context.SaveChangesAsync(ct);
}
}Qui Entity Framework Core è libero di fare quello che vuole: attributi di mapping, configurazioni Fluent API, query ottimizzate con AsNoTracking, lazy loading o eager loading a seconda del caso. Il domain model non ne sa nulla. Se un giorno si decide di passare da EF a Dapper o a un database diverso, si riscrive solo questo layer, non il dominio.
Configurazione Fluent API separata dal domain model
// MiaApp.Infrastructure/Persistence/Configurations/OrdineConfiguration.cs
public class OrdineConfiguration : IEntityTypeConfiguration<Ordine>
{
public void Configure(EntityTypeBuilder<Ordine> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Stato)
.HasConversion<string>()
.HasMaxLength(50);
builder.OwnsMany(o => o.Righe, riga =>
{
riga.WithOwner();
riga.Property(r => r.PrezzoUnitario).HasPrecision(18, 2);
});
// Il costruttore privato è accessibile tramite reflection
builder.Metadata
.FindNavigation(nameof(Ordine.Righe))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}Query read-optimized con Dapper
Per le operazioni di lettura che non devono passare per il domain model (dashboard, report, liste paginate), Dapper permette query SQL ottimizzate con mapping diretto su DTO:
// MiaApp.Infrastructure/Persistence/Repositories/OrdiniReadRepository.cs
public class OrdiniReadRepository : IOrdiniReadRepository
{
private readonly IDbConnection _connection;
public OrdiniReadRepository(IDbConnection connection)
=> _connection = connection;
public async Task<OrdineDto?> GetDtoByIdAsync(Guid id, CancellationToken ct)
{
const string sql = @"
SELECT o.Id, o.ClienteId, o.Stato, o.CreatedAt,
r.ProdottoId, r.Quantita, r.PrezzoUnitario
FROM Ordini o
LEFT JOIN RigheOrdine r ON r.OrdineId = o.Id
WHERE o.Id = @Id";
var result = await _connection.QueryAsync<OrdineDto, RigaOrdineDto, OrdineDto>(
sql,
(ordine, riga) =>
{
ordine.Righe ??= new List<RigaOrdineDto>();
ordine.Righe.Add(riga);
return ordine;
},
new { Id = id },
splitOn: "ProdottoId");
return result.FirstOrDefault();
}
}Extension method per la registrazione della DI
// MiaApp.Infrastructure/DependencyInjection.cs
public static class InfrastructureDependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("Default")));
services.AddScoped<IOrdiniRepository, OrdiniRepository>();
services.AddScoped<IOrdiniReadRepository, OrdiniReadRepository>();
services.AddScoped<INotificheService, EmailNotificheService>();
return services;
}
}Il layer Presentation: Minimal API, controller e configurazione della DI
Il layer Presentation (il progetto MiaApp.Api) è responsabile di ricevere le richieste HTTP, validare il formato dell'input, delegare l'elaborazione all'Application tramite MediatR, e restituire la risposta formattata correttamente. Non contiene logica di business: quella sta nel Domain. Non contiene logica di orchestrazione: quella sta in Application.
Minimal API con MediatR
// MiaApp.Api/Endpoints/OrdiniEndpoints.cs
public static class OrdiniEndpoints
{
public static WebApplication MapOrdiniEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/ordini")
.WithTags("Ordini")
.RequireAuthorization();
group.MapGet("/{id:guid}", async (
Guid id,
IMediator mediator,
CancellationToken ct) =>
{
var result = await mediator.Send(new GetOrdineByIdQuery(id), ct);
return result is null
? Results.NotFound()
: Results.Ok(result);
})
.WithName("GetOrdineById")
.Produces<OrdineDto>()
.ProducesProblem(StatusCodes.Status404NotFound);
group.MapPost("/{id:guid}/conferma", async (
Guid id,
ConfermaOrdineRequest request,
IMediator mediator,
CancellationToken ct) =>
{
var command = new ConfermaOrdineCommand(id, request.OperatoreId);
var result = await mediator.Send(command, ct);
return Results.Ok(result);
})
.WithName("ConfermaOrdine")
.Produces<ConfermaOrdineResult>();
return app;
}
}Program.cs: dove tutto si connette
// MiaApp.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);
// Application layer
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(ConfermaOrdineHandler).Assembly);
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssembly(typeof(ConfermaOrdineCommand).Assembly);
// Infrastructure layer
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.MapOrdiniEndpoints();
app.UseSwagger();
app.UseSwaggerUI();
app.Run();Nota come Program.cs conosca sia Application che Infrastructure (necessario per la registrazione della DI), ma la dipendenza di Api su Infrastructure è accettata solo qui, nella radice di composizione. I controller e gli endpoint non importano mai tipi di Infrastructure direttamente.
Testing della Clean Architecture: unit test del Domain, integration test dell'Infrastructure
Uno dei benefici principali della Clean Architecture è la testabilità: ogni layer può essere testato in isolamento con la strategia appropriata. La piramide di test risultante ha una base larga di unit test veloci e una punta stretta di integration test più lenti.
Unit test del Domain layer: veloci e senza dipendenze
I test del Domain layer sono i più semplici e i più veloci da scrivere. Nessun mock, nessuna configurazione, nessun database. Si istanzia l'entità, si chiama il metodo, si verifica lo stato o l'eccezione attesa.
// MiaApp.Domain.Tests/Entities/OrdineTests.cs
public class OrdineTests
{
[Fact]
public void Conferma_OrdineVuoto_LanciaEccezione()
{
var ordine = Ordine.Crea(Guid.NewGuid());
var act = () => ordine.Conferma();
act.Should().Throw<OrdineVuotoException>();
}
[Fact]
public void Conferma_ConRighe_CambiaStatoInConfermato()
{
var ordine = Ordine.Crea(Guid.NewGuid());
ordine.AggiungiRiga(Guid.NewGuid(), 2, 49.99m);
ordine.Conferma();
ordine.Stato.Should().Be(StatoOrdine.Confermato);
}
[Fact]
public void Conferma_ConRighe_GeneraDomainEvent()
{
var ordine = Ordine.Crea(Guid.NewGuid());
ordine.AggiungiRiga(Guid.NewGuid(), 1, 100m);
ordine.Conferma();
ordine.DomainEvents.Should().ContainSingle()
.Which.Should().BeOfType<OrdineConfermato>();
}
[Fact]
public void AggiungiRiga_OrdineConfermato_LanciaEccezione()
{
var ordine = Ordine.Crea(Guid.NewGuid());
ordine.AggiungiRiga(Guid.NewGuid(), 1, 50m);
ordine.Conferma();
var act = () => ordine.AggiungiRiga(Guid.NewGuid(), 1, 25m);
act.Should().Throw<OrdineNonModificabileException>();
}
}Questi test girano in millisecondi. Una suite completa di 500 test del domain layer si esegue in meno di cinque secondi. La velocità dei test è un fattore critico per il ritmo di sviluppo: test lenti vengono eseguiti meno frequentemente, riducendo il feedback loop.
Test del layer Application: mock delle interfacce
// MiaApp.Application.Tests/Ordini/ConfermaOrdineHandlerTests.cs
public class ConfermaOrdineHandlerTests
{
private readonly Mock<IOrdiniRepository> _repositoryMock = new();
private readonly Mock<INotificheService> _notificheMock = new();
private readonly Mock<ILogger<ConfermaOrdineHandler>> _loggerMock = new();
[Fact]
public async Task Handle_OrdineEsistente_ConfermaENotifica()
{
var ordine = Ordine.Crea(Guid.NewGuid());
ordine.AggiungiRiga(Guid.NewGuid(), 1, 100m);
_repositoryMock
.Setup(r => r.GetByIdAsync(ordine.Id, It.IsAny<CancellationToken>()))
.ReturnsAsync(ordine);
var handler = new ConfermaOrdineHandler(
_repositoryMock.Object,
_notificheMock.Object,
_loggerMock.Object);
var result = await handler.Handle(
new ConfermaOrdineCommand(ordine.Id, Guid.NewGuid()),
CancellationToken.None);
result.Stato.Should().Be(StatoOrdine.Confermato);
_repositoryMock.Verify(r => r.SaveAsync(ordine, It.IsAny<CancellationToken>()), Times.Once);
_notificheMock.Verify(n => n.InviaConfermaAsync(
ordine.ClienteId, ordine.Id, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_OrdineNonTrovato_LanciaOrdineNotFoundException()
{
_repositoryMock
.Setup(r => r.GetByIdAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((Ordine?)null);
var handler = new ConfermaOrdineHandler(
_repositoryMock.Object,
_notificheMock.Object,
_loggerMock.Object);
var act = async () => await handler.Handle(
new ConfermaOrdineCommand(Guid.NewGuid(), Guid.NewGuid()),
CancellationToken.None);
await act.Should().ThrowAsync<OrdineNotFoundException>();
}
}Integration test del layer Infrastructure: Testcontainers
I test di Infrastructure richiedono un database reale. Nel 2026 la soluzione migliore è Testcontainers for .NET: avvia un container Docker con SQL Server (o qualsiasi altro database) durante i test e lo distrugge alla fine, garantendo test isolati e ripetibili in qualsiasi ambiente, incluse le pipeline CI/CD.
// MiaApp.Infrastructure.Tests/Persistence/OrdiniRepositoryTests.cs
public class OrdiniRepositoryTests : IAsyncLifetime
{
private readonly MsSqlContainer _sqlContainer = new MsSqlBuilder().Build();
private AppDbContext _context = null!;
public async Task InitializeAsync()
{
await _sqlContainer.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(_sqlContainer.GetConnectionString())
.Options;
_context = new AppDbContext(options);
await _context.Database.MigrateAsync();
}
[Fact]
public async Task SaveAsync_NuovoOrdine_PersisteSulDatabase()
{
var ordine = Ordine.Crea(Guid.NewGuid());
ordine.AggiungiRiga(Guid.NewGuid(), 2, 49.99m);
var repository = new OrdiniRepository(_context);
await repository.AddAsync(ordine, CancellationToken.None);
var found = await repository.GetByIdAsync(ordine.Id, CancellationToken.None);
found.Should().NotBeNull();
found!.Righe.Should().HaveCount(1);
}
public async Task DisposeAsync() => await _sqlContainer.DisposeAsync();
}Errori comuni nell'implementare Clean Architecture in C#: le trappole più frequenti
Dopo anni di code review su progetti .NET che dichiarano di usare Clean Architecture, emergono pattern di errore ricorrenti. Conoscerli è il modo più rapido per evitarli.
Anemic Domain Model: la logica finisce negli handler
L'errore più comune e più costoso nel lungo termine. Le entità di dominio diventano semplici contenitori di dati con getter e setter, e tutta la logica di business migra negli handler di Application. Il risultato è un Application layer che fa troppo, un Domain layer che non fa niente, e test di Application che devono testare la logica di business invece di testare l'orchestrazione.
Il segnale d'allarme: un handler che prende decisioni basate sullo stato dell'entità invece di delegarle all'entità stessa. Se scrivi if (ordine.Stato == StatoOrdine.Bozza) ordine.Stato = StatoOrdine.Confermato in un handler, stai bypassando il dominio.
Leaking di tipi di Infrastructure nel Domain o Application
Trovare using Microsoft.EntityFrameworkCore in un file del Domain layer è una violazione architetturale diretta. Più subdolo è trovare attributi EF ([Key], [Required]) sulle entità di dominio: tecnicamente funziona, ma accoppia il domain model al framework di persistenza.
Usa la Fluent API di EF Core nel layer Infrastructure, mai attributi nel Domain. Per verificare automaticamente le violazioni, considera l'adozione di ArchUnitNET: una libreria per scrivere test che verificano le regole architetturali.
Interfacce ovunque anche dove non servono
Non ogni classe ha bisogno di un'interfaccia. Le interfacce in Application hanno senso per i repository, per i servizi di notifica, per qualsiasi dipendenza esterna che si vuole poter mockare nei test. Non ha senso creare IConfermaOrdineHandler solo per avere l'interfaccia: MediatR gestisce la risoluzione tramite il tipo del command, non tramite interfacce degli handler.
Il criterio per creare un'interfaccia in Application è: ho bisogno di mockare questa dipendenza nei test di Application? Se sì, l'interfaccia ha senso. Altrimenti no.
DTO mal posizionati e trasformazioni eccessive
I DTO per la comunicazione con la UI vivono in Application o Presentation, mai in Domain. AutoMapper è utile per trasformazioni ripetitive, ma il suo abuso porta a mappings ingestibili dove non è più chiaro cosa viene trasformato in cosa. Per sistemi con trasformazioni semplici, la mappatura esplicita è spesso più leggibile.
Ignorare i bounded context in sistemi complessi
Clean Architecture definisce la struttura verticale (layer), ma non dice come dividere orizzontalmente il sistema tra diversi sotto-domini. In sistemi complessi, applicare Clean Architecture senza considerare i bounded context di DDD porta a un domain model monolitico che si accresce indefinitamente. Per approfondire come combinare Clean Architecture con DDD, l'articolo su come diventare Software Architect fornisce il contesto più ampio.
Modular Monolith vs Clean Architecture vs Microservizi: quando scegliere cosa
Una delle domande più frequenti di chi si avvicina all'architettura è: devo usare Clean Architecture, un Modular Monolith o i Microservizi? La risposta corretta è: dipende dal problema, non dalla preferenza tecnologica. Ecco come ragionare.
Clean Architecture: per dominio complesso, team medio, vita lunga
Clean Architecture ha senso quando la logica di business è complessa e ha regole non banali, quando il progetto durerà anni e il team cambierà, quando i test automatizzati della logica di business sono prioritari, e quando c'è la possibilità concreta di cambiare tecnologia di persistenza o delivery nel tempo. Tipicamente adatta per applicazioni enterprise con dominio ricco.
Modular Monolith: il compromesso intelligente per molti casi
Il Modular Monolith è un'architettura spesso ignorata che risolve il 70% dei problemi con il 30% della complessità. È un singolo processo (monolite) diviso in moduli con confini espliciti e comunicazione controllata. I moduli condividono il database ma non l'accesso diretto agli altri moduli: comunicano tramite interfacce pubbliche o eventi interni.
Ha senso quando il sistema non ha requisiti di deployment indipendente tra componenti, quando i team sono piccoli (meno di 8-10 persone per modulo), e quando si vuole la separazione delle responsabilità senza l'overhead operativo dei microservizi. Molti sistemi che oggi usano microservizi sarebbero più semplici e più manutenibili come modular monolith. L'articolo su microservizi vs monolite approfondisce questo trade-off.
Microservizi: per team grandi, deployment indipendente, scale diversa
I microservizi hanno senso quando i team che lavorano su diverse parti del sistema sono abbastanza grandi e indipendenti da giustificare i costi operativi, quando parti diverse del sistema hanno requisiti di scalabilità radicalmente diversi, e quando il deployment indipendente dei servizi è un requisito reale, non solo un'aspirazione. Per la maggior parte delle organizzazioni sotto i 50-100 developer, un Modular Monolith ben strutturato è più efficiente.
La domanda giusta da farsi
Prima di scegliere l'architettura, rispondi a queste domande: Quanta complessità di business c'è realmente nel dominio? Quanto a lungo vivrà questo sistema? Quante persone ci lavoreranno contemporaneamente? Quali sono i requisiti di deployment e scalabilità? Le risposte guidano la scelta molto meglio di qualsiasi preferenza tecnologica personale.
Casi studio reali: Clean Architecture applicata a sistemi enterprise in produzione
Tre scenari tipici in cui Clean Architecture si è rivelata la scelta giusta, e tre in cui sarebbe stata eccessiva.
Caso 1: piattaforma e-commerce B2B con regole di pricing complesse
Un sistema di e-commerce B2B con regole di pricing basate su contratti, sconti a volume, promozioni temporali e prezzi personalizzati per singolo cliente. La logica di pricing è il cuore del dominio e cambia frequentemente seguendo le politiche commerciali. Clean Architecture con un Domain layer ricco permette di testare ogni combinazione di regole in isolamento, di aggiungere nuove regole senza toccare l'infrastruttura, e di spiegare le regole di business al team commerciale attraverso il codice stesso. Risultato: 400 unit test del domain layer che girano in 2 secondi, regole di business comprensibili anche ai non-tecnici.
Caso 2: sistema di gestione documentale con workflow approvazione
Un sistema di gestione documentale con workflow di approvazione multi-step, regole di routing basate sul tipo di documento e sulla struttura organizzativa, e integrazione con tre sistemi esterni. I workflow di approvazione sono Domain entities con stato esplicito e transizioni validate. L'integrazione con i sistemi esterni è tutta in Infrastructure, sostituibile senza toccare il dominio. Quando uno dei tre sistemi esterni ha cambiato le proprie API, l'aggiornamento ha richiesto modifiche solo al layer Infrastructure corrispondente.
Quando avremmo dovuto usare qualcosa di più semplice
Un'applicazione di backoffice per la gestione degli annunci pubblicitari su una piccola piattaforma media. Operazioni principalmente CRUD, nessuna logica di business complessa, team di due sviluppatori, vita attesa del progetto di 18 mesi. La Clean Architecture ha introdotto complessità strutturale che non era giustificata dal problema: quattro progetti per operazioni che sarebbero state perfette con un controller che chiama direttamente un service che chiama direttamente il DbContext. Il refactoring verso Minimal API + Vertical Slices ha ridotto il codice del 40% senza perdere nulla di sostanziale.
Quando la Clean Architecture è overkill: le alternative più leggere
La Clean Architecture non è la risposta a tutto. Riconoscere quando è eccessiva è competenza architetturale tanto quanto saperla applicare bene.
Non ha senso quando: è un'applicazione CRUD senza logica reale (form verso database); è un prototipo o MVP che potrebbe non sopravvivere alla prima review; il team è piccolo e la velocità di sviluppo è l'unica metrica che conta a breve termine; il dominio è semplice e non cambierà significativamente.
Le alternative da considerare:
Vertical Slice Architecture: ogni feature è un file (o una cartella) che contiene tutto il necessario per quella feature: command, handler, validazione, mapping, risposta. Nessuna separazione in layer orizzontali. Veloce da sviluppare, facilmente comprensibile per le piccole feature, ma scala peggio quando la logica di business diventa complessa e condivisa tra feature.
Minimal API con un service layer semplice: per sistemi dominati da operazioni CRUD con pochissima logica, un'architettura con endpoint Minimal API e un service layer diretto su DbContext è spesso la soluzione più pragmatica. Il principio di non aggiungere complessità che non è giustificata dal problema corrente.
La maturità di un architect si misura anche dalla capacità di resistere alla tentazione di applicare l'architettura più sofisticata quando il problema non la richiede. Per approfondire questo tema e comprendere come scegliere l'architettura giusta in base al contesto, l'articolo sui pattern architetturali software offre una visione completa dei trade-off.
Validazione con FluentValidation nella Clean Architecture C#: dove va e come funziona
La domanda dove mettere la validazione è una di quelle che torna in ogni progetto che adotta Clean Architecture. La risposta sbagliata è: nel controller. La risposta corretta è: nel layer Application, agganciata al MediatR pipeline, con FluentValidation come libreria di riferimento.
I controller validano il formato dell'input (campo obbligatorio, lunghezza massima, formato email). L'Application layer valida le regole di business relative al comando specifico: "l'operatore ha i permessi per confermare questo ordine", "la quantità richiesta non supera la disponibilità in magazzino", "la data di consegna è almeno tre giorni lavorativi nel futuro". Confondere i due livelli porta a controller gonfi di logica che non dovrebbe stare lì, o a domain layer che validano regole applicative che non sono invarianti di dominio puro.
FluentValidation è la scelta standard nel mondo .NET per questo ruolo: sintassi fluida, composizione di regole, messaggi di errore localizzabili, integrazione nativa con il pipeline di MediatR. Vediamo come si costruisce un sistema di validazione completo nel layer Application.
Validator per i Command: regole composite e condizionali
Ogni Command che entra nel sistema dovrebbe avere il proprio validator. Il validator vive nella stessa cartella del Command, non in una cartella separata: la prossimità aiuta la manutenzione e rende evidente che validator e command sono un'unica unità logica.
// MiaApp.Application/Ordini/Commands/CreaOrdineCommand.cs
public record CreaOrdineCommand(
Guid ClienteId,
List<RigaOrdineInput> Righe,
DateTime? DataConsegnaRichiesta,
string? NoteInterne) : IRequest<CreaOrdineResult>;
public record RigaOrdineInput(Guid ProdottoId, int Quantita, decimal PrezzoUnitario);
// MiaApp.Application/Ordini/Commands/CreaOrdineValidator.cs
public class CreaOrdineValidator : AbstractValidator<CreaOrdineCommand>
{
private readonly IClientiRepository _clienti;
private readonly IProdottiRepository _prodotti;
public CreaOrdineValidator(
IClientiRepository clienti,
IProdottiRepository prodotti)
{
_clienti = clienti;
_prodotti = prodotti;
RuleFor(x => x.ClienteId)
.NotEmpty().WithMessage("Il cliente è obbligatorio")
.MustAsync(ClienteEsisteEAttivo)
.WithMessage("Il cliente non esiste o non è attivo");
RuleFor(x => x.Righe)
.NotEmpty().WithMessage("L'ordine deve avere almeno una riga")
.Must(r => r.Count <= 100)
.WithMessage("Un ordine non può contenere più di 100 righe");
RuleForEach(x => x.Righe).SetValidator(new RigaOrdineInputValidator());
RuleFor(x => x.DataConsegnaRichiesta)
.GreaterThan(DateTime.UtcNow.AddDays(3))
.When(x => x.DataConsegnaRichiesta.HasValue)
.WithMessage("La data di consegna deve essere almeno 3 giorni lavorativi nel futuro");
RuleFor(x => x.NoteInterne)
.MaximumLength(500)
.When(x => x.NoteInterne is not null)
.WithMessage("Le note interne non possono superare 500 caratteri");
}
private async Task<bool> ClienteEsisteEAttivo(
Guid clienteId,
CancellationToken ct)
=> await _clienti.EsisteEAttivoAsync(clienteId, ct);
}
public class RigaOrdineInputValidator : AbstractValidator<RigaOrdineInput>
{
public RigaOrdineInputValidator()
{
RuleFor(x => x.ProdottoId)
.NotEmpty().WithMessage("Il prodotto è obbligatorio");
RuleFor(x => x.Quantita)
.GreaterThan(0).WithMessage("La quantità deve essere maggiore di zero")
.LessThanOrEqualTo(9999).WithMessage("La quantità massima per riga è 9999");
RuleFor(x => x.PrezzoUnitario)
.GreaterThan(0).WithMessage("Il prezzo unitario deve essere positivo")
.PrecisionScale(18, 2, ignoreTrailingZeros: true)
.WithMessage("Il prezzo può avere al massimo due decimali");
}
}Nota l'uso di MustAsync per le regole che richiedono accesso asincrono al database (verifica esistenza cliente), e RuleForEach con un validator figlio per validare ogni elemento della lista di righe. Il validator figlio è una classe separata riutilizzabile in altri contesti dove serve validare una riga di ordine.
Il ValidationBehavior che restituisce errori strutturati invece di lanciare eccezioni
Il ValidationBehavior mostrato nella sezione precedente lancia una ValidationException in caso di errori. Questo approccio funziona ma ha un limite: l'eccezione deve essere intercettata e trasformata in una risposta HTTP strutturata da un middleware globale. Una alternativa più esplicita restituisce un risultato di errore tipizzato senza usare le eccezioni come flow control per i casi attesi.
// MiaApp.Application/Common/ValidationResult.cs
public class ValidationResult
{
public bool IsValid => !Errors.Any();
public Dictionary<string, string[]> Errors { get; } = new();
public static ValidationResult Ok() => new();
public static ValidationResult Fail(Dictionary<string, string[]> errors)
{
var result = new ValidationResult();
foreach (var (key, messages) in errors)
result.Errors[key] = messages;
return result;
}
}
// MiaApp.Application/Behaviors/ValidationBehavior.cs (versione con Result pattern)
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : class
{
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 ct)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, ct)));
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(e => e != null)
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
if (!failures.Any())
return await next();
// Lancia sempre, ma con payload strutturato e leggibile
throw new ApplicationValidationException(failures);
}
}
// MiaApp.Application/Exceptions/ApplicationValidationException.cs
public class ApplicationValidationException : Exception
{
public Dictionary<string, string[]> Errors { get; }
public ApplicationValidationException(Dictionary<string, string[]> errors)
: base("Si sono verificati uno o più errori di validazione")
=> Errors = errors;
}Gestione centralizzata nel middleware di ASP.NET Core
Con la validazione nel pipeline di MediatR, il controller rimane pulito. Un exception handler globale intercetta l'ApplicationValidationException e la trasforma in una risposta HTTP 422 con il payload standard di ProblemDetails:
// MiaApp.Api/Middleware/ExceptionHandlingMiddleware.cs
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (ApplicationValidationException ex)
{
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await context.Response.WriteAsJsonAsync(new ValidationProblemDetails(
ex.Errors.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value))
{
Title = "Errori di validazione",
Status = StatusCodes.Status422UnprocessableEntity
});
}
catch (OrdineNotFoundException ex)
{
_logger.LogWarning("Risorsa non trovata: {Message}", ex.Message);
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Risorsa non trovata",
Status = StatusCodes.Status404NotFound,
Detail = ex.Message
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Eccezione non gestita");
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Title = "Errore interno del server",
Status = StatusCodes.Status500InternalServerError
});
}
}
}Il risultato è un'API che restituisce sempre risposte strutturate secondo lo standard RFC 7807 (ProblemDetails), con errori di validazione dettagliati per campo. I client possono mostrare gli errori all'utente senza parsing fragile delle stringhe di errore. Il tutto senza una riga di codice di gestione errori nei controller o negli handler.
Result pattern in Clean Architecture C#: gestire gli errori di dominio senza eccezioni
Le eccezioni in C# sono progettate per situazioni eccezionali, non per il normale flusso di business. "Cliente non trovato" non è un evento eccezionale in un sistema che gestisce ordini: è un caso d'uso previsto che deve essere gestito. Usare le eccezioni per propagare errori di dominio attesi ha costi reali: performance peggiori (le eccezioni sono costose da creare per la stack trace), codice che nasconde i casi di errore nelle signature dei metodi, e difficoltà a gestire errori multipli simultaneamente.
Il Result pattern risolve questo problema rendendo esplicito il contratto: ogni operazione dichiara nella propria firma che può fallire, e chi chiama è costretto a gestire entrambi i casi. In C# ci sono tre approcci principali: una implementazione custom di Result<T>, la libreria ErrorOr, o la libreria OneOf. Vediamo prima l'implementazione custom, poi come integrarla nel flusso Clean Architecture.
Implementazione custom di Result<T> nel Domain layer
Il Result<T> può vivere nel Domain layer perché è un concetto generico del dominio: ogni operazione che può fallire con errori di business restituisce un Result. La struttura è semplice ma efficace:
// MiaApp.Domain/Common/Result.cs
public class Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public DomainError? Error { get; }
private Result(T value)
{
IsSuccess = true;
Value = value;
Error = null;
}
private Result(DomainError error)
{
IsSuccess = false;
Value = default;
Error = error;
}
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(DomainError error) => new(error);
public TResult Match<TResult>(
Func<T, TResult> onSuccess,
Func<DomainError, TResult> onFailure)
=> IsSuccess ? onSuccess(Value!) : onFailure(Error!);
}
// Versione senza valore di ritorno per le operazioni void
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public DomainError? Error { get; }
private Result(bool isSuccess, DomainError? error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, null);
public static Result Failure(DomainError error) => new(false, error);
}
// MiaApp.Domain/Common/DomainError.cs
public record DomainError(string Code, string Description)
{
// Errori predefiniti riutilizzabili
public static readonly DomainError OrdineNonTrovato =
new("Ordine.NonTrovato", "L'ordine specificato non esiste");
public static readonly DomainError OrdineGiaConfermato =
new("Ordine.GiaConfermato", "L'ordine è già stato confermato e non può essere modificato");
public static readonly DomainError OrdineVuoto =
new("Ordine.Vuoto", "L'ordine non può essere confermato senza righe");
public static DomainError QuantitaNonDisponibile(int disponibile) =>
new("Ordine.QuantitaNonDisponibile",
$"La quantità richiesta supera la disponibilità in magazzino ({disponibile} unità disponibili)");
}I DomainError predefiniti come campi statici sono una convenzione utile: centralizzano i messaggi di errore, evitano magic string sparse nel codice, e rendono i test più leggibili (result.Error.Should().Be(DomainError.OrdineVuoto)).
Usare Result nel Domain e Application layer
Il domain model restituisce Result invece di lanciare eccezioni per gli errori attesi. Le eccezioni rimangono per i casi veramente eccezionali (bug, violazioni di invarianti interne):
// MiaApp.Domain/Entities/Ordine.cs (versione con Result pattern)
public class Ordine
{
// ... (proprietà come prima)
public Result Conferma()
{
if (!_righe.Any())
return Result.Failure(DomainError.OrdineVuoto);
if (Stato != StatoOrdine.Bozza)
return Result.Failure(DomainError.OrdineGiaConfermato);
Stato = StatoOrdine.Confermato;
_domainEvents.Add(new OrdineConfermato(Id, ClienteId, _righe.Sum(r => r.Totale)));
return Result.Success();
}
public Result AggiungiRiga(Guid prodottoId, int quantita, decimal prezzoUnitario)
{
if (Stato != StatoOrdine.Bozza)
return Result.Failure(DomainError.OrdineGiaConfermato);
if (quantita <= 0)
// Questo è un bug del chiamante, non un errore di dominio atteso: eccezione appropriata
throw new ArgumentException("La quantità deve essere positiva", nameof(quantita));
_righe.Add(new RigaOrdine(prodottoId, quantita, prezzoUnitario));
return Result.Success();
}
}
// MiaApp.Application/Ordini/Commands/ConfermaOrdineHandler.cs (versione con Result)
public class ConfermaOrdineHandler
: IRequestHandler<ConfermaOrdineCommand, Result<ConfermaOrdineResult>>
{
private readonly IOrdiniRepository _repository;
private readonly INotificheService _notifiche;
public ConfermaOrdineHandler(
IOrdiniRepository repository,
INotificheService notifiche)
{
_repository = repository;
_notifiche = notifiche;
}
public async Task<Result<ConfermaOrdineResult>> Handle(
ConfermaOrdineCommand command,
CancellationToken ct)
{
var ordine = await _repository.GetByIdAsync(command.OrdineId, ct);
if (ordine is null)
return Result<ConfermaOrdineResult>.Failure(DomainError.OrdineNonTrovato);
var confermaResult = ordine.Conferma();
if (confermaResult.IsFailure)
return Result<ConfermaOrdineResult>.Failure(confermaResult.Error!);
await _repository.SaveAsync(ordine, ct);
await _notifiche.InviaConfermaAsync(ordine.ClienteId, ordine.Id, ct);
return Result<ConfermaOrdineResult>.Success(
new ConfermaOrdineResult(ordine.Id, ordine.Stato, DateTime.UtcNow));
}
}Mappatura degli errori di dominio agli HTTP status code nel controller
L'endpoint di Presentation riceve il Result dall'handler e lo trasforma nella risposta HTTP appropriata. La mappatura da DomainError a HTTP status code vive in Presentation perché è una decisione di presentazione, non di dominio: il dominio sa che l'ordine non esiste, non sa che questo corrisponde a un 404.
// MiaApp.Api/Endpoints/OrdiniEndpoints.cs (versione con Result pattern)
public static class OrdiniEndpoints
{
public static WebApplication MapOrdiniEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/ordini")
.WithTags("Ordini")
.RequireAuthorization();
group.MapPost("/{id:guid}/conferma", async (
Guid id,
ConfermaOrdineRequest request,
IMediator mediator,
CancellationToken ct) =>
{
var command = new ConfermaOrdineCommand(id, request.OperatoreId);
var result = await mediator.Send(command, ct);
return result.Match(
onSuccess: data => Results.Ok(data),
onFailure: error => MapErrorToHttpResult(error));
});
return app;
}
// Mappatura centralizzata degli errori di dominio
private static IResult MapErrorToHttpResult(DomainError error) =>
error.Code switch
{
"Ordine.NonTrovato" => Results.NotFound(new ProblemDetails
{
Title = "Risorsa non trovata",
Detail = error.Description,
Status = 404
}),
"Ordine.GiaConfermato" or "Ordine.Vuoto" => Results.UnprocessableEntity(
new ProblemDetails
{
Title = "Operazione non valida",
Detail = error.Description,
Status = 422
}),
_ => Results.Problem(
title: "Errore interno",
detail: error.Description,
statusCode: 500)
};
}Il metodo MapErrorToHttpResult può anche essere esteso come extension method su DomainError in un helper del layer Presentation, oppure implementato come dizionario di mapping se gli errori sono molti. L'importante è che la logica di mapping sia in un solo posto e non dispersa negli endpoint.
Quando usare Result pattern e quando non usarlo
Il Result pattern non è la soluzione per ogni scenario. Ha senso usarlo quando l'errore è un caso atteso e parte del flusso normale del business, quando si vuole che il compilatore costringa il chiamante a gestire i casi di errore, e quando si hanno handler che possono fallire in modi diversi che richiedono risposte HTTP diverse. Non ha senso applicarlo alle eccezioni di infrastruttura (timeout del database, errori di rete) che devono comunque emergere come eccezioni non gestite e finire nel middleware di error handling. La regola pratica: usa Result per gli errori di dominio previsti, lascia che le eccezioni gestiscano i guasti imprevisti dell'infrastruttura.
Per chi vuole adottare un Result pattern più ricco senza implementarlo da zero, le librerie ErrorOr (di Amichai Mantinband, molto popolare nella community .NET) e OneOf offrono primitive più complete con supporto per errori multipli e discriminated union-style pattern matching. Entrambe si integrano bene con il layer Application di Clean Architecture.
ArchUnitNET: test automatici per verificare le regole architetturali in Clean Architecture C#
Le violazioni architetturali nei progetti .NET hanno un pattern comune: iniziano piccole, quasi sempre giustificate da "urgenza" o "un caso eccezionale", e si moltiplicano silenziosamente finché il confine tra i layer non esiste più nella pratica. Quando il Domain layer inizia a importare tipi di Infrastructure, o quando i controller accedono direttamente al DbContext, la Clean Architecture è diventata solo una struttura di cartelle senza i benefici promessi.
ArchUnitNET è la risposta a questo problema: una libreria per .NET che permette di scrivere test architetturali eseguiti come unit test normali nella pipeline CI. Se una regola viene violata, il build fallisce. Non è più una convenzione affidata alla disciplina del team: è un vincolo automatizzato che si applica ad ogni commit.
Configurazione e primi test architetturali
// MiaApp.ArchTests/ArchitectureTests.cs
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.Fluent;
using Xunit;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
public class ArchitectureTests
{
private static readonly Architecture Architecture =
new ArchLoader()
.LoadAssemblies(
typeof(MiaApp.Domain.Entities.Ordine).Assembly,
typeof(MiaApp.Application.Ordini.Commands.ConfermaOrdineCommand).Assembly,
typeof(MiaApp.Infrastructure.Persistence.AppDbContext).Assembly,
typeof(MiaApp.Api.Program).Assembly)
.Build();
// Il Domain non deve dipendere da nessun altro layer interno
[Fact]
public void Domain_NonDipendeDA_Application()
{
var rule = Types()
.That().ResideInAssembly(typeof(Ordine).Assembly)
.Should().NotDependOnAny(
Types().That().ResideInAssembly(
typeof(ConfermaOrdineCommand).Assembly));
rule.Check(Architecture);
}
// L'Application non deve dipendere da Infrastructure
[Fact]
public void Application_NonDipendeDA_Infrastructure()
{
var rule = Types()
.That().ResideInAssembly(typeof(ConfermaOrdineCommand).Assembly)
.Should().NotDependOnAny(
Types().That().ResideInAssembly(
typeof(AppDbContext).Assembly));
rule.Check(Architecture);
}
// Il Domain non deve importare Entity Framework
[Fact]
public void Domain_NonImporta_EntityFramework()
{
var rule = Types()
.That().ResideInAssembly(typeof(Ordine).Assembly)
.Should().NotDependOnAny(
Types().That().ResideInNamespace("Microsoft.EntityFrameworkCore"));
rule.Check(Architecture);
}
// I repository devono implementare le interfacce definite in Application
[Fact]
public void Repository_ImplementanoInterfacce_DefiniteInApplication()
{
var rule = Classes()
.That().ResideInNamespace("MiaApp.Infrastructure.Persistence.Repositories")
.Should().ImplementInterface(
Types().That().ResideInNamespace("MiaApp.Application.Interfaces").As("repository interfaces"));
rule.Check(Architecture);
}
// I handler MediatR vivono nel layer Application, non in Infrastructure o Presentation
[Fact]
public void Handler_VivonoIn_Application()
{
var rule = Classes()
.That().ImplementInterface(typeof(MediatR.IRequestHandler<,>))
.Should().ResideInAssembly(typeof(ConfermaOrdineCommand).Assembly)
.Because("gli handler di use case appartengono al layer Application");
rule.Check(Architecture);
}
}Questi test si aggiungono come progetto separato (MiaApp.ArchTests) e girano nella stessa pipeline degli altri test. Il vantaggio è concreto: ogni PR che viola le regole architetturali viene bloccata automaticamente, prima del code review. Il costo è minimo: pochi test da scrivere una volta, manutenzione quasi nulla nel tempo. Per un team che adotta Clean Architecture su un progetto di lungo periodo, ArchUnitNET è uno dei ritorni sull'investimento più elevati in termini di qualità del codice. L'articolo su pattern architetturali software contestualizza questo approccio nel quadro più ampio delle scelte architetturali. Se vuoi imparare C# applicando questi pattern su codice reale con mentoring diretto, il percorso è strutturato esattamente per questo.
Domande frequenti
La Clean Architecture è un approccio architetturale proposto da Robert C. Martin che organizza il software in layer concentrici con una regola fondamentale: le dipendenze puntano sempre verso l'interno. Il codice di business (al centro) non dipende da database, framework o UI. Sono i layer esterni (infrastruttura, presentazione) a dipendere dal core, non viceversa.
No. Clean Architecture aggiunge struttura e separazione che hanno un costo in termini di file e layer aggiuntivi. Per applicazioni semplici, CRUD-heavy o prototipi, può essere overkill. Ha senso quando la logica di business è complessa e deve essere testabile indipendentemente dall'infrastruttura, quando il progetto ha una vita lunga e il team cambierà nel tempo.
Sono concettualmente simili e spesso usati come sinonimi. L'Onion Architecture di Jeffrey Palermo e la Clean Architecture di Martin condividono il principio delle dipendenze verso l'interno. La Clean Architecture è più esplicita nel definire i quattro layer (Entities, Use Cases, Interface Adapters, Frameworks) e nel ruolo degli Use Case come orchestratori.
Il pattern più comune divide la solution in quattro progetti: Domain (entità e logica di dominio pura), Application (use case, interfacce dei servizi, DTO), Infrastructure (implementazioni di database, API esterne, email), e Presentation (API controllers o Blazor). Solo Domain non ha dipendenze esterne. Application dipende solo da Domain. Infrastructure e Presentation dipendono da Application.
I test del layer Domain sono puri unit test: nessun mock, nessun database, nessuna dipendenza esterna. Si istanzia l'entità, si chiama il metodo, si verifica lo stato risultante. I test del layer Application usano mock delle interfacce di repository e servizi. Solo i test del layer Infrastructure richiedono un database reale o un container test. Questo approccio rende i test del core veloci e stabili.
CQRS (Command Query Responsibility Segregation) si integra naturalmente con Clean Architecture nel layer Application: le operazioni di scrittura sono Command, quelle di lettura sono Query, ognuna con il suo handler. Ha senso quando le ottimizzazioni per lettura e scrittura divergono significativamente, o quando si vuole separare chiaramente gli use case. Con MediatR la registrazione degli handler è automatica e il codice rimane pulito.
I più frequenti: mettere logica di business negli handler Application invece che nelle entità Domain (Anemic Domain Model), leakare tipi di Infrastructure in Application o Domain, creare interfacce per tutto anche quando non servono per il testing, usare AutoMapper dove non aggiunge valore, e non definire confini chiari tra bounded context in sistemi complessi.
