in Architettura Software, Informatica

MediatR: Un Pattern di Comunicazione In-Process per Applicazioni C# Scalabili

La premessa è che ho lavorato a questo post nel corso di mesi, ed ho pensato di pubblicarlo anche se recentemente è cambiato il tipo di licenza. In un post successivo vedremo le modifiche nel dettaglio.

Analizzeremo una libreria C# estremamente potente e popolare che può rivoluzionare il modo in cui gestite la comunicazione tra i componenti all’interno delle vostre applicazioni: MediatR. Se state cercando di ridurre le dipendenze accoppiate, migliorare la manutenibilità del codice e adottare principi di design puliti come S.O.L.I.D., questa libreria può esservi di grande aiuto.

In questo post, non solo analizzeremo MediatR e perché ne abbiamo bisogno, ma faremo una full-immersion nel suo funzionamento interno, creeremo un esempio pratico dettagliato e analizzeremo le best practice, pattern avanzati e considerazioni sull’architettura.

Indice

  1. Introduzione a MediatR: Il Problema della Comunicazione Accoppiata
    • Cos’è MediatR?
    • Perché usare MediatR? I benefici chiave
    • Il problema del “Rigid Coupling”
    • Il Pattern Mediator
  2. Come Funziona MediatR: Architettura e Componenti Chiave
    • Il Cuore di MediatR: Richieste e Handler
    • IRequest<TResponse> e IRequestHandler<TRequest, TResponse>
    • Notifiche e Notificatori
    • INotification e INotificationHandler<TNotification>
    • Pipeline Behaviors (Comportamenti di Pipeline)
    • IPipelineBehavior<TRequest, TResponse>
    • Il Concetto di Dispatching
    • Iniezione delle Dipendenze e Registrazione
  3. Esempio Pratico: Creare un’API Web con MediatR
    • Scenario: Un Sistema Semplice di Gestione Prodotti
    • Setup del Progetto
    • Definizione delle Richieste (Comandi e Query)
      • CreateProductCommand
      • GetProductByIdQuery
    • Implementazione degli Handler
      • CreateProductCommandHandler
      • GetProductByIdQueryHandler
    • Implementazione delle Notifiche
      • ProductCreatedNotification
      • ProductCreatedNotificationHandler (per logging e cache)
    • Implementazione dei Pipeline Behaviors
      • LoggingBehavior
      • ValidationBehavior (con FluentValidation)
    • Configurazione di MediatR nel Contenitore IoC (Dependency Injection)
    • Utilizzo di MediatR in un Controller API
    • Testare l’Applicazione
  4. Approfondimenti e Best Practice
    • Nomenclatura delle Richieste: Comandi (C) e Query (Q)
    • Limiti del Design Monolitico: Quando MediatR è più efficace
    • Strutturazione del Progetto: Organizzare Richieste e Handler
    • Gestione degli Errori: try-catch nei Pipeline Behaviors
    • Transazioni di Database: Incapsulare logica transazionale
    • Validazione: Integrazione con FluentValidation
    • Caching: Decoratori e Pipeline Behaviors
    • Logging e Monitoring: L’importanza dei Behaviors
    • Sicurezza e Autorizzazione: Middleware o Behaviors
    • Asincronicità: Gestione delle Operazioni Asincrone
  5. Considerazioni Avanzate e Pattern Correlati
    • CQRS (Command Query Responsibility Segregation) e MediatR: Una Sintesi Perfetta
    • Event-Driven Architecture (Architettura Basata sugli Eventi) e Notifiche di MediatR: Cosa è uguale, cosa è diverso
    • Problemi Comuni e Soluzioni
      • Over-engineering per applicazioni semplici
      • Debug del Flusso
      • Gestione di Handler multipli per la stessa Request (anti-pattern)
    • Alternativa a MediatR: Se esiste un’esigenza diversa
  6. Conclusione

1. Introduzione a MediatR: Il Problema della Comunicazione Accoppiata

Immaginiamo di costruire un’applicazione complessa. Avete moduli, classi e servizi che devono interagire tra loro. Senza un pattern ben definito, potremmo finire con codice che assomiglia a questo:

public class OrderService
{
    private readonly ProductService _productService;
    private readonly PaymentService _paymentService;
    private readonly NotificationService _notificationService;
    private readonly InventoryService _inventoryService;

    public OrderService(ProductService productService,
                        PaymentService paymentService,
                        NotificationService notificationService,
                        InventoryService inventoryService)
    {
        _productService = productService;
        _paymentService = paymentService;
        _notificationService = notificationService;
        _inventoryService = inventoryService;
    }

    public void PlaceOrder(Order order)
    {
        // Logica complessa...
        _productService.CheckAvailability(order.Items);
        _paymentService.ProcessPayment(order.Amount);
        _inventoryService.UpdateStock(order.Items);
        _notificationService.SendConfirmationEmail(order.CustomerEmail);
        // Altre operazioni...
    }
}

