Ir para o conteúdo
Backend

CQRS e Event Sourcing com TypeScript: Separando Leitura, Escrita e Histórico de Verdade

Marcos Soares
Atualizado em 
14 minutos de leitura
Ilustracao 3D de prisma dividido em leitura e escrita com cascata de eventos representando CQRS e Event Sourcing
Ouça este artigo
0:00CQRS e Event Sourcing com TypeScript: Separando Leitura, Escrita e Histórico de Verdade--:--

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.

Neste artigo

O problema que CRUD esconde de você

Uma tabela orders com 15 colunas. Alguém muda o status de um pedido de "pendente" para "cancelado". O UPDATE sobrescreve o valor anterior. Três meses depois, o financeiro pergunta: "esse pedido foi cancelado antes ou depois do pagamento ser confirmado?". Você não sabe. O banco guarda o estado atual, não a sequência de fatos que levou até ele.

CQRS (Command Query Responsibility Segregation) separa o modelo de escrita do modelo de leitura. Event Sourcing grava cada mudança de estado como um evento imutável, em vez de sobrescrever linhas. Juntos, resolvem o problema de auditoria e permitem otimizar leitura e escrita de forma independente.

Mas a combinação tem custo real: complexidade de infraestrutura, eventual consistency entre modelos, e uma curva de aprendizado que pega de surpresa quem trata os dois como "CRUD com eventos". Este post implementa ambos do zero em TypeScript, com código que roda, e mostra onde a coisa quebra.

CQRS vs. CRUD vs. Event Sourcing: quando usar cada um

CritérioCRUD tradicionalCQRS (sem Event Sourcing)CQRS + Event Sourcing
Complexidade de implementaçãoBaixaMédiaAlta
Auditoria completa de mudançasNão (precisa de tabela auxiliar)Não nativamenteSim, por design
Performance de leitura otimizávelLimitada (mesmo modelo)Alta (modelo de leitura separado)Alta
ConsistênciaImediataEventual ou imediataEventual
Quando faz sentidoApps com menos de 5 entidades de domínio, CRUD puroLeitura e escrita com padrões de acesso muito diferentesDomínios onde o histórico de mudanças É o negócio (financeiro, logística, healthcare)
Quando é excessoNunca é excesso para CRUD simplesApps pequenas sem diferença de carga leitura/escritaQualquer sistema onde você não precisa reconstruir estado a partir de eventos

Se seu sistema é um cadastro de usuários com login e perfil, CQRS + Event Sourcing é overengineering. Se você está modelando transações financeiras, movimentação de estoque ou workflows com múltiplos estados intermediários, a combinação se paga.

Modelando eventos de domínio

Eventos descrevem fatos que já aconteceram. O nome sempre no passado: OrderCreated, PaymentConfirmed, OrderCancelled. Comece pelos tipos:

TypeScript
// src/events/order-events.ts
 
export interface DomainEvent {
  readonly eventId: string;
  readonly aggregateId: string;
  readonly eventType: string;
  readonly timestamp: Date;
  readonly version: number;
  readonly payload: Record<string, unknown>;
}
 
export interface OrderCreated extends DomainEvent {
  eventType: "OrderCreated";
  payload: {
    customerId: string;
    items: Array<{ productId: string; quantity: number; priceInCents: number }>;
    totalInCents: number;
  };
}
 
export interface PaymentConfirmed extends DomainEvent {
  eventType: "PaymentConfirmed";
  payload: {
    paymentId: string;
    amountInCents: number;
    method: "credit_card" | "pix" | "boleto";
  };
}
 
export interface OrderCancelled extends DomainEvent {
  eventType: "OrderCancelled";
  payload: {
    reason: string;
    cancelledBy: string;
  };
}
 
export type OrderEvent = OrderCreated | PaymentConfirmed | OrderCancelled;

O campo version é o número sequencial do evento dentro do aggregate. Ele serve para detectar conflitos de concorrência: se dois comandos tentam gravar a versão 3 ao mesmo tempo, o segundo falha. Sem isso, você perde eventos.

Event Store: gravando fatos imutáveis

O event store é um append-only log. Nada é atualizado, nada é deletado. A implementação mínima em memória (para entender o contrato) e depois a interface para persistência real:

TypeScript
// src/store/event-store.ts
 
import { randomUUID } from "node:crypto";
import type { DomainEvent } from "../events/order-events.js";
 
export class EventStore {
  // Map de aggregateId para lista ordenada de eventos
  private streams = new Map<string, DomainEvent[]>();
 
