Clean Architecture con .NET: Guida Pratica
Padroneggiare la Clean Architecture in .NET con C#. Scoprire i principi SOLID, la separazione dei layer e i pattern di implementazione per applicazioni manutenibili.

La Clean Architecture, resa popolare da Robert C. Martin (Uncle Bob), organizza il codice ponendo la logica di business al centro dell'applicazione, indipendente dai framework e dai dettagli implementativi. Questo approccio architetturale garantisce testabilità, manutenibilità e scalabilità per le applicazioni .NET. Questa guida presenta un'implementazione pratica con ASP.NET Core.
Le applicazioni che mescolano logica di business e codice infrastrutturale diventano rapidamente difficili da mantenere. La Clean Architecture impone una separazione rigorosa che permette di modificare i dettagli tecnici senza impattare il cuore del business.
Principi fondamentali della Clean Architecture
La Clean Architecture si basa sull'inversione delle dipendenze: i layer interni non conoscono quelli esterni. Il dominio di business resta isolato e può evolvere indipendentemente dalle scelte tecniche come il framework web o il database.
┌─────────────────────────────────────────────────────────┐
│ Presentation │
│ (Controllers, Razor Pages, Blazor, API Endpoints) │
├─────────────────────────────────────────────────────────┤
│ Infrastructure │
│ (EF Core, External APIs, File System, Email) │
├─────────────────────────────────────────────────────────┤
│ Application │
│ (Use Cases, Commands, Queries, DTOs) │
├─────────────────────────────────────────────────────────┤
│ Domain │
│ (Entities, Value Objects, Domain Services) │
└─────────────────────────────────────────────────────────┘Le dipendenze puntano sempre verso l'interno: Presentation → Infrastructure → Application → Domain. Il Domain non referenzia alcun altro progetto.
Struttura del progetto .NET in Clean Architecture
L'organizzazione dei progetti rispecchia i diversi layer. Ogni layer corrisponde a un progetto distinto nella solution di Visual Studio, garantendo una separazione fisica delle responsabilità.
# terminal
# Creazione della struttura della solution
dotnet new sln -n CleanArchitecture
# Creazione dei progetti per ciascun layer
dotnet new classlib -n CleanArchitecture.Domain -o src/CleanArchitecture.Domain
dotnet new classlib -n CleanArchitecture.Application -o src/CleanArchitecture.Application
dotnet new classlib -n CleanArchitecture.Infrastructure -o src/CleanArchitecture.Infrastructure
dotnet new webapi -n CleanArchitecture.Api -o src/CleanArchitecture.Api
# Aggiunta dei progetti alla solution
dotnet sln add src/CleanArchitecture.Domain
dotnet sln add src/CleanArchitecture.Application
dotnet sln add src/CleanArchitecture.Infrastructure
dotnet sln add src/CleanArchitecture.Api
# Configurazione dei riferimenti tra progetti
cd src/CleanArchitecture.Application
dotnet add reference ../CleanArchitecture.Domain
cd ../CleanArchitecture.Infrastructure
dotnet add reference ../CleanArchitecture.Application
cd ../CleanArchitecture.Api
dotnet add reference ../CleanArchitecture.InfrastructureQuesta struttura assicura che il Domain rimanga indipendente e che le dipendenze rispettino la Dependency Rule della Clean Architecture.
Il layer Domain: il cuore del business
Il layer Domain contiene le entità di business, i Value Objects e le interfacce dei repository. Nessuna dipendenza esterna è ammessa in questo layer.
namespace CleanArchitecture.Domain.Entities;
// Entità con identità e ciclo di vita di business
public class Order
{
// Identificativo univoco dell'ordine
public Guid Id { get; private set; }
// Riferimento al cliente (Value Object per l'email)
public string CustomerEmail { get; private set; }
// Collezione di item (relazione one-to-many)
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
// Stato dell'ordine (enum di business)
public OrderStatus Status { get; private set; }
// Importo totale calcolato
public decimal TotalAmount => _items.Sum(i => i.TotalPrice);
// Date di tracciamento
public DateTime CreatedAt { get; private set; }
public DateTime? ShippedAt { get; private set; }
// Costruttore privato che impone l'uso del factory method
private Order() { }
// Factory method per creare un ordine valido
public static Order Create(string customerEmail)
{
// Validazione delle regole di business in fase di creazione
if (string.IsNullOrWhiteSpace(customerEmail))
throw new DomainException("L'email del cliente è obbligatoria.");
if (!IsValidEmail(customerEmail))
throw new DomainException("Formato email non valido.");
return new Order
{
Id = Guid.NewGuid(),
CustomerEmail = customerEmail,
Status = OrderStatus.Pending,
CreatedAt = DateTime.UtcNow
};
}
// Metodo di business: aggiungere un item
public void AddItem(Product product, int quantity)
{
// Regola di business: impossibile modificare un ordine spedito
if (Status == OrderStatus.Shipped)
throw new DomainException("Impossibile modificare un ordine già spedito.");
if (quantity <= 0)
throw new DomainException("La quantità deve essere positiva.");
// Verifica se il prodotto è già presente
var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
if (existingItem != null)
{
existingItem.IncreaseQuantity(quantity);
}
else
{
_items.Add(OrderItem.Create(this, product, quantity));
}
}
// Metodo di business: confermare l'ordine
public void Confirm()
{
if (Status != OrderStatus.Pending)
throw new DomainException("Solo gli ordini in attesa possono essere confermati.");
if (!_items.Any())
throw new DomainException("Un ordine deve contenere almeno un item.");
Status = OrderStatus.Confirmed;
}
// Metodo di business: spedire l'ordine
public void Ship()
{
if (Status != OrderStatus.Confirmed)
throw new DomainException("L'ordine deve essere confermato prima della spedizione.");
Status = OrderStatus.Shipped;
ShippedAt = DateTime.UtcNow;
}
private static bool IsValidEmail(string email) =>
email.Contains('@') && email.Contains('.');
}L'entità Order incapsula le regole di business e protegge il proprio stato interno. Le modifiche devono passare obbligatoriamente attraverso metodi di business che validano gli invarianti.
namespace CleanArchitecture.Domain.Entities;
// Entità figlia con identità propria
public class OrderItem
{
public Guid Id { get; private set; }
public Guid OrderId { get; private set; }
public Guid ProductId { get; private set; }
public string ProductName { get; private set; }
public decimal UnitPrice { get; private set; }
public int Quantity { get; private set; }
// Calcolo del prezzo totale di riga
public decimal TotalPrice => UnitPrice * Quantity;
private OrderItem() { }
// Factory method con validazione
public static OrderItem Create(Order order, Product product, int quantity)
{
return new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = product.Id,
ProductName = product.Name,
UnitPrice = product.Price,
Quantity = quantity
};
}
// Metodo per aumentare la quantità
public void IncreaseQuantity(int additionalQuantity)
{
if (additionalQuantity <= 0)
throw new DomainException("La quantità aggiuntiva deve essere positiva.");
Quantity += additionalQuantity;
}
}namespace CleanArchitecture.Domain.Enums;
// Enumerazione degli stati dell'ordine
public enum OrderStatus
{
Pending = 0, // In attesa di conferma
Confirmed = 1, // Confermato, pronto per la spedizione
Shipped = 2, // Spedito
Delivered = 3, // Consegnato
Cancelled = 4 // Annullato
}I Value Objects come Money, Address o Email incapsulano concetti di business privi di identità propria. La loro uguaglianza si basa sui valori, non sui riferimenti. Questo approccio rafforza l'espressività del dominio.
Le interfacce Repository nel Domain
Le interfacce dei repository sono definite nel Domain, mentre le loro implementazioni risiedono nell'Infrastructure. Questo pattern rispetta il principio di inversione delle dipendenze.
namespace CleanArchitecture.Domain.Interfaces;
// Interfaccia del repository Order
public interface IOrderRepository
{
// Recupero per identificativo
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
// Recupero con item inclusi
Task<Order?> GetByIdWithItemsAsync(Guid id, CancellationToken cancellationToken = default);
// Recupero tramite email del cliente
Task<IEnumerable<Order>> GetByCustomerEmailAsync(
string email,
CancellationToken cancellationToken = default);
// Aggiunta di un nuovo ordine
Task AddAsync(Order order, CancellationToken cancellationToken = default);
// Aggiornamento di un ordine esistente
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
// Eliminazione di un ordine
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
}namespace CleanArchitecture.Domain.Interfaces;
// Pattern Unit of Work per la gestione transazionale
public interface IUnitOfWork : IDisposable
{
// Repository accessibili tramite UoW
IOrderRepository Orders { get; }
IProductRepository Products { get; }
// Salvataggio atomico di tutte le modifiche
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
// Gestione esplicita delle transazioni
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}Il pattern Unit of Work coordina le operazioni su più repository all'interno di una singola transazione.
Pronto a superare i tuoi colloqui su .NET?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
Il layer Application: orchestrazione degli Use Case
Il layer Application contiene la logica applicativa (use case), i DTO e le interfacce dei servizi esterni. Orchestra le interazioni tra il dominio e il mondo esterno.
using MediatR;
namespace CleanArchitecture.Application.Orders.Commands;
// Command che rappresenta l'intenzione di creare un ordine
public record CreateOrderCommand(
string CustomerEmail,
List<OrderItemDto> Items
) : IRequest<Guid>;
// DTO per gli item dell'ordine
public record OrderItemDto(
Guid ProductId,
int Quantity
);using MediatR;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Application.Orders.Commands;
// Handler che implementa il caso d'uso
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
private readonly IUnitOfWork _unitOfWork;
public CreateOrderCommandHandler(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<Guid> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
// Creazione dell'entità Order tramite factory method
var order = Order.Create(request.CustomerEmail);
// Aggiunta degli item all'ordine
foreach (var item in request.Items)
{
// Recupero del prodotto dal repository
var product = await _unitOfWork.Products
.GetByIdAsync(item.ProductId, cancellationToken);
if (product == null)
throw new NotFoundException($"Prodotto {item.ProductId} non trovato.");
// Uso del metodo di business dell'entità
order.AddItem(product, item.Quantity);
}
// Persistenza tramite repository
await _unitOfWork.Orders.AddAsync(order, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);
return order.Id;
}
}MediatR implementa il pattern Mediator per disaccoppiare gli handler dai controller. Ogni command ha un unico handler responsabile della sua elaborazione.
using MediatR;
namespace CleanArchitecture.Application.Orders.Queries;
// Query per recuperare un ordine tramite ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDto?>;
// DTO di risposta per un ordine
public record OrderDto(
Guid Id,
string CustomerEmail,
string Status,
decimal TotalAmount,
DateTime CreatedAt,
DateTime? ShippedAt,
List<OrderItemResponseDto> Items
);
// DTO per gli item nella risposta
public record OrderItemResponseDto(
Guid Id,
string ProductName,
decimal UnitPrice,
int Quantity,
decimal TotalPrice
);using MediatR;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Application.Orders.Queries;
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDto?>
{
private readonly IOrderRepository _orderRepository;
public GetOrderByIdQueryHandler(IOrderRepository orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<OrderDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
// Recupero con item inclusi
var order = await _orderRepository
.GetByIdWithItemsAsync(request.OrderId, cancellationToken);
if (order == null)
return null;
// Mapping verso DTO (proiezione manuale)
return new OrderDto(
Id: order.Id,
CustomerEmail: order.CustomerEmail,
Status: order.Status.ToString(),
TotalAmount: order.TotalAmount,
CreatedAt: order.CreatedAt,
ShippedAt: order.ShippedAt,
Items: order.Items.Select(i => new OrderItemResponseDto(
Id: i.Id,
ProductName: i.ProductName,
UnitPrice: i.UnitPrice,
Quantity: i.Quantity,
TotalPrice: i.TotalPrice
)).ToList()
);
}
}La separazione Commands/Queries segue il pattern CQRS (Command Query Responsibility Segregation), ottimizzando letture e scritture in modo indipendente.
Validazione con FluentValidation
La validazione dei command avviene nel layer Application, prima dell'esecuzione dell'handler.
using FluentValidation;
using CleanArchitecture.Application.Orders.Commands;
namespace CleanArchitecture.Application.Orders.Validators;
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
// Email obbligatoria e formato valido
RuleFor(x => x.CustomerEmail)
.NotEmpty().WithMessage("L'email del cliente è obbligatoria.")
.EmailAddress().WithMessage("Formato email non valido.");
// Almeno un item richiesto
RuleFor(x => x.Items)
.NotEmpty().WithMessage("L'ordine deve contenere almeno un item.");
// Validazione di ogni item
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty().WithMessage("L'identificativo del prodotto è obbligatorio.");
item.RuleFor(i => i.Quantity)
.GreaterThan(0).WithMessage("La quantità deve essere positiva.")
.LessThanOrEqualTo(100).WithMessage("Quantità massima: 100.");
});
}
}using FluentValidation;
using MediatR;
namespace CleanArchitecture.Application.Common.Behaviors;
// Pipeline behavior per la validazione automatica
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)
{
// Se non ci sono validatori, prosegui
if (!_validators.Any())
return await next();
// Esecuzione di tutti i validatori
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
// Aggregazione degli errori
var failures = validationResults
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
// Lancio dell'eccezione in caso di errori
if (failures.Any())
throw new ValidationException(failures);
return await next();
}
}Il ValidationBehavior viene eseguito automaticamente prima di ogni handler, garantendo che solo i command validi raggiungano la logica di business.
Il layer Infrastructure: implementazione tecnica
Il layer Infrastructure fornisce le implementazioni concrete delle interfacce definite in Domain e Application.
using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Infrastructure.Persistence;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Applicazione delle configurazioni dall'assembly
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Infrastructure.Persistence.Configurations;
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
// Tabella e chiave primaria
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
// Proprietà
builder.Property(o => o.CustomerEmail)
.IsRequired()
.HasMaxLength(256);
builder.Property(o => o.Status)
.IsRequired()
.HasConversion<string>(); // Memorizzato come stringa leggibile
// Relazione con OrderItems
builder.HasMany(o => o.Items)
.WithOne()
.HasForeignKey(i => i.OrderId)
.OnDelete(DeleteBehavior.Cascade);
// Indice per la ricerca per email
builder.HasIndex(o => o.CustomerEmail);
// Accesso al campo privato _items
builder.Navigation(o => o.Items)
.UsePropertyAccessMode(PropertyAccessMode.Field);
}
}using Microsoft.EntityFrameworkCore;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
namespace CleanArchitecture.Infrastructure.Repositories;
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context)
{
_context = context;
}
public async Task<Order?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
{
return await _context.Orders
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<Order?> GetByIdWithItemsAsync(
Guid id,
CancellationToken cancellationToken = default)
{
// Eager loading degli item
return await _context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
}
public async Task<IEnumerable<Order>> GetByCustomerEmailAsync(
string email,
CancellationToken cancellationToken = default)
{
return await _context.Orders
.Include(o => o.Items)
.Where(o => o.CustomerEmail == email)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(cancellationToken);
}
public async Task AddAsync(
Order order,
CancellationToken cancellationToken = default)
{
await _context.Orders.AddAsync(order, cancellationToken);
}
public Task UpdateAsync(
Order order,
CancellationToken cancellationToken = default)
{
// EF Core traccia automaticamente le modifiche
_context.Orders.Update(order);
return Task.CompletedTask;
}
public async Task DeleteAsync(
Guid id,
CancellationToken cancellationToken = default)
{
await _context.Orders
.Where(o => o.Id == id)
.ExecuteDeleteAsync(cancellationToken);
}
}I repository non devono esporre direttamente IQueryable, poiché creerebbe una dipendenza da EF Core nei layer superiori. Sono preferibili metodi specifici con parametri chiari.
Implementazione di Unit of Work
using Microsoft.EntityFrameworkCore.Storage;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Repositories;
namespace CleanArchitecture.Infrastructure.Persistence;
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction? _transaction;
// Lazy loading dei repository
private IOrderRepository? _orderRepository;
private IProductRepository? _productRepository;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
// Creazione on-demand (lazy initialization)
public IOrderRepository Orders =>
_orderRepository ??= new OrderRepository(_context);
public IProductRepository Products =>
_productRepository ??= new ProductRepository(_context);
public async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task BeginTransactionAsync(
CancellationToken cancellationToken = default)
{
_transaction = await _context.Database
.BeginTransactionAsync(cancellationToken);
}
public async Task CommitTransactionAsync(
CancellationToken cancellationToken = default)
{
if (_transaction == null)
throw new InvalidOperationException("Nessuna transazione attiva.");
await _transaction.CommitAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
public async Task RollbackTransactionAsync(
CancellationToken cancellationToken = default)
{
if (_transaction == null)
throw new InvalidOperationException("Nessuna transazione attiva.");
await _transaction.RollbackAsync(cancellationToken);
await _transaction.DisposeAsync();
_transaction = null;
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}Configurazione della Dependency Injection
La registrazione dei servizi avviene in ciascun layer tramite metodi di estensione, poi orchestrati in Program.cs.
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Infrastructure.Persistence;
using CleanArchitecture.Infrastructure.Repositories;
namespace CleanArchitecture.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// Configurazione di Entity Framework
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
b => b.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
// Registrazione dello Unit of Work (Scoped)
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Repository individuali se necessari
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IProductRepository, ProductRepository>();
return services;
}
}using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using CleanArchitecture.Application.Common.Behaviors;
namespace CleanArchitecture.Application;
public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
var assembly = typeof(DependencyInjection).Assembly;
// Registrazione di MediatR con gli handler
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
// Registrazione dei validatori FluentValidation
services.AddValidatorsFromAssembly(assembly);
// Pipeline behaviors (l'ordine di esecuzione è importante)
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}
}using CleanArchitecture.Application;
using CleanArchitecture.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
// Registrazione dei layer
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// Configurazione dell'API
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();Il layer Presentation: controller API
I controller sono semplici adattatori che delegano il lavoro a MediatR.
using MediatR;
using Microsoft.AspNetCore.Mvc;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Application.Orders.Queries;
namespace CleanArchitecture.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
[ProducesResponseType(typeof(Guid), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(
[FromBody] CreateOrderCommand command,
CancellationToken cancellationToken)
{
// Delega completa a MediatR
var orderId = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id = orderId },
orderId);
}
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(OrderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(
Guid id,
CancellationToken cancellationToken)
{
var order = await _mediator.Send(
new GetOrderByIdQuery(id),
cancellationToken);
if (order == null)
return NotFound();
return Ok(order);
}
[HttpPost("{id:guid}/confirm")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Confirm(
Guid id,
CancellationToken cancellationToken)
{
await _mediator.Send(new ConfirmOrderCommand(id), cancellationToken);
return NoContent();
}
[HttpPost("{id:guid}/ship")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Ship(
Guid id,
CancellationToken cancellationToken)
{
await _mediator.Send(new ShipOrderCommand(id), cancellationToken);
return NoContent();
}
}I controller restano sottili e non contengono logica di business, solo il mapping HTTP.
Test unitari del layer Application
La Clean Architecture facilita i test unitari grazie all'isolamento delle dipendenze.
using Moq;
using Xunit;
using CleanArchitecture.Application.Orders.Commands;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Tests.Application;
public class CreateOrderCommandHandlerTests
{
private readonly Mock<IUnitOfWork> _unitOfWorkMock;
private readonly CreateOrderCommandHandler _handler;
public CreateOrderCommandHandlerTests()
{
_unitOfWorkMock = new Mock<IUnitOfWork>();
_handler = new CreateOrderCommandHandler(_unitOfWorkMock.Object);
}
[Fact]
public async Task Handle_ValidCommand_CreatesOrderAndReturnsId()
{
// Arrange
var product = new Product { Id = Guid.NewGuid(), Name = "Test", Price = 100 };
_unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
It.IsAny<Guid>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(product);
_unitOfWorkMock.Setup(x => x.Orders.AddAsync(
It.IsAny<Order>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
_unitOfWorkMock.Setup(x => x.SaveChangesAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(1);
var command = new CreateOrderCommand(
CustomerEmail: "test@example.com",
Items: new List<OrderItemDto>
{
new(ProductId: product.Id, Quantity: 2)
});
// Act
var result = await _handler.Handle(command, CancellationToken.None);
// Assert
Assert.NotEqual(Guid.Empty, result);
_unitOfWorkMock.Verify(x => x.Orders.AddAsync(
It.Is<Order>(o => o.CustomerEmail == "test@example.com"),
It.IsAny<CancellationToken>()), Times.Once);
_unitOfWorkMock.Verify(x => x.SaveChangesAsync(
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Handle_ProductNotFound_ThrowsNotFoundException()
{
// Arrange
_unitOfWorkMock.Setup(x => x.Products.GetByIdAsync(
It.IsAny<Guid>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync((Product?)null);
var command = new CreateOrderCommand(
CustomerEmail: "test@example.com",
Items: new List<OrderItemDto>
{
new(ProductId: Guid.NewGuid(), Quantity: 1)
});
// Act & Assert
await Assert.ThrowsAsync<NotFoundException>(
() => _handler.Handle(command, CancellationToken.None));
}
}I mock di Moq permettono di isolare l'handler dalle sue dipendenze, testando esclusivamente la logica di orchestrazione.
Conclusione
La Clean Architecture con .NET fornisce una struttura robusta per le applicazioni enterprise. La separazione rigorosa delle responsabilità tra i layer Domain, Application, Infrastructure e Presentation garantisce un codice manutenibile e testabile nel lungo termine.
Checklist Clean Architecture .NET
- ✅ Domain isolato senza dipendenze esterne
- ✅ Entità con logica di business incapsulata
- ✅ Interfacce dei repository nel Domain
- ✅ Use Case tramite Commands/Queries (CQRS)
- ✅ Validazione con FluentValidation e Pipeline Behaviors
- ✅ Infrastructure che implementa le interfacce del Domain
- ✅ Controller sottili che delegano a MediatR
- ✅ Test unitari isolati con mock
Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
L'investimento iniziale in questa architettura si ripaga rapidamente nei progetti medio-grandi, dove i cambiamenti dei requisiti tecnici (migrazione del database, cambio di framework) impattano solo i layer esterni, preservando intatto il cuore del business.
Tag
Condividi
Articoli correlati

Domande Colloquio C# e .NET: Guida Completa 2026
Le 17 domande più frequenti nei colloqui C# e .NET. LINQ, async/await, dependency injection, Entity Framework e best practice con risposte dettagliate ed esempi di codice.

.NET 8: Creare un'API con ASP.NET Core
Guida completa alla creazione di un'API REST professionale con .NET 8 e ASP.NET Core. Controller, Entity Framework Core, validazione e best practice.

Entity Framework Core: Ottimizzazione delle Prestazioni e Best Practice nel 2026
Ottimizzazione delle prestazioni di EF Core 10 con AsNoTracking, split query, operazioni batch, il nuovo operatore LeftJoin e filtri di query con nome. Guida pratica con esempi C# per applicazioni .NET 10 in produzione.