Questo esempio mostra un OrderService che ha dipendenze dirette da ProductService, PaymentService, NotificationService e InventoryService. Questo è un esempio di accoppiamento rigido (tight coupling). Cosa succede se cambiamo l’interfaccia di PaymentService? Dobbiamo modificare OrderService. Se aggiungiamo un nuovo servizio necessario per l’ordinazione, dobbiamo modificare OrderService e potenzialmente tutti i costruttori che lo istanziano. Questo viola il Principio del Single Responsibility (SRP) e il Principio Open/Closed (OCP).

Cos’è MediatR?

MediatR è una libreria C# creata da Jimmy Bogard che implementa il Pattern Mediator. Il suo scopo principale è quello di facilitare la comunicazione in-process desacoppiata tra i componenti della vostra applicazione. Invece di far sì che gli oggetti comunichino direttamente tra loro, introducete un “mediatore” che gestisce tutte le richieste e le notifiche, inoltrandole agli handler appropriati.

Pensiamolo come un centro di smistamento: un componente invia un “messaggio” al mediatore, e il mediatore sa esattamente a chi inoltrare quel messaggio per l’elaborazione, senza che il mittente conosca il destinatario specifico.

Perché usare MediatR? I Benefici Chiave

  1. Disaccoppiamento (Decoupling): Questo è il beneficio più grande. I mittenti non hanno bisogno di conoscere i destinatari (handler). Questo riduce le dipendenze dirette e rende il codice più facile da modificare e testare.
  2. Principio del Single Responsibility (SRP): Ogni classe (handler) ha una singola responsabilità: gestire un tipo specifico di richiesta o notifica.
  3. Principio Open/Closed (OCP): Potete estendere il comportamento dell’applicazione aggiungendo nuovi handler o pipeline behaviors senza modificare il codice esistente.
  4. Manutenibilità Migliorata: Con componenti più piccoli e focalizzati, il codice diventa più facile da capire, debuggare e mantenere.
  5. Testabilità Aumentata: Poiché gli handler sono classi isolate con una singola responsabilità, sono molto più facili da testare in modo unitario.
  6. Supporto per CQRS (Command Query Responsibility Segregation): MediatR si sposa perfettamente con il pattern CQRS, dove le operazioni di scrittura (comandi) e di lettura (query) sono separate.
  7. Comportamenti della Pipeline (Pipeline Behaviors): Consente di inserire logica trasversale (come logging, validazione, caching) prima o dopo l’esecuzione di un handler, in modo simile a un middleware.

Il Problema del “Rigid Coupling”

Come accennato, il coupling rigido si verifica quando una classe dipende direttamente da molte altre classi concrete. Questo porta a:

  • Difficoltà di modifica: Un cambiamento in una dipendenza può richiedere modifiche in molte altre classi.
  • Difficoltà di test: È difficile testare una singola classe senza dover istanziare tutte le sue dipendenze.
  • Scarso riutilizzo: Le classi sono troppo specifiche e legate al loro contesto originale.
  • Violazione di SRP: Una classe finisce per avere più di una ragione per cambiare, ad esempio, OrderService è responsabile sia della logica di business dell’ordine che dell’orchestrazione di altri servizi.

Il Pattern Mediator

MediatR è un’implementazione del Pattern Mediator della Gang of Four.

Il Pattern Mediator definisce un oggetto che incapsula come un insieme di oggetti interagiscono. Fornisce un punto centrale di controllo per la comunicazione tra gli oggetti, riducendo l’accoppiamento diretto tra loro. I partecipanti non comunicano direttamente tra loro, ma attraverso l’oggetto Mediator. Questo aumenta il riusabilità dei componenti e riduce la complessità del sistema.

Nel contesto di MediatR:

  • Colleghi (Colleagues): Le classi che inviano richieste o notifiche (es. Controller, Servizi).
  • Mediator: L’istanza di IMediator che riceve le richieste/notifiche e le inoltra.
  • Handler: Le classi che ricevono e processano le richieste/notifiche.

2. Come Funziona MediatR: Architettura e Componenti Chiave

MediatR si basa su un insieme di interfacce e classi per orchestrarne il comportamento. Comprendere questi componenti è fondamentale.

Il Cuore di MediatR: Richieste e Handler

Al centro di MediatR ci sono le Richieste e i Handler associati.

IRequest<TResponse> e IRequestHandler<TRequest, TResponse>

  • IRequest<TResponse>: Rappresenta un messaggio o un’operazione che ha un singolo handler e si aspetta una risposta di tipo TResponse. Se l’operazione non restituisce nulla, si usa IRequest<Unit> (dove Unit è un tipo void di MediatR).

Esempio di richiesta (comando):

