Saltar para o conteúdo
CQRS e Event Sourcing: Quando Usar (e Quando Evitar)
Arquitetura de Software

CQRS e Event Sourcing: Quando Usar (e Quando Evitar)

25 de julho de 2024·Paulo de Paula

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

CasoCQRSEvent Sourcing
Audit trail completo obrigatório (financeiro, saúde)
Undo/redo de operações
Consultas complexas com read model otimizadoopcional
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.