DDD: CQRS e Event Sourcing - Separando comandos e consultas (Parte 12)
Este é o décimo segundo artigo da série sobre Domain-Driven Design. Agora vamos explorar dois padrões avançados que trabalham excepcionalmente bem juntos: CQRS (Command Query Responsibility Segregation) e Event Sourcing. Estes padrões são fundamentais para construir sistemas altamente escaláveis, auditáveis e resilientes.
CQRS e Event Sourcing representam uma mudança de paradigma significativa na forma como pensamos sobre persistência e modelagem de dados. Enquanto a abordagem tradicional tenta usar o mesmo modelo para leitura e escrita, estes padrões reconhecem que essas operações têm necessidades fundamentalmente diferentes.
O que é CQRS?
CQRS (Command Query Responsibility Segregation) é um padrão arquitetural que separa completamente as operações de escrita (comandos) das operações de leitura (consultas) em modelos distintos. Martin Fowler define:
"CQRS significa separar a responsabilidade entre comandos (mudanças) e consultas (leituras) em diferentes modelos."
A Filosofia por Trás do CQRS
A separação entre comandos e consultas não é apenas uma questão técnica - é uma decisão arquitetural que reconhece que:
Comandos (escrita) se preocupam com:
- Validação de regras de negócio
- Consistência transacional
- Integridade dos dados
- Processamento sequencial
Consultas (leitura) se preocupam com:
- Performance de acesso
- Formatos específicos de apresentação
- Agregação de dados
- Escalabilidade horizontal
Esta separação permite otimizar cada lado independentemente, escolhendo as tecnologias e estratégias mais adequadas para cada necessidade específica.
Cenário Tradicional vs CQRS
Para entender melhor o valor do CQRS, vamos comparar uma implementação tradicional com uma implementação usando CQRS.
Abordagem Tradicional
Na abordagem tradicional, usamos o mesmo modelo e repositório para operações de leitura e escrita:
<?php
// ❌ PROBLEMA: Mesmo modelo para leitura e escrita
class ProdutoService
{
private ProdutoRepository $produtoRepository;
public function __construct(ProdutoRepository $produtoRepository)
{
$this->produtoRepository = $produtoRepository;
}
public function buscarProduto(string $id): ?ProdutoView
{
$produto = $this->produtoRepository->buscarPorId($id);
if ($produto === null) {
return null;
}
// Precisa transformar entidade rica em view simples
return new ProdutoView(
$produto->getId(),
$produto->getNome(),
$produto->getPreco()->getValor(),
$produto->getDescricao(),
$produto->getCategoria()->getNome(),
$produto->getQuantidadeEstoque()
);
}
public function atualizarPreco(string $id, float $novoPreco): void
{
$produto = $this->produtoRepository->buscarPorId($id);
if ($produto === null) {
throw new DomainException('Produto não encontrado');
}
$produto->alterarPreco(new Dinheiro($novoPreco));
$this->produtoRepository->salvar($produto);
}
}
Esta abordagem apresenta limitações:
- A mesma entidade serve para casos de uso muito diferentes
- Performance de leitura limitada pela complexidade do modelo de escrita
- Dificuldade para otimizar consultas específicas
- Acoplamento entre necessidades de leitura e escrita
Implementação com CQRS
Com CQRS, separamos completamente as responsabilidades:
<?php
// ✅ SOLUÇÃO: Separação clara entre comandos e consultas
// === LADO DOS COMANDOS (Write Side) ===
interface Command {}
class AlterarPrecoProdutoCommand implements Command
{
public function __construct(
public readonly string $produtoId,
public readonly float $novoPreco
) {}
}
class AdicionarProdutoCommand implements Command
{
public function __construct(
public readonly string $nome,
public readonly string $descricao,
public readonly float $preco,
public readonly string $categoriaId,
public readonly int $quantidadeInicial
) {}
}
// Command Handlers - focados em regras de negócio
class ProdutoCommandHandler
{
private ProdutoRepository $produtoRepository;
private EventDispatcher $eventDispatcher;
public function __construct(
ProdutoRepository $produtoRepository,
EventDispatcher $eventDispatcher
) {
$this->produtoRepository = $produtoRepository;
$this->eventDispatcher = $eventDispatcher;
}
public function handle(AlterarPrecoProdutoCommand $command): void
{
$produto = $this->produtoRepository->buscarPorId($command->produtoId);
if ($produto === null) {
throw new DomainException('Produto não encontrado');
}
$precoAnterior = $produto->getPreco()->getValor();
$produto->alterarPreco(new Dinheiro($command->novoPreco));
$this->produtoRepository->salvar($produto);
// Publicar evento para atualizar side de leitura
$this->eventDispatcher->publish(new ProdutoPrecoAlteradoEvent(
$produto->getId(),
$precoAnterior,
$command->novoPreco,
new DateTime()
));
}
public function handle(AdicionarProdutoCommand $command): void
{
$produto = Produto::criar(
$command->nome,
$command->descricao,
new Dinheiro($command->preco),
$command->categoriaId,
$command->quantidadeInicial
);
$this->produtoRepository->salvar($produto);
$this->eventDispatcher->publish(new ProdutoAdicionadoEvent(
$produto->getId(),
$produto->getNome(),
$produto->getPreco()->getValor(),
new DateTime()
));
}
}
// === LADO DAS CONSULTAS (Read Side) ===
interface Query {}
class BuscarProdutoQuery implements Query
{
public function __construct(
public readonly string $produtoId
) {}
}
class ListarProdutosPorCategoriaQuery implements Query
{
public function __construct(
public readonly string $categoriaId,
public readonly int $limite = 20,
public readonly int $offset = 0
) {}
}
// Query Handlers - focados em performance de leitura
class ProdutoQueryHandler
{
private ProdutoReadRepository $produtoReadRepository;
public function __construct(ProdutoReadRepository $produtoReadRepository)
{
$this->produtoReadRepository = $produtoReadRepository;
}
public function handle(BuscarProdutoQuery $query): ?ProdutoView
{
return $this->produtoReadRepository->buscarPorId($query->produtoId);
}
public function handle(ListarProdutosPorCategoriaQuery $query): array
{
return $this->produtoReadRepository->listarPorCategoria(
$query->categoriaId,
$query->limite,
$query->offset
);
}
}
// Views específicas para leitura - otimizadas para apresentação
class ProdutoView
{
public function __construct(
public readonly string $id,
public readonly string $nome,
public readonly float $preco,
public readonly string $descricao,
public readonly string $categoriaNome,
public readonly int $quantidadeEstoque,
public readonly bool $disponivel,
public readonly DateTime $ultimaAtualizacao
) {}
}
class ProdutoListaView
{
public function __construct(
public readonly string $id,
public readonly string $nome,
public readonly float $preco,
public readonly string $categoriaNome,
public readonly bool $disponivel
) {}
}
O que é Event Sourcing?
Event Sourcing é um padrão onde o estado atual é derivado de uma sequência imutável de eventos, ao invés de armazenar apenas o estado atual. Em vez de fazer UPDATE em registros, armazenamos cada mudança como um evento.
Conceitos Fundamentais do Event Sourcing
Eventos como Fonte da Verdade: O estado atual é reconstruído pela reprodução (replay) de todos os eventos que levaram a ele.
Imutabilidade: Uma vez criados, os eventos nunca são alterados ou deletados.
Auditoria Completa: Temos um histórico completo de todas as mudanças que aconteceram no sistema.
Temporalidade: Podemos reconstruir o estado do sistema em qualquer ponto no tempo.
Comparação: Estado vs Eventos
Vamos ver a diferença prática:
<?php
// ❌ Abordagem tradicional: armazenar apenas estado atual
// Tabela: contas_bancarias
// id | titular_id | saldo | created_at | updated_at
// 1 | 123 | 1500 | 2024-01-01 | 2024-01-25
$conta = new ContaBancaria('1', '123', 1500.00);
// ✅ Event Sourcing: armazenar eventos que levaram ao estado
$eventos = [
new ContaCriadaEvent('1', '123', 1000.00, new DateTime('2024-01-01')),
new DepositoRealizadoEvent('1', 500.00, 'TED', new DateTime('2024-01-15')),
new SaqueRealizadoEvent('1', 200.00, 'ATM', new DateTime('2024-01-20')),
new DepositoRealizadoEvent('1', 200.00, 'PIX', new DateTime('2024-01-25'))
];
// Estado atual = resultado da aplicação de todos os eventos
// Saldo = 1000 + 500 - 200 + 200 = 1500
Implementando Event Sourcing
1. Definindo Eventos de Domínio
Primeiro, vamos criar a estrutura básica para eventos de domínio:
<?php
interface DomainEvent
{
public function getEventId(): string;
public function getAggregateId(): string;
public function getEventType(): string;
public function getTimestamp(): DateTime;
public function getVersion(): int;
public function getPayload(): array;
}
abstract class BaseDomainEvent implements DomainEvent
{
protected string $eventId;
protected string $aggregateId;
protected DateTime $timestamp;
protected int $version;
public function __construct(
string $aggregateId,
int $version,
?DateTime $timestamp = null
) {
$this->eventId = uniqid('evt_');
$this->aggregateId = $aggregateId;
$this->version = $version;
$this->timestamp = $timestamp ?? new DateTime();
}
public function getEventId(): string
{
return $this->eventId;
}
public function getAggregateId(): string
{
return $this->aggregateId;
}
public function getTimestamp(): DateTime
{
return $this->timestamp;
}
public function getVersion(): int
{
return $this->version;
}
abstract public function getEventType(): string;
abstract public function getPayload(): array;
}
// Eventos específicos do domínio
class ContaCriadaEvent extends BaseDomainEvent
{
public function __construct(
string $contaId,
int $version,
public readonly string $titularId,
public readonly float $saldoInicial,
?DateTime $timestamp = null
) {
parent::__construct($contaId, $version, $timestamp);
}
public function getEventType(): string
{
return 'ContaCriada';
}
public function getPayload(): array
{
return [
'titularId' => $this->titularId,
'saldoInicial' => $this->saldoInicial
];
}
}
class DepositoRealizadoEvent extends BaseDomainEvent
{
public function __construct(
string $contaId,
int $version,
public readonly float $valor,
public readonly string $origem,
?DateTime $timestamp = null
) {
parent::__construct($contaId, $version, $timestamp);
}
public function getEventType(): string
{
return 'DepositoRealizado';
}
public function getPayload(): array
{
return [
'valor' => $this->valor,
'origem' => $this->origem
];
}
}
class SaqueRealizadoEvent extends BaseDomainEvent
{
public function __construct(
string $contaId,
int $version,
public readonly float $valor,
public readonly string $local,
?DateTime $timestamp = null
) {
parent::__construct($contaId, $version, $timestamp);
}
public function getEventType(): string
{
return 'SaqueRealizado';
}
public function getPayload(): array
{
return [
'valor' => $this->valor,
'local' => $this->local
];
}
}
2. Aggregate com Event Sourcing
Agora vamos implementar um aggregate que gera e consome eventos:
<?php
abstract class EventSourcedAggregate
{
protected array $eventosNaoCommitados = [];
protected int $versao = 0;
protected function adicionarEvento(DomainEvent $evento): void
{
$this->eventosNaoCommitados[] = $evento;
$this->aplicarEvento($evento);
$this->versao++;
}
abstract protected function aplicarEvento(DomainEvent $evento): void;
public function getEventosNaoCommitados(): array
{
return $this->eventosNaoCommitados;
}
public function marcarEventosComoCommitados(): void
{
$this->eventosNaoCommitados = [];
}
public function getVersao(): int
{
return $this->versao;
}
public static function fromHistory(array $eventos): static
{
$aggregate = new static();
foreach ($eventos as $evento) {
$aggregate->aplicarEvento($evento);
$aggregate->versao = $evento->getVersion();
}
return $aggregate;
}
}
class ContaBancaria extends EventSourcedAggregate
{
private string $id = '';
private string $titularId = '';
private float $saldo = 0.0;
private bool $ativa = false;
public static function criar(
string $id,
string $titularId,
float $saldoInicial
): self {
$conta = new self();
if ($saldoInicial < 0) {
throw new DomainException('Saldo inicial não pode ser negativo');
}
$evento = new ContaCriadaEvent(
$id,
1,
$titularId,
$saldoInicial
);
$conta->adicionarEvento($evento);
return $conta;
}
public function depositar(float $valor, string $origem): void
{
if (!$this->ativa) {
throw new DomainException('Conta não está ativa');
}
if ($valor <= 0) {
throw new DomainException('Valor do depósito deve ser positivo');
}
$evento = new DepositoRealizadoEvent(
$this->id,
$this->versao + 1,
$valor,
$origem
);
$this->adicionarEvento($evento);
}
public function sacar(float $valor, string $local): void
{
if (!$this->ativa) {
throw new DomainException('Conta não está ativa');
}
if ($valor <= 0) {
throw new DomainException('Valor do saque deve ser positivo');
}
if ($valor > $this->saldo) {
throw new DomainException('Saldo insuficiente');
}
$evento = new SaqueRealizadoEvent(
$this->id,
$this->versao + 1,
$valor,
$local
);
$this->adicionarEvento($evento);
}
protected function aplicarEvento(DomainEvent $evento): void
{
switch ($evento->getEventType()) {
case 'ContaCriada':
$this->aplicarContaCriada($evento);
break;
case 'DepositoRealizado':
$this->aplicarDepositoRealizado($evento);
break;
case 'SaqueRealizado':
$this->aplicarSaqueRealizado($evento);
break;
default:
throw new InvalidArgumentException('Tipo de evento desconhecido: ' . $evento->getEventType());
}
}
private function aplicarContaCriada(ContaCriadaEvent $evento): void
{
$this->id = $evento->getAggregateId();
$this->titularId = $evento->titularId;
$this->saldo = $evento->saldoInicial;
$this->ativa = true;
}
private function aplicarDepositoRealizado(DepositoRealizadoEvent $evento): void
{
$this->saldo += $evento->valor;
}
private function aplicarSaqueRealizado(SaqueRealizadoEvent $evento): void
{
$this->saldo -= $evento->valor;
}
// Getters
public function getId(): string
{
return $this->id;
}
public function getSaldo(): float
{
return $this->saldo;
}
public function isAtiva(): bool
{
return $this->ativa;
}
}
3. Event Store - Armazenando Eventos
O Event Store é responsável por persistir e recuperar eventos:
<?php
interface EventStore
{
public function salvarEventos(string $aggregateId, array $eventos, int $versaoEsperada): void;
public function buscarEventos(string $aggregateId): array;
public function buscarEventosApartirVersao(string $aggregateId, int $versao): array;
}
class MySQLEventStore implements EventStore
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function salvarEventos(string $aggregateId, array $eventos, int $versaoEsperada): void
{
$this->pdo->beginTransaction();
try {
// Verificar versão para evitar conflitos
$stmt = $this->pdo->prepare(
'SELECT MAX(version) as max_version FROM events WHERE aggregate_id = ?'
);
$stmt->execute([$aggregateId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$versaoAtual = $result['max_version'] ?? 0;
if ($versaoAtual !== $versaoEsperada) {
throw new DomainException('Conflito de concorrência detectado');
}
// Salvar novos eventos
$stmt = $this->pdo->prepare(
'INSERT INTO events (event_id, aggregate_id, event_type, version, payload, timestamp)
VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($eventos as $evento) {
$stmt->execute([
$evento->getEventId(),
$evento->getAggregateId(),
$evento->getEventType(),
$evento->getVersion(),
json_encode($evento->getPayload()),
$evento->getTimestamp()->format('Y-m-d H:i:s')
]);
}
$this->pdo->commit();
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function buscarEventos(string $aggregateId): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM events WHERE aggregate_id = ? ORDER BY version ASC'
);
$stmt->execute([$aggregateId]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map([$this, 'reconstruirEvento'], $rows);
}
public function buscarEventosApartirVersao(string $aggregateId, int $versao): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM events WHERE aggregate_id = ? AND version > ? ORDER BY version ASC'
);
$stmt->execute([$aggregateId, $versao]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map([$this, 'reconstruirEvento'], $rows);
}
private function reconstruirEvento(array $row): DomainEvent
{
$payload = json_decode($row['payload'], true);
$timestamp = new DateTime($row['timestamp']);
switch ($row['event_type']) {
case 'ContaCriada':
return new ContaCriadaEvent(
$row['aggregate_id'],
(int)$row['version'],
$payload['titularId'],
(float)$payload['saldoInicial'],
$timestamp
);
case 'DepositoRealizado':
return new DepositoRealizadoEvent(
$row['aggregate_id'],
(int)$row['version'],
(float)$payload['valor'],
$payload['origem'],
$timestamp
);
case 'SaqueRealizado':
return new SaqueRealizadoEvent(
$row['aggregate_id'],
(int)$row['version'],
(float)$payload['valor'],
$payload['local'],
$timestamp
);
default:
throw new InvalidArgumentException('Tipo de evento desconhecido: ' . $row['event_type']);
}
}
}
4. Repository com Event Sourcing
O repository trabalha com o Event Store para reconstituir aggregates:
<?php
class ContaBancariaRepository
{
private EventStore $eventStore;
public function __construct(EventStore $eventStore)
{
$this->eventStore = $eventStore;
}
public function buscarPorId(string $id): ?ContaBancaria
{
$eventos = $this->eventStore->buscarEventos($id);
if (empty($eventos)) {
return null;
}
return ContaBancaria::fromHistory($eventos);
}
public function salvar(ContaBancaria $conta): void
{
$eventosNaoCommitados = $conta->getEventosNaoCommitados();
if (empty($eventosNaoCommitados)) {
return;
}
$versaoEsperada = $conta->getVersao() - count($eventosNaoCommitados);
$this->eventStore->salvarEventos(
$conta->getId(),
$eventosNaoCommitados,
$versaoEsperada
);
$conta->marcarEventosComoCommitados();
}
}
Projeções e Read Models
Com Event Sourcing, criamos projeções otimizadas para consultas específicas:
<?php
// Projeção para saldo atual
class SaldoAtualProjection
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function handle(DomainEvent $evento): void
{
switch ($evento->getEventType()) {
case 'ContaCriada':
$this->criarRegistroSaldo($evento);
break;
case 'DepositoRealizado':
$this->adicionarValor($evento);
break;
case 'SaqueRealizado':
$this->subtrairValor($evento);
break;
}
}
private function criarRegistroSaldo(ContaCriadaEvent $evento): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO saldos_atuais (conta_id, saldo, ultima_atualizacao) VALUES (?, ?, ?)'
);
$stmt->execute([
$evento->getAggregateId(),
$evento->saldoInicial,
$evento->getTimestamp()->format('Y-m-d H:i:s')
]);
}
private function adicionarValor(DepositoRealizadoEvent $evento): void
{
$stmt = $this->pdo->prepare(
'UPDATE saldos_atuais SET saldo = saldo + ?, ultima_atualizacao = ? WHERE conta_id = ?'
);
$stmt->execute([
$evento->valor,
$evento->getTimestamp()->format('Y-m-d H:i:s'),
$evento->getAggregateId()
]);
}
private function subtrairValor(SaqueRealizadoEvent $evento): void
{
$stmt = $this->pdo->prepare(
'UPDATE saldos_atuais SET saldo = saldo - ?, ultima_atualizacao = ? WHERE conta_id = ?'
);
$stmt->execute([
$evento->valor,
$evento->getTimestamp()->format('Y-m-d H:i:s'),
$evento->getAggregateId()
]);
}
}
// Projeção para extrato
class ExtratoProjection
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function handle(DomainEvent $evento): void
{
switch ($evento->getEventType()) {
case 'DepositoRealizado':
$this->adicionarMovimentacao($evento, 'CREDITO');
break;
case 'SaqueRealizado':
$this->adicionarMovimentacao($evento, 'DEBITO');
break;
}
}
private function adicionarMovimentacao(DomainEvent $evento, string $tipo): void
{
$payload = $evento->getPayload();
$stmt = $this->pdo->prepare(
'INSERT INTO extrato (conta_id, tipo, valor, descricao, data_movimentacao) VALUES (?, ?, ?, ?, ?)'
);
$stmt->execute([
$evento->getAggregateId(),
$tipo,
$payload['valor'],
$tipo === 'CREDITO' ? 'Depósito via ' . $payload['origem'] : 'Saque em ' . $payload['local'],
$evento->getTimestamp()->format('Y-m-d H:i:s')
]);
}
}
Vantagens e Desvantagens
Vantagens do CQRS + Event Sourcing
Escalabilidade: Reads e writes podem ser otimizados e escalados independentemente.
Auditoria Completa: Histórico completo de todas as mudanças no sistema.
Flexibilidade: Novas projeções podem ser criadas sem afetar o modelo de escrita.
Resiliência: Eventos imutáveis facilitam a recuperação de falhas.
Insights de Negócio: O histórico de eventos oferece dados valiosos para análise.
Desvantagens e Cuidados
Complexidade: Significativamente mais complexo que uma abordagem tradicional.
Consistência Eventual: Reads podem estar temporariamente defasados.
Curva de Aprendizado: Requer mudança de mindset da equipe.
Overhead: Pode ser excessivo para sistemas simples.
Debugging: Mais difícil debuggar problemas em produção.
Quando Usar CQRS e Event Sourcing
Cenários Ideais
Sistemas com Alta Escala: Quando reads e writes têm padrões de acesso muito diferentes.
Auditoria Rigorosa: Sistemas financeiros, médicos, legais que precisam de histórico completo.
Análise de Comportamento: Quando você precisa entender "como" os dados mudaram, não apenas "o que" mudou.
Múltiplas Representações: Quando os mesmos dados precisam ser apresentados de formas muito diferentes.
Quando NÃO Usar
Sistemas Simples: CRUD básico sem necessidades especiais de performance ou auditoria.
Equipes Inexperientes: O padrão requer conhecimento sólido de DDD e arquitetura.
Requisitos de Consistência Imediata: Quando não pode haver delay entre write e read.
Conclusão
CQRS e Event Sourcing são padrões poderosos que, quando aplicados corretamente, podem resolver problemas complexos de escalabilidade, auditoria e flexibilidade. No entanto, eles vêm com complexidade adicional significativa e devem ser usados apenas quando os benefícios justificam claramente essa complexidade.
A implementação destes padrões requer planejamento cuidadoso, especialmente na definição de eventos e projeções. Mas quando bem implementados, eles oferecem uma base sólida para sistemas altamente escaláveis e auditáveis.
No próximo e último artigo da série, exploraremos Hexagonal Architecture e Clean Architecture, padrões arquiteturais que complementam perfeitamente os conceitos de DDD que estudamos.