public class CreateProductCommand : IRequest<ProductDto>
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

Esempio di richiesta (query)

public class GetProductByIdQuery : IRequest<ProductDto>
{
    public Guid Id { get; set; }
}
  • IRequestHandler<TRequest, TResponse>: È l’interfaccia che un handler deve implementare. Ogni TRequest avrà esattamente un IRequestHandler che lo gestisce. Il metodo Handle è dove viene eseguita la logica di business per quella richiesta. C#
public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, ProductDto>
{
    private readonly IProductRepository _productRepository;
    private readonly IMapper _mapper; // Esempio di dipendenza

    public CreateProductCommandHandler(IProductRepository productRepository, IMapper mapper)
    {
        _productRepository = productRepository;
        _mapper = mapper;
    }

    public async Task<ProductDto> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        // Logica di creazione del prodotto
        var product = _mapper.Map<Product>(request);
        await _productRepository.AddAsync(product);
        await _productRepository.SaveAsync(); // Salva le modifiche

        return _mapper.Map<ProductDto>(product);
    }
}

Notiamo che il metodo Handle è asincrono (Task<TResponse>) e accetta un CancellationToken per supportare la cancellazione delle operazioni.

Notifiche e Notificatori

Le Notifiche sono messaggi che possono avere zero o più handler. A differenza delle richieste, le notifiche sono un meccanismo “fire-and-forget” e non si aspettano una risposta dal mediatore. Sono perfette per gli domain event o per scenari in cui più componenti devono reagire allo stesso evento.

INotification e INotificationHandler<TNotification>

  • INotification: Interfaccia che una classe deve implementare per essere considerata una notifica.
public class ProductCreatedNotification : INotification
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public decimal ProductPrice { get; set; }
}

Per la stessa ProductCreatedNotification, possono esserci più handler, ognuno con una propria responsabilità. Quando si pubblica una notifica (await _mediator.Publish(notification)), MediatR trova ed esegue tutti gli handler registrati per quella notifica.

Pipeline Behaviors (Comportamenti di Pipeline)

I Pipeline Behaviors sono un meccanismo potente per intercettare l’elaborazione di una richiesta, sia prima che dopo l’esecuzione dell’handler effettivo. Sono ideali per implementare la logica trasversale (cross-cutting concerns) in modo pulito e modulare, senza sporcare la logica di business negli handler.

IPipelineBehavior<TRequest, TResponse>

Questa interfaccia è simile a un middleware in ASP.NET Core. Permette di incapsulare logica che deve essere eseguita per tutte (o un sottoinsieme di) le richieste.

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"Inizio elaborazione richiesta: {typeof(TRequest).Name}");

        var response = await next(); // Chiamata al prossimo behavior nella pipeline o all'handler finale

        _logger.LogInformation($"Fine elaborazione richiesta: {typeof(TRequest).Name}");
        return response;
    }
}

L’RequestHandlerDelegate<TResponse> next è il delegato che rappresenta il prossimo passo nella pipeline (o l’handler finale). Chiamare await next() fa avanzare la richiesta.

Esempi comuni di Pipeline Behaviors:

  • Logging: Registrare l’inizio e la fine dell’elaborazione di una richiesta.
  • Validazione: Eseguire la validazione degli input prima che l’handler venga eseguito.
  • Gestione delle transazioni: Iniziare una transazione prima dell’handler e fare il commit/rollback dopo.
  • Caching: Controllare se una risposta è già in cache prima di eseguire l’handler.
  • Autorizzazione/Autenticazione: Verificare i permessi dell’utente.

Il Concetto di Dispatching

Quando si utilizza MediatR, non si chiamano direttamente gli handler. Si interagisce con l’interfaccia IMediator.

  • IMediator.Send(IRequest<TResponse> request, CancellationToken cancellationToken): Utilizzato per inviare una richiesta. MediatR troverà il singolo handler associato a quel tipo di richiesta, eseguirà tutti i behaviors registrati e infine l’handler. Restituisce la risposta dell’handler.
  • IMediator.Publish(INotification notification, CancellationToken cancellationToken): Utilizzato per pubblicare una notifica. MediatR troverà tutti gli handler registrati per quella notifica e li eseguirà. Non restituisce un valore.

Iniezione delle Dipendenze e Registrazione

MediatR si integra perfettamente con i contenitori di Iniezione delle Dipendenze (IoC), in particolare con il contenitore integrato di .NET Core/5+/6+. È necessario registrare MediatR e tutti i suoi handler, notifiche e behaviors all’avvio dell’applicazione.

L’estensione MediatR.Extensions.Microsoft.DependencyInjection semplifica enormemente questo processo.