  async append(
    aggregateId: string,
    events: Omit<DomainEvent, "eventId" | "timestamp">[],
    expectedVersion: number
  ): Promise<void> {
    const stream = this.streams.get(aggregateId) ?? [];
    const currentVersion = stream.length;
 
    // Optimistic concurrency: rejeita se a versão esperada não bate
    if (currentVersion !== expectedVersion) {
      throw new ConcurrencyError(
        `Esperava versão ${expectedVersion}, mas o aggregate está na versão ${currentVersion}`
      );
    }
 
    const enrichedEvents = events.map((event, index) => ({
      ...event,
      eventId: randomUUID(),
      timestamp: new Date(),
      version: currentVersion + index + 1,
    }));
 
    this.streams.set(aggregateId, [...stream, ...enrichedEvents as DomainEvent[]]);
  }
 
  async getStream(aggregateId: string): Promise<DomainEvent[]> {
    return this.streams.get(aggregateId) ?? [];
  }
}
 
export class ConcurrencyError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ConcurrencyError";
  }
}

Em produção, substitua o Map por uma tabela com constraint única em (aggregateId, version). Postgres, DynamoDB e EventStoreDB são escolhas comuns. A documentação do EventStoreDB descreve o modelo de streams com detalhes que vão além do escopo aqui.

Aggregate: reconstruindo estado a partir de eventos

O aggregate é a entidade de domínio que aplica regras de negócio e emite eventos. O estado nunca é setado diretamente: ele é derivado do replay dos eventos.

TypeScript
// src/aggregates/order-aggregate.ts
 
import type { OrderEvent, OrderCreated, PaymentConfirmed, OrderCancelled } from "../events/order-events.js";
 
type OrderStatus = "pending" | "paid" | "cancelled";
 
interface OrderState {
  id: string;
  status: OrderStatus;
  customerId: string;
  totalInCents: number;
  version: number;
}
 
export class OrderAggregate {
  private state: OrderState;
  // Eventos pendentes que ainda não foram persistidos
  private uncommittedEvents: OrderEvent[] = [];
 
  private constructor(state: OrderState) {
    this.state = state;
  }
 
  static fromEvents(aggregateId: string, events: OrderEvent[]): OrderAggregate {
    const initialState: OrderState = {
      id: aggregateId,
      status: "pending",
      customerId: "",
      totalInCents: 0,
      version: 0,
    };
 
    const aggregate = new OrderAggregate(initialState);
 
    // Replay: aplica cada evento em sequência para reconstruir o estado
    for (const event of events) {
      aggregate.apply(event);
    }
 
    return aggregate;
  }
 
  get currentVersion(): number {
    return this.state.version;
  }
 
  get uncommitted(): OrderEvent[] {
    return [...this.uncommittedEvents];
  }
 
  confirmPayment(paymentId: string, amountInCents: number, method: "credit_card" | "pix" | "boleto"): void {
    if (this.state.status !== "pending") {
      throw new Error(`Não é possível confirmar pagamento de pedido com status "${this.state.status}"`);
    }
 
    if (amountInCents !== this.state.totalInCents) {
      throw new Error(
        `Valor do pagamento (${amountInCents}) difere do total do pedido (${this.state.totalInCents})`
      );
    }
 
    this.raiseEvent({
      eventType: "PaymentConfirmed",
      aggregateId: this.state.id,
      version: this.state.version + 1,
      payload: { paymentId, amountInCents, method },
    } as PaymentConfirmed);
  }
 
  cancel(reason: string, cancelledBy: string): void {
    if (this.state.status === "cancelled") {
      throw new Error("Pedido já está cancelado");
    }
 
    this.raiseEvent({
      eventType: "OrderCancelled",
      aggregateId: this.state.id,
      version: this.state.version + 1,
      payload: { reason, cancelledBy },
    } as OrderCancelled);
  }
 
  private raiseEvent(event: OrderEvent): void {
    this.apply(event);
    this.uncommittedEvents.push(event);
  }
 
  private apply(event: OrderEvent): void {
    switch (event.eventType) {
      case "OrderCreated":
        this.state.customerId = event.payload.customerId;
        this.state.totalInCents = event.payload.totalInCents;
        this.state.status = "pending";
        break;
      case "PaymentConfirmed":
        this.state.status = "paid";
        break;
      case "OrderCancelled":
        this.state.status = "cancelled";
        break;
    }
    this.state.version = event.version;
  }
}

