Quando lo stesso modello deve fare due mestieri opposti
Una API classica usa un unico modello per scrivere e per leggere. Funziona finché i due bisogni coincidono, poi si divaricano. Chi scrive vuole regole rigorose, un modello normalizzato e transazioni; chi legge vuole una riga già pronta per la schermata «I miei ordini», con nome del cliente, totale e numero di articoli, senza tre JOIN a ogni richiesta. Se usi lo stesso modello per entrambi ottieni le query lente di uno o le tabelle gonfie dell'altro.
CQRS (Command Query Responsibility Segregation) separa i due mestieri in due modelli: il lato dei comandi modifica lo stato e applica le regole, il lato delle query legge da una forma pensata per chi legge. Un meccanismo di sincronizzazione, di solito asincrono, tiene allineati i due. È una decisione di costo: Martin Fowler avverte che CQRS è un salto mentale rilevante e che non va affrontato se il beneficio non lo vale. Questo articolo costruisce il meccanismo completo in piccolo, con il lato scrittura, la vista di lettura, la proiezione che le allinea e il comportamento che l'utente vede nell'intervallo in cui non lo sono.
Comando e query: la regola che viene da Meyer
CQRS estende un principio più vecchio, la separazione comando-query di Bertrand Meyer: un'operazione o cambia lo stato o
restituisce un dato, non entrambe le cose. Un metodo reserveInventory che prenota le scorte e restituisce lo stato
aggiornato mescola le due strade; il comando dovrebbe solo prenotare, e chi vuole il livello di scorta lo chiede con una query.
Fowler segnala che Meyer applica la regola in modo assoluto ma ammette eccezioni, come estrarre un elemento da una pila (una lettura che modifica). In un sistema asincrono la separazione smette di essere un'opinione di stile: dopo il comando la vista di lettura può non essere ancora aggiornata, quindi restituire da quella uno stato come risposta al comando sarebbe sbagliato.
Il sistema completo in un solo programma
Il programma usa SQLite in memoria e una coda al posto di un broker. Le tabelle orders,
order_items e customers sono il lato scrittura, normalizzato; order_list_view è il lato
lettura, con una riga per ordine. In produzione la vista starebbe tipicamente in un database o in un servizio separato, qui
sta nello stesso file per semplicità. Non c'è event sourcing: il lato scrittura salva lo stato, e la proiezione lo rilegge.
È il modo più semplice di fare CQRS, e basta per mostrare tutti i comportamenti che contano.
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(`
-- Lato scrittura: normalizzato, e' la fonte di verita'.
CREATE TABLE customers (id TEXT PRIMARY KEY, full_name TEXT NOT NULL);
CREATE TABLE orders (id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL);
CREATE TABLE order_items (order_id TEXT NOT NULL, product_id TEXT NOT NULL, quantity INTEGER NOT NULL, unit_price REAL NOT NULL);
-- Lato lettura: una riga per la schermata "I miei ordini", niente JOIN al momento della query.
CREATE TABLE order_list_view (order_id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, customer_name TEXT NOT NULL,
total REAL NOT NULL, item_count INTEGER NOT NULL, status TEXT NOT NULL, version INTEGER NOT NULL);
`);
db.prepare('INSERT INTO customers VALUES (?, ?)').run('cli-3', 'Maria Rossi');
type DomainEvent = { type: 'OrderPlaced' | 'OrderCancelled' | 'CustomerRenamed'; orderId?: string; customerId?: string };
const queue: DomainEvent[] = []; // al posto di un broker: la coda tra i due lati
// ---- Command side: valida, scrive, annuncia. Non restituisce dati di lettura.
function placeOrder(orderId: string, customerId: string, items: { productId: string; quantity: number; unitPrice: number }[]): number {
if (items.length === 0) throw new Error('Ordine senza articoli');
if (items.some((i) => i.quantity <= 0)) throw new Error('Quantita non valida');
db.exec('BEGIN');
db.prepare('INSERT INTO orders VALUES (?, ?, ?, ?)').run(orderId, customerId, 'Confirmed', 1);
for (const i of items) db.prepare('INSERT INTO order_items VALUES (?, ?, ?, ?)').run(orderId, i.productId, i.quantity, i.unitPrice);
db.exec('COMMIT');
queue.push({ type: 'OrderPlaced', orderId });
return 1; // la versione prodotta: il client la usera' per sapere quando la lettura e' aggiornata
}
function cancelOrder(orderId: string): number {
db.prepare("UPDATE orders SET status = 'Cancelled', version = version + 1 WHERE id = ?").run(orderId);
queue.push({ type: 'OrderCancelled', orderId });
return (db.prepare('SELECT version FROM orders WHERE id = ?').get(orderId) as { version: number }).version;
}
function renameCustomer(customerId: string, fullName: string): void {
db.prepare('UPDATE customers SET full_name = ? WHERE id = ?').run(fullName, customerId);
queue.push({ type: 'CustomerRenamed', customerId });
}
// ---- Projection: un evento alla volta, riscrive la vista a partire dal lato scrittura.
function tick(): boolean {
const e = queue.shift();
if (!e) return false;
if (e.type === 'OrderPlaced' || e.type === 'OrderCancelled') {
db.prepare(`INSERT OR REPLACE INTO order_list_view
SELECT o.id, o.customer_id, c.full_name, SUM(i.quantity * i.unit_price), COUNT(*), o.status, o.version
FROM orders o JOIN customers c ON c.id = o.customer_id JOIN order_items i ON i.order_id = o.id
WHERE o.id = ? GROUP BY o.id`).run(e.orderId!);
} else {
db.prepare('UPDATE order_list_view SET customer_name = (SELECT full_name FROM customers WHERE id = ?) WHERE customer_id = ?')
.run(e.customerId!, e.customerId!);
}
return true;
}
// ---- Query side: solo SELECT sulla vista.
const lista = (customerId: string) =>
db.prepare('SELECT order_id, customer_name, total, item_count, status, version FROM order_list_view WHERE customer_id = ?').all(customerId);
// Il client conosce la versione che il comando ha prodotto e aspetta che la vista la raggiunga.
// Qui l'attesa e' "fai girare la proiezione una volta"; in produzione e' una pausa reale.
function aspettaVersione(orderId: string, version: number, maxTentativi = 10): number {
for (let t = 1; t <= maxTentativi; t++) {
const r = db.prepare('SELECT version FROM order_list_view WHERE order_id = ?').get(orderId) as { version: number } | undefined;
if (r && r.version >= version) return t;
tick();
}
throw new Error('Vista non aggiornata entro il limite');
}
const v1 = placeOrder('o-1', 'cli-3', [{ productId: 'penna', quantity: 2, unitPrice: 3.5 }, { productId: 'quaderno', quantity: 1, unitPrice: 4 }]);
console.log('subito dopo il comando :', JSON.stringify(lista('cli-3')));
console.log('tentativi per vedere v' + v1 + ' :', aspettaVersione('o-1', v1));
console.log('dopo la proiezione :', JSON.stringify(lista('cli-3')));
renameCustomer('cli-3', 'Maria Bianchi');
console.log('nome dopo il rename (vista):', (lista('cli-3')[0] as { customer_name: string }).customer_name);
tick();
console.log('nome dopo la proiezione :', (lista('cli-3')[0] as { customer_name: string }).customer_name);
const v2 = cancelOrder('o-1');
console.log('tentativi per vedere v' + v2 + ' :', aspettaVersione('o-1', v2));
console.log('stato finale :', (lista('cli-3')[0] as { status: string }).status);
try { placeOrder('o-2', 'cli-3', [{ productId: 'penna', quantity: 0, unitPrice: 3.5 }]); } catch (e) { console.log('rifiutato:', (e as Error).message, '| eventi in coda:', queue.length); }
// Output:
// subito dopo il comando : []
// tentativi per vedere v1 : 2
// dopo la proiezione : [{"order_id":"o-1","customer_name":"Maria Rossi","total":11,"item_count":2,"status":"Confirmed","version":1}]
// nome dopo il rename (vista): Maria Rossi
// nome dopo la proiezione : Maria Bianchi
// tentativi per vedere v2 : 2
// stato finale : Cancelled
// rifiutato: Quantita non valida | eventi in coda: 0
Tre cose da guardare nell'uscita. Subito dopo placeOrder la vista è vuota: il comando ha accodato un evento, ma
nessuno l'ha ancora proiettato. Questo è il periodo di inconsistenza che CQRS asincrono introduce, e non è un malfunzionamento.
La funzione aspettaVersione è la risposta più semplice: il comando restituisce la versione che ha prodotto
(1), e il client aspetta che la vista arrivi a quella versione; qui l'attesa è tick(), cioè lasciare
lavorare la proiezione, in produzione è una pausa reale con un limite di tentativi.
La seconda cosa: dopo
renameCustomer il nome nella vista resta quello vecchio finché l'evento non viene proiettato, perché la
denormalizzazione copia i dati e li fa invecchiare. Terza: la richiesta con quantità zero è rifiutata dal lato comandi e non
mette nessun evento in coda, quindi la validazione sta prima della scrittura, non nella vista.
Una difficoltà che il programma nasconde: placeOrder scrive nel database e poi mette l'evento in coda.
Se il processo si ferma tra le due righe, l'ordine esiste e la vista non lo saprà mai. Su un broker vero questa è la doppia
scrittura che l'outbox pattern risolve, e va risolta prima
di mettere CQRS in produzione. Un'altra: la proiezione rilegge lo stato dal lato scrittura invece di fidarsi dell'evento, il che
è più semplice ma la accoppia al database dei comandi; con evento ricco di dati si può evitare, a prezzo di eventi più grossi,
come vedi nel confronto sulla forma degli eventi.
Vista di lettura: tabella, vista materializzata, cache
Il modello di lettura non deve essere un database a parte. Esistono tre forme, in ordine crescente di distacco dal lato scrittura:
- una vista materializzata nello stesso database, che richiede un
REFRESHe non ha messaggi in mezzo; - una tabella di lettura mantenuta da una proiezione, come nel programma sopra, nello stesso database o in uno diverso;
- un archivio dedicato, come una cache o un motore di ricerca, alimentato dagli eventi.
Ordinale per quanto costano da gestire: la prima è quasi gratis, la terza richiede un componente da gestire e da riallineare in
caso di errore. Una cautela sulla prima: la documentazione di PostgreSQL dice che REFRESH MATERIALIZED VIEW
sostituisce completamente il contenuto della vista, e che con l'opzione CONCURRENTLY si aggiorna senza bloccare le
letture concorrenti.
Non esiste un refresh incrementale automatico nel comando: un articolo che lo promette descrive qualcosa di diverso da ciò che il comando fa. Per ordini di grandezza («quante volte più veloce») non c'è una cifra universale: dipende dalle query, dai dati e dagli indici, e va misurata sul tuo carico.
Scalare le due parti separatamente
Il guadagno promesso da CQRS è scalare i due lati in modo indipendente. In un negozio le letture sono molte volte le scritture: puoi dare al lato comandi poche istanze con un database primario, e al lato query tante istanze senza stato che leggono da repliche o da una cache. La separazione consente di farlo senza cambiare il modello dell'altra parte.
Ma non è il motivo per cui conviene adottarlo da subito: se non hai mai avuto un collo di bottiglia in lettura, una replica di sola lettura e un indice in più costano molto meno di due modelli da sincronizzare. Usalo quando i due modelli sono davvero diversi, non quando sono uguali ma lenti.
Quando CQRS è una cattiva idea
Il costo è concreto: più componenti da distribuire (lato comandi, lato query, proiezione, database di lettura), la consistenza eventuale da spiegare all'interfaccia, e validazioni che a volte devono comparire in due punti. Il segnale per non farlo è un dominio CRUD senza regole di business complesse, dove un solo modello ben indicizzato basta. Fowler indica che CQRS si adatta ai domini complessi, quelli che traggono vantaggio anche dal Domain-Driven Design, e l'Azure Architecture Center avverte che la sincronizzazione porta alla consistenza eventuale.
- le viste di lettura somigliano molto al modello di scrittura: non ti serve;
- l'interfaccia non tollera un ritardo, nemmeno breve: serve un'alternativa o l'attesa della versione;
- il team non può gestire un componente in più: parti da una vista materializzata.
Sono tre domande da porsi in quest'ordine. Se la prima risposta è sì, la discussione finisce lì. Se la seconda è sì, puoi comunque procedere con l'attesa della versione mostrata sopra o con un aggiornamento ottimistico dell'interfaccia (mostri subito il risultato atteso e lo riconcilii quando la vista è pronta). La terza è la più sottovalutata: il costo operativo di un database di lettura da riallineare dopo un errore può superare quello di scrittura del codice.
Che cosa sai fare adesso
Sai dividere un sistema in lato comandi e lato query, costruire una vista di lettura con una proiezione e riconoscere i due sintomi della consistenza eventuale: la vista vuota subito dopo un comando e il dato copiato che invecchia. Sai gestire il ritardo aspettando la versione prodotta dal comando. Per usare gli eventi come fonte della verità del lato scrittura, il passo successivo è Event Sourcing + CQRS; per l'esperienza d'uso quando la vista è in ritardo, l'articolo sulla consistenza eventuale. Chi arriva dagli eventi può ripartire da Event Sourcing.
Fonti
- Azure Architecture Center, CQRS pattern - separazione dei modelli di lettura e scrittura, consistenza eventuale come conseguenza, consultata il 2026-11-12
- Martin Fowler, CQRS - avvertenza sul costo concettuale e su quando non adottarlo, consultata il 2026-11-12
- Martin Fowler, Command Query Separation - attribuzione del principio a Bertrand Meyer ed eccezioni come l'estrazione da una pila, consultata il 2026-11-12
- PostgreSQL, REFRESH MATERIALIZED VIEW - sostituzione completa del contenuto e opzione
CONCURRENTLY, consultata il 2026-11-12
Il programma è stato eseguito con Node.js v24.11.0 e il modulo sperimentale node:sqlite; l'output nei commenti è quello reale. Nessun broker né database di produzione: la coda è un array in memoria.

Commenti
Caricamento commenti...
Accedi per commentare