// Nel metodo ConfigureServices di Startup.cs o Program.cs (per .NET 6+)
public void ConfigureServices(IServiceCollection services)
{
    // ... altri servizi

    // Registra MediatR e scopri automaticamente tutti gli handler e behaviors
    // dall'assembly che contiene la classe di avvio (o altri assembly specificati)
    services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));

    // Se si vogliono aggiungere specifici pipeline behaviors:
    services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

    // ... altri servizi
}

RegisterServicesFromAssembly cercherà automaticamente tutte le implementazioni di IRequestHandler, INotificationHandler e IPipelineBehavior negli assembly specificati e le registrerà nel contenitore IoC.

3. Esempio Pratico: Creare un’API Web con MediatR

Vediamo come mettere tutto insieme creando una semplice API per la gestione dei prodotti.

Scenario: Un Sistema Semplice di Gestione Prodotti

Supponiamo di voler creare un’API che permetta di:

  1. Creare un nuovo prodotto.
  2. Recuperare un prodotto per ID.
  3. Registrare le operazioni di creazione prodotto.
  4. Validare i dati in ingresso.

Setup del Progetto

Creiamo un progetto ASP.NET Core Web API.

dotnet new webapi -n MediatRDemo
cd MediatRDemo

Aggiungiamo i pacchetti NuGet necessari:

dotnet add package MediatR
dotnet add package MediatR.Extensions.Microsoft.DependencyInjection
dotnet add package AutoMapper.Extensions.Microsoft.DependencyInjection # Per il mapping
dotnet add package FluentValidation.DependencyInjection.Extensions # Per la validazione

Definiamo le nostre entità e DTOs.

Cartella Entities/

Product.cs

public class Product
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

Cartella DTOs/

ProductDto.cs

C#

public class ProductDto
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

Cartella Data/ (Una semplice repository in-memory per semplicità)

IProductRepository.cs

using MediatRDemo.Entities;

namespace MediatRDemo.Data;

public interface IProductRepository
{
    Task AddAsync(Product product);
    Task<Product> GetByIdAsync(Guid id);
    Task SaveAsync(); // Simula il salvataggio nel DB
}

ProductRepository.cs

using MediatRDemo.Entities;

namespace MediatRDemo.Data;

public class ProductRepository : IProductRepository
{
    private static readonly Dictionary<Guid, Product> _products = new();

    public Task AddAsync(Product product)
    {
        _products[product.Id] = product;
        return Task.CompletedTask;
    }

    public Task<Product> GetByIdAsync(Guid id)
    {
        _products.TryGetValue(id, out var product);
        return Task.FromResult(product);
    }

    public Task SaveAsync()
    {
        // In una vera applicazione, qui ci sarebbe il SaveChanges di un DbContext
        Console.WriteLine("Changes saved to 'database' (in-memory dictionary).");
        return Task.CompletedTask;
    }
}

Definizione delle Richieste (Comandi e Query)

Cartella Requests/Commands/

CreateProductCommand.cs

using MediatR;
using MediatRDemo.DTOs;

namespace MediatRDemo.Requests.Commands;

public class CreateProductCommand : IRequest<ProductDto>
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
}

Cartella Requests/Queries/

GetProductByIdQuery.cs

using MediatR;
using MediatRDemo.DTOs;

namespace MediatRDemo.Requests.Queries;

public class GetProductByIdQuery : IRequest<ProductDto>
{
    public Guid Id { get; set; }
}

Implementazione degli Handler

Cartella Handlers/

CreateProductCommandHandler.cs

using MediatR;
using AutoMapper;
using MediatRDemo.Data;
using MediatRDemo.Entities;
using MediatRDemo.DTOs;
using MediatRDemo.Notifications; // Per la notifica

namespace MediatRDemo.Handlers;

public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, ProductDto>
{
    private readonly IProductRepository _productRepository;
    private readonly IMapper _mapper;
    private readonly IMediator _mediator; // Per pubblicare notifiche

    public CreateProductCommandHandler(IProductRepository productRepository, IMapper mapper, IMediator mediator)
    {
        _productRepository = productRepository;
        _mapper = mapper;
        _mediator = mediator;
    }

    public async Task<ProductDto> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        var product = _mapper.Map<Product>(request);
        product.Id = Guid.NewGuid(); // Genera un nuovo ID

        await _productRepository.AddAsync(product);
        await _productRepository.SaveAsync();

        var productDto = _mapper.Map<ProductDto>(product);

        // Pubblica una notifica dopo la creazione del prodotto
        await _mediator.Publish(new ProductCreatedNotification
        {
            ProductId = product.Id,
            ProductName = product.Name,
            ProductPrice = product.Price
        }, cancellationToken);

        return productDto;
    }
}

GetProductByIdQueryHandler.cs

using MediatR;
using AutoMapper;
using MediatRDemo.Data;
using MediatRDemo.DTOs;
using MediatRDemo.Requests.Queries;

namespace MediatRDemo.Handlers;

