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.

Guida Clean Architecture con .NET e C#

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.

Perché Clean Architecture?

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.

text
┌─────────────────────────────────────────────────────────┐
│                     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à.

bash
# 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.Infrastructure

Questa 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.

Domain/Entities/Order.cscsharp
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.

Domain/Entities/OrderItem.cscsharp
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;
    }
}
Domain/Enums/OrderStatus.cscsharp
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
}
Value Objects

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.

Domain/Interfaces/IOrderRepository.cscsharp
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);
}
Domain/Interfaces/IUnitOfWork.cscsharp
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.

Application/Orders/Commands/CreateOrderCommand.cscsharp
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
);
Application/Orders/Commands/CreateOrderCommandHandler.cscsharp
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.

Application/Orders/Queries/GetOrderByIdQuery.cscsharp
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
);
Application/Orders/Queries/GetOrderByIdQueryHandler.cscsharp
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.

Application/Orders/Validators/CreateOrderCommandValidator.cscsharp
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.");
        });
    }
}
Application/Common/Behaviors/ValidationBehavior.cscsharp
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.

Infrastructure/Persistence/AppDbContext.cscsharp
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);
    }
}
Infrastructure/Persistence/Configurations/OrderConfiguration.cscsharp
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);
    }
}
Infrastructure/Repositories/OrderRepository.cscsharp
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);
    }
}
Attenzione alle Leaky Abstractions

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

Infrastructure/Persistence/UnitOfWork.cscsharp
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.

Infrastructure/DependencyInjection.cscsharp
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;
    }
}
Application/DependencyInjection.cscsharp
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;
    }
}
Api/Program.cscsharp
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.

Api/Controllers/OrdersController.cscsharp
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.

Tests/Application/CreateOrderCommandHandlerTests.cscsharp
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

#dotnet
#clean architecture
#csharp
#aspnet core
#design patterns

Condividi

Articoli correlati