in Architettura Software, Informatica

Implementare CQRS in .NET Core C#: Un Approccio Pratico per Architetture Scalabili e Manutenibili

Nell’ingegneria del software moderna, la complessità delle applicazioni cresce esponenzialmente, portando spesso a monolitici difficili da scalare e mantenere. Pattern architetturali come CQRS (Command Query Responsibility Segregation) emergono come soluzioni potenti per affrontare queste sfide, separando esplicitamente le operazioni di scrittura (Command) da quelle di lettura (Query). Questo approccio non solo migliora la manutenibilità e la scalabilità, ma apre anche le porte a ottimizzazioni specifiche per ciascun flusso.

In questo post, esploreremo come implementare CQRS utilizzando .NET Core C#, fornendo esempi pratici per aiutare gli sviluppatori a integrare questo pattern nelle loro architetture.

Cos’è CQRS e Perché Usarlo?

CQRS è un pattern che separa il modello per le operazioni di lettura dei dati (Query) dal modello per le operazioni di scrittura dei dati (Command). Le ragioni principali per adottarlo includono:

  • Scalabilità Indipendente: È possibile scalare i carichi di lavoro di lettura e scrittura in modo indipendente, ottimizzando le risorse.
  • Performance Ottimizzate: I modelli di lettura possono essere denormalizzati e ottimizzati per query veloci, mentre i modelli di scrittura possono essere progettati per garantire la coerenza transazionale.
  • Manutenibilità Migliorata: La separazione delle responsabilità riduce la complessità del codice e facilita lo sviluppo e la manutenzione.
  • Flussi di Lavoro Chiari: I processi di business sono modellati in modo più esplicito attraverso comandi e query.
  • Supporto per Event Sourcing: CQRS si integra naturalmente con il pattern Event Sourcing, dove ogni modifica di stato è memorizzata come una sequenza di eventi immutabili.

Componenti Chiave di CQRS

  1. Commands: Oggetti che rappresentano un’intenzione di cambiare lo stato del sistema (es. CreateProductCommand, UpdateOrderCommand). Sono imperativi e non restituiscono dati.
  2. Command Handlers: Classi che ricevono un Command e contengono la logica di business per eseguire l’operazione di scrittura e persistere le modifiche.
  3. Queries: Oggetti che rappresentano una richiesta di dati (es. GetProductByIdQuery, GetAllOrdersQuery). Non modificano lo stato del sistema.
  4. Query Handlers: Classi che ricevono una Query e contengono la logica per recuperare e restituire i dati richiesti.
  5. Mediator: Un componente (spesso implementato tramite librerie come MediatR) che instrada i Commands e le Queries ai rispettivi Handlers, disaccoppiando il mittente dal ricevitore.

Esempio Pratico in .NET Core C#

Consideriamo un’applicazione di gestione prodotti.

1. Definizione delle Interfacce Generiche (Opzionale ma Raccomandato)

Per una maggiore flessibilità e coerenza, possiamo definire interfacce generiche per Commands, Queries, e i loro Handlers. Qui utilizzeremo MediatR per la sua semplicità ed efficienza.

// Installare il pacchetto NuGet: Install-Package MediatR.Extensions.Microsoft.DependencyInjection

using MediatR; // Assicurati di avere questo using

// Per i Commands
public interface ICommand : IRequest { } // Command senza valore di ritorno
public interface ICommand<out TResponse> : IRequest<TResponse> { } // Command con valore di ritorno

// Per le Queries
public interface IQuery<out TResponse> : IRequest<TResponse> { }

2. I Commands e i loro Handlers

Command:

// Commands/CreateProductCommand.cs
public class CreateProductCommand : ICommand<int>
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

Command Handler:

C#

// CommandHandlers/CreateProductCommandHandler.cs
using MediatR;
using System.Threading;
using System.Threading.Tasks;

public class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, int>
{
    private readonly IProductRepository _productRepository; // Supponiamo un repository
    // Iniezione di dipendenza per il repository, o per un contesto ORM (es. DbContext)