public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
    private readonly IProductRepository _productRepository;
    private readonly IMapper _mapper;

    public GetProductByIdQueryHandler(IProductRepository productRepository, IMapper mapper)
    {
        _productRepository = productRepository;
        _mapper = mapper;
    }

    public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        var product = await _productRepository.GetByIdAsync(request.Id);

        if (product == null)
        {
            return null; // O lanciare un'eccezione specifica (es. NotFoundException)
        }

        return _mapper.Map<ProductDto>(product);
    }
}

Implementazione delle Notifiche

Cartella Notifications/

ProductCreatedNotification.cs

using MediatR;

namespace MediatRDemo.Notifications;

public class ProductCreatedNotification : INotification
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public decimal ProductPrice { get; set; }
}

Cartella Handlers/ (Notificatori)

ProductCreatedLoggerHandler.cs

using MediatR;
using MediatRDemo.Notifications;

namespace MediatRDemo.Handlers;

public class ProductCreatedLoggerHandler : INotificationHandler<ProductCreatedNotification>
{
    private readonly ILogger<ProductCreatedLoggerHandler> _logger;

    public ProductCreatedLoggerHandler(ILogger<ProductCreatedLoggerHandler> logger)
    {
        _logger = logger;
    }

    public Task Handle(ProductCreatedNotification notification, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"[Notification] Prodotto '{notification.ProductName}' (ID: {notification.ProductId}) creato con successo. Prezzo: {notification.ProductPrice:C}");
        return Task.CompletedTask;
    }
}

Implementazione dei Pipeline Behaviors

Cartella Behaviors/

LoggingBehavior.cs

using MediatR;

namespace MediatRDemo.Behaviors;

public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        _logger.LogInformation($"----- Inizio elaborazione Request: {typeof(TRequest).Name} -----");
        // Puoi loggare i dettagli della richiesta se non contiene dati sensibili
        // _logger.LogInformation($"Request data: {Newtonsoft.Json.JsonConvert.SerializeObject(request)}");

        var response = await next(); // Chiamata al prossimo behavior o all'handler

        _logger.LogInformation($"----- Fine elaborazione Request: {typeof(TRequest).Name} -----");
        // Puoi loggare i dettagli della risposta se non contiene dati sensibili
        // _logger.LogInformation($"Response data: {Newtonsoft.Json.JsonConvert.SerializeObject(response)}");

        return response;
    }
}

ValidationBehavior.cs (Richiede FluentValidation e FluentValidation.DependencyInjection.Extensions)

using FluentValidation;
using MediatR;

namespace MediatRDemo.Behaviors;

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 cancellationToken)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);
            var validationResults = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
            var failures = validationResults.SelectMany(r => r.Errors).Where(f => f != null).ToList();

            if (failures.Any())
            {
                // Qui puoi lanciare un'eccezione specifica (es. ValidationException)
                // Che verrà gestita da un middleware globale per ritornare un BadRequest 400
                throw new ValidationException(failures);
            }
        }
        return await next();
    }
}

Cartella Validators/

CreateProductCommandValidator.cs

using FluentValidation;
using MediatRDemo.Requests.Commands;

namespace MediatRDemo.Validators;

public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().WithMessage("Il nome del prodotto è richiesto.");
        RuleFor(x => x.Price).GreaterThan(0).WithMessage("Il prezzo deve essere maggiore di zero.");
        RuleFor(x => x.Stock).GreaterThanOrEqualTo(0).WithMessage("Lo stock non può essere negativo.");
    }
}

Configurazione di MediatR nel Contenitore IoC (Dependency Injection)

Modifica Program.cs (per .NET 6+) o Startup.cs (per versioni precedenti):

using MediatR;
using MediatRDemo.Behaviors;
using MediatRDemo.Data;
using MediatRDemo.Mapping; // Per AutoMapper
using MediatRDemo.Validators; // Per FluentValidation
using FluentValidation;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// Registra AutoMapper
builder.Services.AddAutoMapper(typeof(MappingProfile)); // Crea MappingProfile come segue

// Registra la repository
builder.Services.AddSingleton<IProductRepository, ProductRepository>(); // Singleton per in-memory demo

// Registra MediatR
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly); // Registra handlers e notificatori dall'assembly corrente
});

// Registra i Pipeline Behaviors in ordine (l'ordine è importante!)
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Registra i validatori di FluentValidation
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);


var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

Cartella Mapping/

MappingProfile.cs (per AutoMapper)

using AutoMapper;
using MediatRDemo.DTOs;
using MediatRDemo.Entities;
using MediatRDemo.Requests.Commands;

namespace MediatRDemo.Mapping;

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<CreateProductCommand, Product>();
        CreateMap<Product, ProductDto>();
    }
}

Utilizzo di MediatR in un Controller API

Cartella Controllers/

ProductsController.cs

