CQRS e Event Sourcing: Quando Usar (e Quando Evitar)
CQRS e Event Sourcing são dois padrões independentes frequentemente mencionados juntos. Você pode usar CQRS sem Event Sourcing (e na maioria dos casos deveria). Event Sourcing sem CQRS raramente faz sentido.
CQRS: separando leitura e escrita
CQRS (Command Query Responsibility Segregation) separa as operações em dois modelos:
- Commands: modificam o estado (CreateOrder, CancelPayment)
- Queries: leem o estado sem modificar (GetOrderById, ListUserOrders)
// ── Command side ──────────────────────────────────────────────
interface CreateOrderCommand {
customerId: string;
items: { productId: string; quantity: number }[];
}
class CreateOrderHandler {
async handle(cmd: CreateOrderCommand): Promise<string> {
const customer = await this.customerRepo.findById(cmd.customerId);
if (!customer.canOrder()) throw new CustomerNotEligibleError();
const order = Order.create(customer, cmd.items);
await this.orderRepo.save(order);
// Publica evento para atualizar read model
await this.eventBus.publish(new OrderCreated(order));
return order.id;
}
}
// ── Query side ────────────────────────────────────────────────
// Read model otimizado para listagem (desnormalizado, rápido)
interface OrderSummary {
id: string;
customerName: string;
totalAmount: number;
status: string;
createdAt: Date;
}
class ListOrdersQuery {
async execute(customerId: string): Promise<OrderSummary[]> {
// Query direta no read model (pode ser uma view materializada)
return this.readDb.query(
`SELECT o.id, c.name as customer_name, o.total_amount, o.status, o.created_at
FROM order_summaries o
JOIN customers c ON c.id = o.customer_id
WHERE o.customer_id = $1
ORDER BY o.created_at DESC`,
[customerId]
);
}
}O read model é atualizado por um event handler separado:
class OrderSummaryProjection {
@On(OrderCreated)
async handleOrderCreated(event: OrderCreated): Promise<void> {
await this.readDb.query(
`INSERT INTO order_summaries (id, customer_id, total_amount, status, created_at)
VALUES ($1, $2, $3, 'pending', $4)`,
[event.orderId, event.customerId, event.totalAmount, event.occurredAt]
);
}
@On(OrderCancelled)
async handleOrderCancelled(event: OrderCancelled): Promise<void> {
await this.readDb.query(
`UPDATE order_summaries SET status = 'cancelled' WHERE id = $1`,
[event.orderId]
);
}
}Event Sourcing: armazenando eventos
Em vez de salvar o estado atual, armazene a sequência de eventos que levou a ele:
// ── Event Store ───────────────────────────────────────────────
interface StoredEvent {
eventId: string;
aggregateId: string;
aggregateType: string;
eventType: string;
payload: unknown;
occurredAt: Date;
version: number;
}
class EventStore {
async append(aggregateId: string, events: DomainEvent[], expectedVersion: number): Promise<void> {
// Optimistic concurrency: garante que ninguém escreveu enquanto líamos
const currentVersion = await this.getCurrentVersion(aggregateId);
if (currentVersion !== expectedVersion) {
throw new ConcurrencyConflictError(aggregateId);
}
for (const event of events) {
await this.db.query(
`INSERT INTO events (event_id, aggregate_id, event_type, payload, occurred_at, version)
VALUES ($1, $2, $3, $4, $5, $6)`,
[event.id, aggregateId, event.type, JSON.stringify(event), event.occurredAt, ++currentVersion]
);
}
}
async loadEvents(aggregateId: string): Promise<StoredEvent[]> {
return this.db.query(
`SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version ASC`,
[aggregateId]
);
}
}Reconstruindo o estado a partir dos eventos:
class BankAccount {
private _balance = 0;
private _version = 0;
private _pendingEvents: DomainEvent[] = [];
// Reconstrói o estado aplicando cada evento na ordem
static rehydrate(events: StoredEvent[]): BankAccount {
const account = new BankAccount();
for (const event of events) {
account.apply(event, false); // false = não adiciona a pendingEvents
}
return account;
}
deposit(amount: number): void {
if (amount <= 0) throw new InvalidAmountError();
this.apply(new MoneyDeposited(this.id, amount), true);
}
withdraw(amount: number): void {
if (amount > this._balance) throw new InsufficientFundsError();
this.apply(new MoneyWithdrawn(this.id, amount), true);
}
private apply(event: DomainEvent, isNew: boolean): void {
switch (event.type) {
case 'MoneyDeposited':
this._balance += (event as MoneyDeposited).amount;
break;
case 'MoneyWithdrawn':
this._balance -= (event as MoneyWithdrawn).amount;
break;
}
this._version++;
if (isNew) this._pendingEvents.push(event);
}
}Snapshots para performance
Com muitos eventos, reconstruir o estado fica lento. Snapshots salvam o estado em um ponto e carregam apenas os eventos posteriores:
async function loadAccount(id: string): Promise<BankAccount> {
const snapshot = await snapshotStore.loadLatest(id);
const events = snapshot
? await eventStore.loadEvents(id, { afterVersion: snapshot.version })
: await eventStore.loadEvents(id);
const account = snapshot
? BankAccount.fromSnapshot(snapshot)
: new BankAccount();
return account.applyEvents(events);
}Quando faz sentido
| Caso | CQRS | Event Sourcing |
|---|---|---|
| Audit trail completo obrigatório (financeiro, saúde) | ✓ | ✓ |
| Undo/redo de operações | ✓ | ✓ |
| Consultas complexas com read model otimizado | ✓ | opcional |
| Debugging temporal (“o que aconteceu às 14h?”) | — | ✓ |
| Alta carga de leitura vs escrita | ✓ | — |
| CRUD simples | ✗ | ✗ |
O custo real
Eventual consistency: o read model pode estar desatualizado por milissegundos. Para a maioria dos casos isso é aceitável; para saldos de conta ou inventário, pode não ser.
Debugging: rastrear um bug requer entender toda a cadeia de eventos, projeções e handlers.
Infraestrutura: precisa de event store, filas de mensagens, mecanismo de replay — bem mais complexo que um banco relacional simples.
Schema evolution: mudar o formato de um evento histórico sem quebrar o replay é um problema não-trivial.
Comece com CQRS simples (sem Event Sourcing) se quiser separar leitura de escrita. Adicione Event Sourcing apenas quando o histórico auditável for um requisito de negócio — não uma escolha técnica.