Introduzione a CQRS (Command Query Responsibility Segregation)

CQRS (Command Query Responsibility Segregation) è un pattern architetturale che suggerisce di separare in modo netto le operazioni di scrittura (command) da quelle di lettura (query) su un sistema. Invece di avere un unico modello di dominio e un unico percorso di accesso ai dati, si definiscono percorsi ottimizzati e spesso modelli differenti per leggere e per modificare lo stato dell'applicazione.

Perché nasce CQRS

In molte applicazioni tradizionali (approccio CRUD) lo stesso modello viene usato per:

  • Mostrare dati all'utente (lettura);
  • Modificare e salvare dati (scrittura);
  • Gestire regole di business, validazioni, sicurezza.

Questo approccio funziona bene finché il dominio è semplice. Quando la complessità cresce, emergono diversi problemi:

  • Modelli gonfi e difficili da mantenere: un singolo modello deve soddisfare esigenze molto diverse.
  • Query complicate e lente: si finisce per scrivere query molto complesse per soddisfare le esigenze di reporting e UI.
  • Difficoltà a scalare: lettura e scrittura hanno spesso requisiti di scalabilità differenti, ma vengono trattate allo stesso modo.

CQRS propone di affrontare questi problemi separando nettamente il modello e il percorso di lettura da quelli di scrittura.

I principi di base di CQRS

1. Separazione tra Command e Query

Il principio di base è: i metodi che modificano lo stato (command) non restituiscono dati di dominio, mentre i metodi che leggono dati (query) non modificano lo stato.

  • Command: eseguono azioni che cambiano lo stato del sistema (crea ordine, aggiorna indirizzo, annulla prenotazione...).
  • Query: recuperano dati per l'utente o per altri sistemi (lista ordini, dettaglio cliente, report, dashboard...).

2. Modelli separati

Con CQRS è normale avere:

  • Un modello di dominio ricco per la scrittura, centrato sulle regole di business.
  • Uno o più modelli di lettura ottimizzati per le esigenze di visualizzazione e reporting.

Il modello di lettura può essere molto semplice (ad esempio DTO piatti) e mappato direttamente sulle esigenze della UI.

3. Canali e storage differenti (opzionale ma comune)

La separazione tra lettura e scrittura può arrivare fino al livello di storage:

  • Database ottimizzato per la scrittura e la consistenza delle regole di business.
  • Database o viste ottimizzate per la lettura (denormalizzate, materializzate, indicizzate per la UI).

Talvolta si usano tecnologie differenti per i due mondi: ad esempio un database relazionale per la scrittura e un database documentale o un motore di ricerca per la lettura.

Esempio concettuale

Immaginiamo un sistema di gestione ordini. In un approccio CRUD tradizionale potremmo avere un servizio con metodi come:


// Esempio semplificato in stile CRUD
public class OrderService
{
    public Order GetOrder(int id) { /* ... */ }

    public List<Order> GetOrdersForCustomer(int customerId) { /* ... */ }

    public void SaveOrder(Order order) { /* ... */ }

    public void UpdateOrderStatus(int orderId, OrderStatus status) { /* ... */ }
}

Con CQRS separiamo i percorsi:

  • Un CommandHandler per gestire la richiesta di creare/modificare un ordine.
  • Un QueryHandler per recuperare gli ordini in una forma pronta per la UI.

// Comando: crea un nuovo ordine
public record CreateOrderCommand(int CustomerId, List<OrderItemDto> Items);

// Gestore del comando
public class CreateOrderCommandHandler
{
    public Task Handle(CreateOrderCommand command)
    {
        // 1. Carica il cliente e le informazioni necessarie
        // 2. Applica le regole di business (limiti di credito, disponibilità prodotti, ecc.)
        // 3. Crea l'aggregato Order e lo persiste
    }
}

// Query: ottieni ordini per un cliente
public record GetOrdersForCustomerQuery(int CustomerId);