    public CreateProductCommandHandler(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<int> Handle(CreateProductCommand request, CancellationToken cancellationToken)
    {
        // Logica di business per la creazione del prodotto
        var newProduct = new Product
        {
            Name = request.Name,
            Price = request.Price,
            Quantity = request.Quantity
        };

        await _productRepository.AddProductAsync(newProduct);
        await _productRepository.SaveAsync(); // Persistenza

        return newProduct.Id; // Restituisce l'ID del prodotto creato
    }
}

3. Le Queries e i loro Handlers

Query:

// Queries/GetProductByIdQuery.cs
public class GetProductByIdQuery : IQuery<ProductDto>
{
    public int ProductId { get; set; }
}

// Data Transfer Object per la Query
public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}

Query Handler:

// QueryHandlers/GetProductByIdQueryHandler.cs
using MediatR;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore; // Esempio con EF Core

public class GetProductByIdQueryHandler : IRequestHandler<GetProductByIdQuery, ProductDto>
{
    private readonly ApplicationDbContext _dbContext; // Supponiamo un DbContext

    public GetProductByIdQueryHandler(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<ProductDto> Handle(GetProductByIdQuery request, CancellationToken cancellationToken)
    {
        // Logica per recuperare il prodotto
        var product = await _dbContext.Products
                                      .AsNoTracking() // Ottimizzazione per le letture
                                      .FirstOrDefaultAsync(p => p.Id == request.ProductId, cancellationToken);

        if (product == null)
        {
            return null; // O sollevare un'eccezione, a seconda della strategia
        }

        return new ProductDto
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Quantity = product.Quantity
        };
    }
}

4. Configurazione in .NET Core (Startup.cs o Program.cs)

Per registrare MediatR e i vostri Handlers, potete usare IServiceCollection in Program.cs o Startup.cs.

// In Program.cs o Startup.cs
using MediatR;
using System.Reflection; // Necessario per l'assembly scanning

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ... altre configurazioni

        // Registra MediatR e cerca gli Handlers nell'assembly corrente
        services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(Assembly.GetExecutingAssembly()));

        // Registra i tuoi repository o DbContext
        services.AddScoped<IProductRepository, ProductRepository>();
        services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer("YourConnectionString"));

        // ...
    }
}

5. Utilizzo nel Controller (o in un Service Layer)

// Controllers/ProductsController.cs
using MediatR;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;

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

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

    [HttpPost]
    public async Task<IActionResult> CreateProduct([FromBody] CreateProductCommand command)
    {
        var productId = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetProductById), new { productId = productId }, command);
    }

    [HttpGet("{productId}")]
    public async Task<IActionResult> GetProductById(int productId)
    {
        var query = new GetProductByIdQuery { ProductId = productId };
        var product = await _mediator.Send(query);

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

        return Ok(product);
    }
}

Considerazioni Avanzate

  • Validazione: Implementare la validazione dei Command all’interno dei Command Handlers o tramite pipeline di MediatR.
  • Gestione degli Errori: Strategie robuste per la gestione degli errori e il logging.
  • Transazioni: Assicurarsi che le operazioni di scrittura all’interno di un Command Handler siano transazionali.
  • Event Sourcing e CQRS: CQRS è spesso abbinato a Event Sourcing per creare sistemi altamente scalabili e auditable.
  • Read Models Dedicati: Per scenari complessi, si possono avere modelli di lettura completamente denormalizzati e ottimizzati per specifiche query, magari persistiti in database diversi (es. NoSQL per le letture veloci, SQL per le scritture transazionali).

Conclusione

L’adozione di CQRS in .NET Core C# offre un framework robusto per costruire applicazioni con architetture chiare, scalabili e manutenibili. Separando le responsabilità di Command e Query, è possibile ottimizzare ogni aspetto del sistema per le sue esigenze specifiche, portando a soluzioni più performanti e facili da evolvere. Sebbene l’implementazione iniziale possa richiedere un piccolo sforzo aggiuntivo, i benefici a lungo termine in termini di architettura e resilienza del sistema sono inestimabili.