CQRS 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ério | CRUD tradicional | CQRS (sem Event Sourcing) | CQRS + Event Sourcing |
|---|---|---|---|
| Complexidade de implementação | Baixa | Média | Alta |
| Auditoria completa de mudanças | Não (precisa de tabela auxiliar) | Não nativamente | Sim, por design |
| Performance de leitura otimizável | Limitada (mesmo modelo) | Alta (modelo de leitura separado) | Alta |
| Consistência | Imediata | Eventual ou imediata | Eventual |
| Quando faz sentido | Apps com menos de 5 entidades de domínio, CRUD puro | Leitura e escrita com padrões de acesso muito diferentes | Domínios onde o histórico de mudanças É o negócio (financeiro, logística, healthcare) |
| Quando é excesso | Nunca é excesso para CRUD simples | Apps pequenas sem diferença de carga leitura/escrita | Qualquer 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:
// 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:
// 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.
// 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:
// 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.
// 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)
// 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.
// 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
// 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.
// 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.
// 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.

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

5 Lacunas Críticas que Todo Dev JavaScript Ignora (e Como Cobrir Cada Uma)

Design Patterns que Todo Dev Senior Deveria Dominar em TypeScript

Ferramentas Open Source que Automatizam o Trabalho Chato do Seu Dia a Dia em JavaScript
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.