// Gestore della query
public class GetOrdersForCustomerQueryHandler
{
    public Task<IReadOnlyList<CustomerOrderReadModel>> Handle(GetOrdersForCustomerQuery query)
    {
        // Esegue query su un modello di lettura (ad esempio viste denormalizzate)
    }
}

CQRS e Event Sourcing

CQRS viene spesso (ma non necessariamente) abbinato a Event Sourcing. Con Event Sourcing lo stato degli oggetti di dominio non è salvato come snapshot finale, ma come sequenza di eventi immutabili che descrivono cosa è successo nel tempo (OrderCreated, ItemAddedToOrder, OrderShipped...).

In questo scenario, il flusso tipico è:

  1. Un comando viene ricevuto (es. CreaOrdine).
  2. Il comando viene applicato al modello di dominio.
  3. Il modello genera eventi di dominio (es. OrdineCreato).
  4. Gli eventi vengono salvati nello store eventi.
  5. Processi separati ascoltano gli eventi e aggiornano uno o più modelli di lettura.

La combinazione CQRS + Event Sourcing permette:

  • Audit completo delle modifiche (è possibile ricostruire lo stato nel tempo).
  • Facilità nel costruire proiezioni differenti per la lettura.
  • Possibilità di reprocessare gli eventi per creare nuovi modelli di lettura.

Modelli di lettura e proiezioni

Un modello di lettura (read model) è una rappresentazione dei dati ottimizzata per una certa vista o use case. Viene spesso chiamato anche proiezione.

Esempi:

  • Dashboard con conteggi aggregati per giorno/mese.
  • Timeline degli eventi di un cliente.
  • Elenco ordini pronto per essere mostrato in tabella con colonne, filtri e ordinamenti specifici.

In CQRS, i modelli di lettura vengono aggiornati reagendo agli eventi provenienti dal lato scrittura.


// Esempio di proiezione semplificata
public class OrdersProjection
{
    // Metodo chiamato quando viene emesso un evento OrderCreated
    public Task When(OrderCreated e)
    {
        // Inserisce una riga nella tabella OrdersReadModel
    }

    // Metodo chiamato quando viene emesso un evento OrderStatusChanged
    public Task When(OrderStatusChanged e)
    {
        // Aggiorna lo stato nella tabella OrdersReadModel
    }
}

Consistenza: immediata vs eventuale

Quando lettura e scrittura sono separate, è comune accettare una consistenza eventuale:

  • Subito dopo un comando di scrittura, il modello di lettura potrebbe non essere ancora aggiornato.
  • Dopo un breve tempo (millisecondi o secondi) i modelli di lettura vengono allineati tramite gli eventi.

È fondamentale progettare l'esperienza utente considerando questa eventuale latenza:

  • Mostrare messaggi di stato (es. "Ordine in elaborazione").
  • Gestire cache e refresh automatici.
  • Limitare i casi in cui la consistenza deve essere immediata, ad esempio per vincoli forti di business.

Vantaggi di CQRS

  • Maggiore chiarezza del codice: la separazione dei ruoli rende il codice più leggibile e comprensibile.
  • Modelli ottimizzati: si possono modellare lettura e scrittura in modo indipendente sulle rispettive esigenze.
  • Scalabilità: è possibile scalare indipendentemente lettura e scrittura, anche usando tecnologie diverse.
  • Testabilità: command e query handler sono spesso facili da testare in isolamento.
  • Flessibilità evolutiva: è possibile aggiungere nuovi modelli di lettura senza toccare il modello di scrittura.

Svantaggi e costi

CQRS introduce anche complessità aggiuntiva, quindi non è adatto a tutti i progetti. Alcuni svantaggi:

  • Maggiore complessità architetturale: più componenti (handler, bus di messaggi, proiezioni, modelli di lettura...).
  • Consistenza eventuale: serve maggiore attenzione nella UX e nella gestione dei casi limite.
  • Integrazione e debug più complessi: la presenza di code di messaggi o processi asincroni rende più difficile seguire il flusso end-to-end.
  • Non necessario per domini semplici: in molti casi un buon design orientato al dominio e un CRUD pulito sono sufficienti.