A separação entre apply (muda estado) e raiseEvent (muda estado E registra como pendente) é o ponto que mais confunde quem está começando. No replay, você só aplica. No comando novo, você aplica e marca para persistir.

Command Handlers: o lado da escrita

Commands representam intenções. Diferente de eventos (fatos passados), commands podem falhar. O handler carrega o aggregate, executa a lógica e persiste os eventos:

TypeScript
// src/commands/confirm-payment-handler.ts
 
import { EventStore } from "../store/event-store.js";
import { OrderAggregate } from "../aggregates/order-aggregate.js";
import type { OrderEvent } from "../events/order-events.js";
 
interface ConfirmPaymentCommand {
  orderId: string;
  paymentId: string;
  amountInCents: number;
  method: "credit_card" | "pix" | "boleto";
}
 
export class ConfirmPaymentHandler {
  constructor(private eventStore: EventStore) {}
 
  async execute(command: ConfirmPaymentCommand): Promise<void> {
    const events = await this.eventStore.getStream(command.orderId) as OrderEvent[];
    const aggregate = OrderAggregate.fromEvents(command.orderId, events);
 
    // A validação de negócio acontece DENTRO do aggregate
    aggregate.confirmPayment(
      command.paymentId,
      command.amountInCents,
      command.method
    );
 
    await this.eventStore.append(
      command.orderId,
      aggregate.uncommitted,
      aggregate.currentVersion - aggregate.uncommitted.length
    );
  }
}

O handler não contém lógica de negócio. Ele orquestra: busca eventos, reconstrói aggregate, chama método de domínio, persiste. Se você modelou seu domínio com DDD e TypeScript, o aggregate é a entidade raiz do bounded context.

Projeções: o lado da leitura

A projeção consome eventos e monta um modelo otimizado para consultas. Esse modelo pode ser uma tabela desnormalizada, um documento JSON, um cache Redis. A projeção é eventualmente consistente com o event store.

TypeScript
// src/projections/order-summary-projection.ts
 
import type { OrderEvent } from "../events/order-events.js";
 
export interface OrderSummary {
  orderId: string;
  customerId: string;
  status: string;
  totalInCents: number;
  paidAt: Date | null;
  cancelledAt: Date | null;
  cancelReason: string | null;
}
 
export class OrderSummaryProjection {
  // Em produção, substitua por Postgres, Redis ou qualquer store de leitura
  private summaries = new Map<string, OrderSummary>();
 
  handleEvent(event: OrderEvent): void {
    switch (event.eventType) {
      case "OrderCreated": {
        this.summaries.set(event.aggregateId, {
          orderId: event.aggregateId,
          customerId: event.payload.customerId,
          status: "pending",
          totalInCents: event.payload.totalInCents,
          paidAt: null,
          cancelledAt: null,
          cancelReason: null,
        });
        break;
      }
      case "PaymentConfirmed": {
        const summary = this.summaries.get(event.aggregateId);
        if (summary) {
          summary.status = "paid";
          summary.paidAt = event.timestamp;
        }
        break;
      }
      case "OrderCancelled": {
        const summary = this.summaries.get(event.aggregateId);
        if (summary) {
          summary.status = "cancelled";
          summary.cancelledAt = event.timestamp;
          summary.cancelReason = event.payload.reason;
        }
        break;
      }
    }
  }
 
  getById(orderId: string): OrderSummary | undefined {
    return this.summaries.get(orderId);
  }
 
  getByCustomer(customerId: string): OrderSummary[] {
    return [...this.summaries.values()].filter(
      (s) => s.customerId === customerId
    );
  }
}

A projeção responde perguntas que o aggregate não consegue responder de forma eficiente. "Quais pedidos do cliente X estão pendentes?" é uma query de leitura. Forçar o aggregate a responder isso significa carregar todos os eventos de todos os pedidos do cliente. A projeção resolve com um filter simples (ou um SELECT com índice, em produção).

Conectar projeções ao fluxo de persistência de eventos é direto quando feito via API layers bem definidas.

O que NÃO fazer

Anti-pattern 1: Eventos com dados demais (fat events)

TypeScript
// ERRADO: o evento carrega o estado inteiro do aggregate
const badEvent = {
  eventType: "OrderUpdated", // nome genérico, não descreve o que aconteceu
  payload: {
    id: "order-123",
    status: "paid",
    customerId: "cust-456",
    items: [/* lista completa */],
    totalInCents: 15000,
    createdAt: "2024-01-01",
    updatedAt: "2024-06-15",
    // ... mais 10 campos
  },
};