using MediatR;
using MediatRDemo.DTOs;
using MediatRDemo.Requests.Commands;
using MediatRDemo.Requests.Queries;
using Microsoft.AspNetCore.Mvc;

namespace MediatRDemo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IMediator _mediator;

    public ProductsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> CreateProduct([FromBody] CreateProductCommand command)
    {
        try
        {
            var product = await _mediator.Send(command);
            return CreatedAtAction(nameof(GetProductById), new { id = product.Id }, product);
        }
        catch (FluentValidation.ValidationException ex)
        {
            return BadRequest(new { errors = ex.Errors.Select(e => e.ErrorMessage) });
        }
        catch (Exception ex)
        {
            // Loggare l'eccezione
            return StatusCode(500, "Si è verificato un errore interno del server.");
        }
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ProductDto>> GetProductById(Guid id)
    {
        var query = new GetProductByIdQuery { Id = id };
        var product = await _mediator.Send(query);

        if (product == null)
        {
            return NotFound();
        }

        return Ok(product);
    }
}

Notiamo come il controller non ha dipendenze dirette da IProductRepository, IMapper o altri servizi. Dipende solo da IMediator. Questo è il disaccoppiamento in azione! La logica specifica di come creare o recuperare un prodotto è demandata agli handler.

Testare l’Applicazione

Eseguiamo l’applicazione (dotnet run). Posiamo usare Swagger UI (solitamente disponibile su https://localhost:<port>/swagger) o Postman/Insomnia.

Per creare un prodotto (POST /api/Products):

JSON

{
  "name": "Laptop XPS 15",
  "price": 1899.99,
  "stock": 10
}

Dovreste vedere nel log della console (grazie al LoggingBehavior e al ProductCreatedLoggerHandler):

  • ----- Inizio elaborazione Request: CreateProductCommand -----
  • Changes saved to 'database' (in-memory dictionary). (dalla repository)
  • [Notification] Prodotto 'Laptop XPS 15' (ID: ...) creato con successo. Prezzo: €1.899,99 (dalla notifica)
  • ----- Fine elaborazione Request: CreateProductCommand -----

E la risposta HTTP 201 Created con il ProductDto.

Per ottenere un prodotto (GET /api/Products/{id}):

Usate l’ID del prodotto appena creato.

Nel log dovremmo vedere nel log:

  • ----- Inizio elaborazione Request: GetProductByIdQuery -----
  • ----- Fine elaborazione Request: GetProductByIdQuery -----

E la risposta HTTP 200 OK con il ProductDto.

Test di Validazione (POST /api/Products con dati non validi):

JSON

{
  "name": "",
  "price": -10,
  "stock": -5
}

Dovremmo ottenere una risposta HTTP 400 Bad Request con gli errori di validazione:

JSON

{
  "errors": [
    "Il nome del prodotto è richiesto.",
    "Il prezzo deve essere maggiore di zero.",
    "Lo stock non può essere negativo."
  ]
}

Questo dimostra il ValidationBehavior in azione, che intercetta la richiesta prima che raggiunga l’handler.

4. Approfondimenti e Best Practice

Nomenclatura delle Richieste: Comandi (C) e Query (Q)

Un pattern comune che si sposa bene con MediatR è CQRS (Command Query Responsibility Segregation).

  • Comandi (Commands): Rappresentano operazioni che modificano lo stato dell’applicazione (es. CreateProductCommand, UpdateOrderCommand, DeleteUserCommand). Non restituiscono dati, se non per confermare l’avvenuta operazione (spesso Unit o un DTO minimale).
  • Query (Queries): Rappresentano operazioni che leggono lo stato dell’applicazione (es. GetProductByIdQuery, GetAllCustomersQuery). Non modificano lo stato e restituiscono dati.

Questa separazione logica aiuta a mantenere gli handler focalizzati e chiari sulla loro intenzione.

Limiti del Design Monolitico: Quando MediatR è più efficace

MediatR è estremamente utile in applicazioni di medie e grandi dimensioni, specialmente quelle che adottano un’architettura a strati con logica di business significativa. In un’applicazione molto piccola e semplice (un CRUD base con due tabelle), l’introduzione di MediatR potrebbe essere vista come “over-engineering”. Tuttavia, anche in questi casi, i benefici in termini di testabilità e chiarezza possono essere un vantaggio.

Strutturazione del Progetto: Organizzare Richieste e Handler

Una buona pratica è organizzare il codice in base alle funzionalità o al dominio, raggruppando richieste, handler, notifiche e validatori correlati.

Esempio di struttura:

YourProject/
├── Features/
│   ├── Products/
│   │   ├── Commands/
│   │   │   └── CreateProduct.cs  (Contiene il comando e il relativo handler annidato o separato)
│   │   │   └── UpdateProduct.cs
│   │   ├── Queries/
│   │   │   └── GetProductById.cs (Contiene la query e il relativo handler)
│   │   │   └── GetAllProducts.cs
│   │   ├── Notifications/
│   │   │   └── ProductCreated.cs (Contiene la notifica e i relativi handler)
│   │   ├── Validators/
│   │   │   └── CreateProductValidator.cs
│   │   └── ProductController.cs
│   ├── Orders/
│   │   ├── Commands/
│   │   ├── Queries/
│   │   └── ...
├── Behaviors/  (Global Behaviors)
│   ├── LoggingBehavior.cs
│   ├── ValidationBehavior.cs
├── DTOs/
├── Entities/
├── Data/
├── Mapping/
└── Program.cs

Questo approccio, spesso chiamato “vertical slice architecture” o “feature folders”, migliora la coesione e riduce l’accoppiamento orizzontale.

Gestione degli Errori: try-catch nei Pipeline Behaviors

È buona pratica gestire le eccezioni in modo centralizzato. Un IPipelineBehavior può fungere da gestore di eccezioni globale per le richieste.

public class ExceptionHandlingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<ExceptionHandlingBehavior<TRequest, TResponse>> _logger;

    public ExceptionHandlingBehavior(ILogger<ExceptionHandlingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        try
        {
            return await next();
        }
        catch (ValidationException ex)
        {
            _logger.LogWarning(ex, "Errore di validazione per la richiesta {RequestName}: {Errors}", typeof(TRequest).Name, string.Join(", ", ex.Errors.Select(e => e.ErrorMessage)));
            throw; // Rilancia per essere gestito da un middleware a livello di HTTP
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Errore durante l'elaborazione della richiesta {RequestName}", typeof(TRequest).Name);
            throw; // Rilancia o gestisci come appropriato (es. trasformare in un errore generico)
        }
    }
}