Quando ha senso usare CQRS

CQRS può essere una scelta sensata quando:

  • Il dominio è complesso e ricco di regole di business.
  • Requisiti di lettura e scrittura sono molto diversi (es. tantissime letture, poche scritture pesanti).
  • Servono viste molto ottimizzate per la UI o per il reporting.
  • È importante poter evolvere nel tempo le viste senza toccare il core di dominio.
  • Si prevede di usare Event Sourcing o un modello a eventi.

Al contrario, CQRS potrebbe essere eccessivo se:

  • L'applicazione è piccola o a breve vita.
  • La complessità del dominio è bassa.
  • Le performance attese sono gestibili con un normale approccio CRUD ben progettato.

Pattern e tecnologie spesso usati con CQRS

  • Event Sourcing: per persistere eventi anziché stato aggregato.
  • Message bus / code di messaggi: per propagare eventi e comandi tra componenti.
  • DDD (Domain-Driven Design): per modellare il dominio in modo ricco sul lato scrittura.
  • Database multipli: un DB per la scrittura, uno o più per la lettura.

Esempio più completo di pipeline CQRS

Immaginiamo il flusso di creazione di un ordine in un sistema CQRS + Event Sourcing:

  1. La UI invia un CreateOrderCommand all'API.
  2. L'API valida i dati di base e instrada il comando verso il CreateOrderCommandHandler.
  3. Il command handler carica eventuali dati necessari (cliente, prodotti...).
  4. Il modello di dominio applica le regole di business e genera un evento OrderCreated.
  5. L'evento viene salvato nello store eventi.
  6. Un processo di proiezione ascolta l'evento e aggiorna il modello di lettura (es. tabella OrdersReadModel).
  7. Quando l'utente chiede la lista degli ordini, una query legge direttamente da OrdersReadModel.

// Pseudo-codice semplificato della pipeline di comando
public class CreateOrderCommandHandler
{
    private readonly IOrderRepository _repository;
    private readonly IEventBus _eventBus;

    public async Task Handle(CreateOrderCommand command)
    {
        var order = new Order(command.CustomerId);

        foreach (var item in command.Items)
        {
            order.AddItem(item.ProductId, item.Quantity, item.UnitPrice);
        }

        // Il dominio potrebbe generare eventi interni
        var events = order.GetUncommittedEvents();

        // Persisto l'aggregato o gli eventi
        await _repository.Save(order);

        // Pubblico gli eventi sul bus
        await _eventBus.Publish(events);
    }
}

Linee guida pratiche

Alcuni suggerimenti per introdurre CQRS in modo pragmatico:

  1. Inizia semplice: separa logica di lettura e scrittura a livello di codice (handler, servizi) senza introdurre subito Event Sourcing o database multipli.
  2. Introdurre CQRS solo dove serve: applicalo ai bounded context o ai moduli più complessi, non per forza a tutto il sistema.
  3. Automatizza la sincronizzazione dei read model: se usi eventi, assicurati di avere strumenti per monitorare e riprocessare le proiezioni.
  4. Progetta la UX tenendo conto della consistenza eventuale: messaggi chiari, refresh dei dati, gestione di stati intermedi.

Conclusione

CQRS è un pattern potente per gestire domini complessi, migliorare la scalabilità e rendere il codice più chiaro separando responsabilità di lettura e scrittura. Tuttavia introduce complessità: va adottato solo quando porta un beneficio concreto e non come soluzione predefinita per qualsiasi applicazione.

Un approccio pragmatico è partire da una separazione logica di command e query all'interno del codice (ad esempio con handler dedicati) e solo successivamente, se necessario, evolvere verso modelli di lettura separati, Event Sourcing e infrastrutture più sofisticate.

Torna su