Isso é um snapshot disfarçado de evento. Quando o schema do aggregate muda, todos os eventos antigos ficam incompatíveis. Eventos devem carregar apenas os dados da mudança que ocorreu.

TypeScript
// CORRETO: evento específico com payload mínimo
const goodEvent = {
  eventType: "PaymentConfirmed",
  payload: {
    paymentId: "pay-789",
    amountInCents: 15000,
    method: "pix",
  },
};

Anti-pattern 2: Lógica de negócio no command handler

TypeScript
// ERRADO: handler decide regra de negócio
export class BadHandler {
  async execute(command: ConfirmPaymentCommand): Promise<void> {
    const events = await this.eventStore.getStream(command.orderId);
    const lastEvent = events[events.length - 1];
 
    // Regra de negócio vazou para o handler
    if (lastEvent?.eventType === "OrderCancelled") {
      throw new Error("Pedido cancelado");
    }
 
    // Gera evento diretamente, sem passar pelo aggregate
    await this.eventStore.append(command.orderId, [{
      eventType: "PaymentConfirmed",
      aggregateId: command.orderId,
      version: events.length + 1,
      payload: { paymentId: command.paymentId, amountInCents: command.amountInCents, method: command.method },
    }], events.length);
  }
}

O handler está inspecionando eventos diretamente e gerando novos sem passar pelo aggregate. Quando a regra mudar (por exemplo, permitir reativar pedidos cancelados), você precisa alterar o handler em vez do domínio. O aggregate existe para encapsular invariantes. Use-o.

Anti-pattern 3: Projeção que escreve no event store

Projeções são consumidores de eventos, nunca produtores. Se uma projeção detecta uma condição e precisa disparar uma ação, ela publica um command, não um evento. Misturar os dois cria ciclos de dependência que tornam o sistema impossível de debugar.

Testando o fluxo completo

Testes em Event Sourcing são surpreendentemente limpos: dado um conjunto de eventos passados, quando um comando é executado, então estes novos eventos são emitidos.

TypeScript
// src/__tests__/order-aggregate.test.ts
 
import { describe, it, expect } from "vitest";
import { OrderAggregate } from "../aggregates/order-aggregate.js";
import type { OrderCreated, OrderEvent } from "../events/order-events.js";
 
describe("OrderAggregate", () => {
  const createdEvent: OrderCreated = {
    eventId: "evt-1",
    aggregateId: "order-1",
    eventType: "OrderCreated",
    timestamp: new Date(),
    version: 1,
    payload: {
      customerId: "cust-1",
      items: [{ productId: "prod-1", quantity: 2, priceInCents: 5000 }],
      totalInCents: 10000,
    },
  };
 
  it("confirma pagamento quando o valor bate", () => {
    const aggregate = OrderAggregate.fromEvents("order-1", [createdEvent]);
 
    aggregate.confirmPayment("pay-1", 10000, "pix");
 
    expect(aggregate.uncommitted).toHaveLength(1);
    expect(aggregate.uncommitted[0].eventType).toBe("PaymentConfirmed");
  });
 
  it("rejeita pagamento com valor diferente do total", () => {
    const aggregate = OrderAggregate.fromEvents("order-1", [createdEvent]);
 
    expect(() => aggregate.confirmPayment("pay-1", 9999, "pix")).toThrow(
      "Valor do pagamento"
    );
  });
 
  it("rejeita cancelamento duplo", () => {
    const cancelledEvents: OrderEvent[] = [
      createdEvent,
      {
        eventId: "evt-2",
        aggregateId: "order-1",
        eventType: "OrderCancelled",
        timestamp: new Date(),
        version: 2,
        payload: { reason: "Cliente desistiu", cancelledBy: "admin" },
      },
    ];
 
    const aggregate = OrderAggregate.fromEvents("order-1", cancelledEvents);
 
    expect(() => aggregate.cancel("Teste", "admin")).toThrow("já está cancelado");
  });
});

Se você já usa Vitest para testar APIs, o padrão é o mesmo. A diferença é que aqui não há HTTP: o teste exercita o domínio diretamente.

Snapshots: quando o replay fica lento

Aggregates com milhares de eventos ficam lentos para reconstruir. Snapshots resolvem: periodicamente, salve o estado atual do aggregate junto com a versão. No próximo carregamento, comece do snapshot e aplique apenas os eventos posteriores.

