CQRS: afzonderlijk lezen en schrijven voor onafhankelijke schaling
Een klassieke REST API gebruikt hetzelfde model om gegevens te lezen en te schrijven. Maar de behoeften lezen en schrijven zijn vaak heel verschillend: schrijven moet consistentie garanderen en valideren van complexe bedrijfsregels; Leesbewerkingen moeten snel, schaalbaar en retournerend zijn gegevens in UI-geoptimaliseerde formaten. Probeer er beide behoeften mee te bevredigen hetzelfde model leidt tot compromissen: complexe query's voor weergaven, of "opgeblazen" modellen om elk type lezen te ondersteunen.
CQRS (Command Query Verantwoordelijkheid Segregatie) scheidt expliciet de schrijfmodel (commando kant) uit het leesmodel (vraagzijde). De commandokant beheert bewerkingen die de status veranderen (maken, bijwerken, verwijderen); de queryzijde verwerkt leesbewerkingen, meestal met geoptimaliseerde datamodellen voor specifieke toepassingsweergaven. Synchronisatie tussen de twee vindt plaats via asynchrone gebeurtenissen of projecties.
Wat je gaat leren
- CQRS-architectuur: opdrachtzijde, queryzijde, asynchrone synchronisatie
- Implementeer een opdrachthandler in TypeScript met validatie
- Projecties: bouw leesmodellen geoptimaliseerd voor weergaven
- Asynchrone synchronisatie via gebeurtenissen (Event Sourcing + CQRS)
- CQRS zonder Event Sourcing: vereenvoudigd hybride model
- Onafhankelijke lees- en schrijfschaling
- Trade-off: mogelijke consistentie en operationele complexiteit
CQRS-architectuur
In CQRS is elke bewerking of a Commando (status wijzigen) of één Vragen (leest de staat zonder deze te wijzigen). Nooit allebei tegelijk methode (CQS-principe van Bertrand Meyer):
// WRONG: un metodo che fa sia command che query
async function reserveInventory(productId: string, quantity: number): Promise<Stock> {
const stock = await db.getStock(productId);
stock.reserved += quantity; // MODIFICA
await db.saveStock(stock);
return stock; // LETTURA
}
// RIGHT CQRS: separa command e query
async function reserveInventory(productId: string, quantity: number): Promise<void> {
// Command: solo modifica, nessun ritorno di stato
const stock = await stockRepo.findById(productId);
stock.reserve(quantity);
await stockRepo.save(stock);
// Pubblica evento per aggiornare il read model
await eventBus.publish(new InventoryReservedEvent(productId, quantity));
}
async function getStockLevel(productId: string): Promise<StockLevelDto> {
// Query: legge dal read model ottimizzato, ZERO side effects
return await stockReadModel.findById(productId);
}
De commandokant: commandobeheer
De commandozijde ontvangt Commando (onveranderlijke objecten die een intentie beschrijven), li geldig, voert bedrijfslogica uit op het aggregaat en publiceert gebeurtenissen. Het patroon Commandobehandelaar en de component die deze stroom coördineert:
// ---- COMMANDS ----
// Ogni command e un DTO immutabile
class PlaceOrderCommand {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly items: ReadonlyArray<{
productId: string;
quantity: number;
}>,
public readonly shippingAddress: Readonly<Address>
) {}
}
// ---- COMMAND HANDLER ----
class PlaceOrderCommandHandler {
constructor(
private readonly orderRepo: OrderRepository,
private readonly productRepo: ProductRepository,
private readonly eventBus: EventBus
) {}
async handle(command: PlaceOrderCommand): Promise<void> {
// 1. Validazione: prodotti esistono?
const products = await Promise.all(
command.items.map((item) => this.productRepo.findById(item.productId))
);
if (products.some((p) => p === null)) {
throw new Error('One or more products not found');
}
// 2. Crea l'Aggregate (logica business)
const order = new OrderAggregate();
order.create(command.orderId, command.customerId);
for (let i = 0; i < command.items.length; i++) {
const product = products[i]!;
order.addItem(
command.items[i].productId,
command.items[i].quantity,
product.price
);
}
order.confirm();
// 3. Persisti gli eventi generati dall'Aggregate
await this.orderRepo.save(order);
// 4. Pubblica gli eventi per aggiornare il read model e notificare altri servizi
const events = order.getUncommittedEvents();
await this.eventBus.publishAll(events);
}
}
// ---- COMMAND BUS ----
// Dispatcher che instrada i command all'handler corretto
class CommandBus {
private handlers = new Map<string, (cmd: unknown) => Promise<void>>();
register<T>(commandType: string, handler: (cmd: T) => Promise<void>): void {
this.handlers.set(commandType, handler as (cmd: unknown) => Promise<void>);
}
async dispatch<T>(commandType: string, command: T): Promise<void> {
const handler = this.handlers.get(commandType);
if (!handler) {
throw new Error(`No handler registered for command: ${commandType}`);
}
await handler(command);
}
}
// Setup
const commandBus = new CommandBus();
commandBus.register('PlaceOrder', (cmd: PlaceOrderCommand) =>
placeOrderHandler.handle(cmd)
);
De querykant: lees model en projecties
De queryzijde onderhoudt een Model lezen: een weergave van de gegevens geoptimaliseerd voor specifieke toepassingsvragen. Terwijl de commandokant werkt met de Aggregate, de querykant werkt met gedenormaliseerde weergaven die zijn opgebouwd via Projecties.
// ---- READ MODEL ----
// Viste denormalizzate ottimizzate per le query dell'UI
// View per la lista ordini: include dati del cliente + totale + stato
interface OrderListItemReadModel {
orderId: string;
customerName: string; // denormalizzato (non serve join)
customerEmail: string;
totalAmount: number;
currency: string;
status: string;
itemCount: number;
placedAt: string;
}
// View per il dettaglio ordine
interface OrderDetailReadModel {
orderId: string;
customerId: string;
customerName: string;
items: Array<{
productId: string;
productName: string; // denormalizzato
quantity: number;
unitPrice: number;
subtotal: number;
}>;
totalAmount: number;
shippingAddress: Address;
status: string;
statusHistory: Array<{ status: string; changedAt: string }>;
estimatedDelivery?: string;
}
// ---- PROIEZIONE ----
// Aggiorna il read model in risposta agli eventi del command side
class OrderReadModelProjection {
constructor(
private readonly readModelDb: ReadModelDatabase,
private readonly customerRepo: CustomerReadRepository
) {}
// Quando un ordine viene confermato, crea/aggiorna le view nel read model
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
// Carica i dati aggiuntivi necessari per la view denormalizzata
const customer = await this.customerRepo.findById(event.customerId);
const orderState = await this.orderRepo.findById(event.orderId);
// Inserisce/aggiorna la view della lista ordini
await this.readModelDb.upsert('order_list_view', {
order_id: event.orderId,
customer_name: customer.fullName,
customer_email: customer.email,
total_amount: orderState.totalAmount,
currency: orderState.currency,
status: 'Confirmed',
item_count: orderState.items.size,
placed_at: orderState.createdAt,
});
// Inserisce la view di dettaglio
const itemsWithNames = await this.enrichItemsWithProductNames(orderState.items);
await this.readModelDb.upsert('order_detail_view', {
order_id: event.orderId,
// ... tutti i campi della view dettaglio
items: JSON.stringify(itemsWithNames),
status_history: JSON.stringify([
{ status: 'Confirmed', changedAt: event.occurredAt }
]),
});
}
async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
// Aggiorna solo lo status nella view
await this.readModelDb.update('order_list_view',
{ order_id: event.orderId },
{ status: 'Cancelled' }
);
// Aggiungi alla status history nella view dettaglio
await this.readModelDb.appendToJsonArray('order_detail_view',
{ order_id: event.orderId },
'status_history',
{ status: 'Cancelled', changedAt: event.occurredAt }
);
}
}
// ---- QUERY HANDLER ----
class OrderQueryHandler {
constructor(private readonly readModelDb: ReadModelDatabase) {}
// Query velocissima sul read model denormalizzato
async getOrderList(customerId: string, page: number, pageSize: number):
Promise<OrderListItemReadModel[]>
{
return this.readModelDb.query(
`SELECT * FROM order_list_view
WHERE customer_id = $1
ORDER BY placed_at DESC
LIMIT $2 OFFSET $3`,
[customerId, pageSize, page * pageSize]
);
}
async getOrderDetail(orderId: string): Promise<OrderDetailReadModel | null> {
return this.readModelDb.queryOne(
'SELECT * FROM order_detail_view WHERE order_id = $1',
[orderId]
);
}
}
CQRS zonder gebeurtenissourcing
CQRS en Event Sourcing zijn orthogonaal: ze werken goed samen, maar kunnen wel gebruikt worden afzonderlijk. Het vereenvoudigde CQRS-model zonder Event Sourcing maakt gebruik van de database hoofddatabase voor schrijfbewerkingen en een aparte database (of gematerialiseerde weergaven) voor leesbewerkingen:
// CQRS semplificato: stesso database, modelli separati
// Write side usa le entita ORM normali
// Read side usa query SQL ottimizzate o viste materializzate
// View materializzata PostgreSQL per la lista ordini
CREATE MATERIALIZED VIEW order_list_view AS
SELECT
o.id AS order_id,
c.full_name AS customer_name,
c.email AS customer_email,
o.total_amount,
o.currency,
o.status,
COUNT(oi.id) AS item_count,
o.created_at AS placed_at
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.full_name, c.email;
-- Refresh automatico della vista materializzata
CREATE INDEX idx_order_list_customer ON order_list_view (customer_email);
-- Con PostgreSQL 17: INCREMENTAL REFRESH (solo le righe cambiate)
-- REFRESH MATERIALIZED VIEW CONCURRENTLY order_list_view;
-- Query sul read model: 100x piu veloce di una query con JOIN
SELECT * FROM order_list_view
WHERE customer_email = 'mario@example.com'
ORDER BY placed_at DESC
LIMIT 20;
Onafhankelijk schalen Lezen en schrijven
Met CQRS kunt u de leeszijde en schrijfzijde onafhankelijk schalen. Als het systeem 100x meer leest dan schrijft (typisch voor een e-commerce), kunt u het volgende hebben:
- Schrijfzijde: 2 exemplaren met primaire PostgreSQL-database
- Lees kant: 10 exemplaren met Redis-cache + alleen-lezen PostgreSQL-replicatie
// Architettura di scaling con Kubernetes
# command-side-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-command-service
spec:
replicas: 2 # write side: pochi ma consistenti
template:
spec:
containers:
- name: api
image: company/order-service:v1
env:
- name: DB_URL
value: "postgres://primary-db:5432/orders" # database primario
- name: SERVICE_MODE
value: "command"
---
# query-side-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-query-service
spec:
replicas: 10 # read side: molte repliche, stateless
template:
spec:
containers:
- name: api
image: company/order-service:v1
env:
- name: DB_URL
value: "postgres://read-replica:5432/orders" # replica read-only
- name: REDIS_URL
value: "redis://cache:6379"
- name: SERVICE_MODE
value: "query"
Beheer eventuele consistentie in de frontend
In CQRS met asynchrone synchronisatie is er een korte periode (doorgaans milliseconden) waarbij het leesmodel na het schrijven nog niet is bijgewerkt. De frontend moet het aankunnen dit correct:
// Pattern 1: Optimistic Update
// Il frontend aggiorna l'UI immediatamente, senza aspettare la query
async function placeOrder(orderData: PlaceOrderDto) {
// 1. Ottimisticamente aggiorna l'UI locale
dispatch({ type: 'ADD_ORDER_OPTIMISTIC', order: { ...orderData, status: 'Pending' } });
try {
// 2. Invia il command al backend
const response = await api.placeOrder(orderData);
// 3. Dopo N ms, ricarica dal read model (che dovrebbe essere aggiornato)
setTimeout(async () => {
const updatedOrder = await api.getOrder(response.orderId);
dispatch({ type: 'UPDATE_ORDER', order: updatedOrder });
}, 500);
} catch (error) {
// 4. In caso di errore, reverta l'ottimistic update
dispatch({ type: 'REVERT_ORDER_OPTIMISTIC' });
throw error;
}
}
// Pattern 2: Poll finche il read model non e aggiornato
async function pollUntilUpdated(orderId: string, expectedStatus: string) {
const MAX_ATTEMPTS = 10;
const DELAY_MS = 200;
for (let i = 0; i < MAX_ATTEMPTS; i++) {
const order = await api.getOrder(orderId);
if (order.status === expectedStatus) return order;
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
throw new Error('Read model not updated within expected time');
}
CQRS-afweging
Voordelen
- Onafhankelijke schaling: Lees zijde en schrijf zijschaal afzonderlijk op basis van werkelijke belasting
- Geoptimaliseerde sjablonen: Het leesmodel kan precies voor de weergave worden gedenormaliseerd, waardoor complexe query's worden geëlimineerd
- Hoge leesprestaties: Query's op het leesmodel zijn eenvoudige SELECT's op vooraf berekende tabellen
- Scheiding van verantwoordelijkheden: Schrijf- en leescode besmetten elkaar niet
Complex om te beheren
- Mogelijke consistentie: Het leesmodel kan enigszins achterblijven bij het schrijven. De frontend moet het aankunnen
- Logische duplicatie: Sommige validaties moeten mogelijk zowel in de opdrachthandler als in het leesmodel plaatsvinden
- Meer componenten om te implementeren: Commandoservice, queryservice, projecties, modeldatabase lezen
- Niet geschikt voor eenvoudige CRUD: De complexiteit van CQRS is niet de moeite waard voor activiteiten zonder complexe bedrijfslogica
Conclusies en volgende stappen
CQRS is een van de meest effectieve architecturen voor systemen met lees- en schrijfbelastingen heel anders. De expliciete scheiding van de commandozijde (consistent, gevalideerd, gebeurtenisgestuurd) vanuit de querykant (snel, gedenormaliseerd, schaalbaar) worden de afwegingen van het enkele model opgelost.
Het volgende artikel combineert Event Sourcing en CQRS samen: we zullen zien hoe de projecties zijn ze lezen uit de gebeurtenisopslag om het leesmodel te bouwen, hoe projecties te beheren bij nieuwe pogingen in geval van een fout, en hoe u snapshots kunt gebruiken om het opnieuw opbouwen van het leesmodel te optimaliseren na een mislukking.
Aankomende artikelen in de Event-Driven Architecture-serie
Gerelateerde serie
- Inkoop van evenementen — de natuurlijke aanvulling van CQRS
- Praktische softwarearchitectuur — waar CQRS moet worden geplaatst in de context van de algemene architectuur