Questo behavior dovrebbe essere registrato prima del LoggingBehavior o ValidationBehavior se si vuole che catturi anche le loro eccezioni.

Transazioni di Database: Incapsulare logica transazionale

Per garantire l’atomicità delle operazioni, specialmente con Entity Framework Core, è possibile creare un behavior di transazione:

public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly AppDbContext _dbContext; // Il tuo DbContext

    public TransactionBehavior(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        // Controlla se la richiesta è un comando che modifica lo stato
        // Potresti usare un'interfaccia marker come ICommand
        if (!IsCommand(request)) return await next();

        await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
        try
        {
            var response = await next();
            await transaction.CommitAsync(cancellationToken);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }

    private bool IsCommand(TRequest request)
    {
        // Esempio: Controlla se la richiesta è un CreateProductCommand o UpdateProductCommand ecc.
        // O meglio, fai implementare un'interfaccia ICommand alle tue richieste di modifica
        return request.GetType().Name.EndsWith("Command");
    }
}

Questo behavior va registrato e posizionato nella pipeline in modo appropriato.

Validazione: Integrazione con FluentValidation

Come mostrato nell’esempio, FluentValidation si integra perfettamente con i Pipeline Behaviors, permettendoti di centralizzare la logica di validazione e di eseguirla automaticamente per ogni richiesta che ha un validatore associato.

Caching: Decoratori e Pipeline Behaviors

È possibile implementare la logica di caching in un Pipeline Behavior, verificando se una query è già stata risolta dalla cache prima di chiamare l’handler.

public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ICacheService _cacheService;
    // ... altri servizi per generare chiavi cache

    public CachingBehavior(ICacheService cacheService) // ICacheService è un servizio custom
    {
        _cacheService = cacheService;
    }

    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken)
    {
        if (request is ICacheableQuery cacheableQuery) // ICacheableQuery è un'interfaccia marker per le query che possono essere cachate
        {
            var cacheKey = cacheableQuery.CacheKey; // O generare la chiave in base alla richiesta
            var cachedResponse = await _cacheService.GetAsync<TResponse>(cacheKey);

            if (cachedResponse != null)
            {
                return cachedResponse;
            }

            var response = await next();
            await _cacheService.SetAsync(cacheKey, response, cacheableQuery.CacheDuration);
            return response;
        }

        return await next();
    }
}

Questa tecnica ti permette di applicare il caching a molte query con poco sforzo, semplicemente implementando un’interfaccia ICacheableQuery.

Logging e Monitoring: L’importanza dei Behaviors

I LoggingBehavior come quello mostrato sono cruciali per la visibilità dell’applicazione. Puoi estenderli per includere telemetria, misurare i tempi di esecuzione e integrare con sistemi di monitoring.

Sicurezza e Autorizzazione: Middleware o Behaviors

L’autorizzazione può essere gestita a diversi livelli:

  • Controller: Tramite attributi [Authorize]. Questo è spesso sufficiente per controlli di alto livello.
  • Pipeline Behaviors: Per un controllo più granulare basato sui dati della richiesta o su policy complesse. Un AuthorizationBehavior può controllare i permessi prima che la richiesta raggiunga l’handler.
  • Interno all’Handler: Se la logica di autorizzazione è intrinsecamente legata ai dati elaborati dall’handler.