TypeScript
// src/store/snapshot-store.ts
 
interface AggregateSnapshot {
  aggregateId: string;
  version: number;
  state: Record<string, unknown>;
  createdAt: Date;
}
 
export class SnapshotStore {
  private snapshots = new Map<string, AggregateSnapshot>();
 
  save(snapshot: AggregateSnapshot): void {
    this.snapshots.set(snapshot.aggregateId, snapshot);
  }
 
  get(aggregateId: string): AggregateSnapshot | undefined {
    return this.snapshots.get(aggregateId);
  }
}

Tire snapshots a cada N eventos (100 é um ponto de partida razoável). Se seu aggregate nunca passa de 50 eventos ao longo da vida, snapshots são complexidade desnecessária.

Decisões de infraestrutura que impactam

A escolha do event store define muito do comportamento operacional. Postgres com uma tabela events funciona até dezenas de milhões de eventos. Acima disso, EventStoreDB ou soluções baseadas em Kafka com compactação por aggregate entram em cena.

Para migrações de schema no event store, a regra é: nunca altere eventos antigos. Crie um upcaster que transforma eventos v1 em v2 no momento da leitura. O dado gravado é imutável.

A consistência eventual entre o event store e as projeções significa que, por alguns milissegundos (ou segundos, dependendo da arquitetura), a leitura pode estar desatualizada. Se isso é inaceitável para um caso específico, faça a projeção de forma síncrona no mesmo processo do command handler, sacrificando throughput de escrita. Em sistemas com feature flags, você pode ligar projeção síncrona só para fluxos críticos e manter o resto assíncrono.

FAQ

Preciso usar CQRS e Event Sourcing juntos?

Não. CQRS funciona sem Event Sourcing: você separa modelos de leitura e escrita, mas persiste estado normalmente com UPDATE. Event Sourcing sem CQRS também é possível, mas pouco prático, porque consultas diretamente no event store são ineficientes para a maioria dos casos de uso. A combinação faz sentido quando você precisa de auditoria completa E padrões de leitura/escrita diferentes.

Event Sourcing funciona com bancos relacionais?

Funciona. Uma tabela com colunas aggregate_id, version, event_type, payload (JSONB no Postgres), timestamp e uma constraint UNIQUE(aggregate_id, version) é suficiente. A constraint garante optimistic concurrency sem lock explícito.

Como lidar com LGPD e direito ao esquecimento se os eventos são imutáveis?

Crypto-shredding: dados pessoais no payload são criptografados com uma chave por usuário. Quando o usuário pede exclusão, você apaga a chave. Os eventos continuam existindo, mas o payload fica ilegível. Alternativa: use referências (IDs) nos eventos e armazene dados pessoais em um store separado que aceita DELETE.

Qual o tamanho de time mínimo para adotar essa arquitetura?

A complexidade não é de código, é operacional. Um dev solo consegue implementar, mas manter projeções, lidar com versionamento de eventos e debugar eventual consistency exige disciplina. Se o domínio justifica (financeiro, logística com rastreamento), vale mesmo para times pequenos. Se não justifica, um backend Node.js bem estruturado com CRUD resolve.

Posso migrar gradualmente de CRUD para Event Sourcing?

Pode. Comece com um bounded context isolado. Mantenha o CRUD existente para o resto do sistema. A fronteira entre os dois é um anti-corruption layer que traduz entre os modelos. Migrar tudo de uma vez é a receita para o projeto parar por meses.

A posição que defendo

CQRS e Event Sourcing são ferramentas de domínio, não de infraestrutura. A decisão de adotar não deveria partir de "quero usar Kafka" ou "vi no blog do Netflix". Deveria partir de: "meu domínio precisa de histórico como cidadão de primeira classe?" e "meus padrões de leitura e escrita são diferentes o suficiente para justificar dois modelos?".

Se as duas respostas forem sim, a arquitetura se paga em meses. Se qualquer uma for não, você está adicionando complexidade que vai cobrar juros em cada deploy, cada onboarding de dev novo e cada incidente de madrugada. O mapa de decisões técnicas que separa engenheiros de operadores de framework passa exatamente por saber quando NÃO usar a ferramenta sofisticada.

Marcos Soares

Escrito por

Marcos Soares

Fullstack Developer · CEO da Agência Poti

Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.

Comentários

Participe da discussão

Seja o primeiro a comentar!

Continue Aprofundando

Conteúdo técnico toda semana

Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.

Sem spam. Cancele a qualquer momento com 1 clique.