Asincronicità: Gestione delle Operazioni Asincrone

MediatR supporta nativamente le operazioni asincrone. Tutti gli handler e behaviors dovrebbero implementare metodi Handle che restituiscono Task o Task<TResponse> e utilizzare await. Questo è fondamentale per la scalabilità delle applicazioni web, poiché evita di bloccare i thread del server.

5. Considerazioni Avanzate e Pattern Correlati

CQRS (Command Query Responsibility Segregation) e MediatR: Una Sintesi Perfetta

MediatR non è un’implementazione completa di CQRS, ma ne è un eccellente abilitatore. CQRS suggerisce di separare il modello per gli aggiornamenti (Comandi) da quello per le letture (Query). Questo significa che potreste avere stack tecnologici, database o modelli di dominio completamente diversi per le due operazioni.

MediatR fornisce il meccanismo di dispacciamento che rende questa separazione facile da implementare a livello di codice:

  • I comandi vengono inviati e gestiti da handler che modificano lo stato.
  • Le query vengono inviate e gestite da handler che recuperano lo stato. Questa separazione aiuta a ottimizzare sia le letture che le scritture in modo indipendente.

Event-Driven Architecture (Architettura Basata sugli Eventi) e Notifiche di MediatR: Cosa è uguale, cosa è diverso

Le notifiche di MediatR sono spesso confuse con gli eventi di un’architettura basata sugli eventi.

  • Notifiche MediatR: Sono eventi in-process. Tutti gli handler di notifica vengono eseguiti nello stesso processo dell’applicazione che ha pubblicato la notifica. Sono sincroni (anche se await gestisce la concurrency internamente, la chiamata di Publish attende il completamento di tutti gli handler per default, a meno che non si configuri un NotificationPublisher diverso). Sono ideali per la reazione immediata ad eventi all’interno dello stesso contesto di dominio o modulo.
  • Eventi (Architettura Event-Driven): Sono eventi inter-processo o distribuiti. Vengono pubblicati su un message broker (es. Kafka, RabbitMQ, Azure Service Bus) e possono essere consumati da altri servizi o microservizi autonomi. Sono intrinsecamente asincroni e offrono maggiore scalabilità e resilienza per sistemi distribuiti.

In sintesi: usate le notifiche MediatR per la comunicazione interna e gli eventi distribuiti per la comunicazione tra servizi. È comune che un handler di notifica MediatR, a sua volta, pubblichi un evento su un message broker.

Problemi Comuni e Soluzioni

  • Over-engineering per applicazioni semplici: Come menzionato, per piccole applicazioni CRUD, MediatR potrebbe aggiungere complessità non necessaria. Valutate sempre il trade-off.
  • Debug del Flusso: Con i behaviors e handler nidificati, il flusso di esecuzione può diventare meno ovvio rispetto a chiamate di metodo dirette. Gli strumenti di debug e un logging robusto sono essenziali.
  • Gestione di Handler multipli per la stessa Request (anti-pattern): Ogni IRequest<TResponse> deve avere esattamente un IRequestHandler. Se avete bisogno di più logica per una singola richiesta, considerate di suddividere la richiesta in richieste più piccole, usare notifiche o ristrutturare l’handler principale per orchestrare altre classi di servizio. Le notifiche sono il modo corretto per scenari “molti a molti”.

Alternativa a MediatR: Se esiste un’esigenza diversa

MediatR non è l’unica soluzione per il disaccoppiamento. Altre opzioni includono:

  • Event Aggregator Pattern: Simile alle notifiche di MediatR, ma meno focalizzato sulle richieste/risposte.
  • Iniezione delle Dipendenze diretta: Per dipendenze semplici, l’iniezione diretta tramite costruttore è ancora la scelta migliore. MediatR non sostituisce completamente la DI, ma la estende.
  • MassTransit/NServiceBus/Rebus: Per architetture basate su messaggi e code, ideali per sistemi distribuiti e microservizi. MediatR non è un message broker.

6. Conclusione

MediatR è una libreria straordinaria che porta un’enorme chiarezza e manutenibilità alle applicazioni C# di medie e grandi dimensioni. Implementando il Pattern Mediator, ci consente di disaccoppiare i componenti, aderire ai principi S.O.L.I.D. e abilitare pattern potenti come CQRS.

Attraverso la combinazione di richieste (Comandi e Query), handler, notifiche e pipeline behaviors, MediatR fornisce un’infrastruttura robusta per la comunicazione in-process. Ricordate di valutarne l’uso in base alla complessità del vostro progetto e di applicare le best practice per massimizzare i suoi benefici.

  • Articoli Correlati per Tag :