Domain-Driven Design (DDD) é uma abordagem para desenvolver software complexo colocando o modelo de domínio no centro de todas as decisões. Criado por Eric Evans no livro seminal de 2003, DDD oferece um vocabulário preciso — Aggregates, Value Objects, Domain Events, Repositories — para modelar problemas do mundo real em código.
O artigo Clean Architecture com PHP introduziu Clean Architecture com uma entidade Pedido. Neste artigo aprofundamos os blocos de construção do DDD: Value Objects com igualdade por valor, Aggregates com invariantes protegidas, Domain Events para comunicação entre contextos, e o padrão Repository como coleção em memória.
Value Objects — identidade por valor
Value Objects não têm identidade própria — dois Value Objects com os mesmos atributos são iguais. São imutáveis: qualquer "modificação" retorna uma nova instância.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
// ── Email — Value Object ──────────────────────────────────────────────
final class Email
{
private readonly string $valor;
public function __construct(string $email)
{
$normalizado = strtolower(trim($email));
if (!filter_var($normalizado, FILTER_VALIDATE_EMAIL)) {
throw new \DomainException("Email inválido: '{$email}'");
}
$this->valor = $normalizado;
}
public function valor(): string { return $this->valor; }
// Igualdade por valor — dois Emails com mesmo endereço são iguais
public function equals(self $outro): bool
{
return $this->valor === $outro->valor;
}
public function __toString(): string { return $this->valor; }
}
// ── Dinheiro — Value Object com operações aritméticas ────────────────
final class Dinheiro
{
// Cuidado ao ler chamadas deste Value Object: o construtor recebe CENTAVOS e
// a fábrica de() recebe REAIS. new Dinheiro(100) é um real; Dinheiro::de(100)
// é cem reais. Trocar um pelo outro passa por qualquer verificação de tipo.
public function __construct(
private readonly int $centavos, // armazena em centavos para evitar float imprecision
private readonly string $moeda = 'BRL',
) {
if ($centavos < 0) {
throw new \DomainException("Valor monetário não pode ser negativo.");
}
}
public static function de(float $reais, string $moeda = 'BRL'): self
{
return new self((int) round($reais * 100), $moeda);
}
public function somar(self $outro): self
{
$this->assertMesmaMoeda($outro);
return new self($this->centavos + $outro->centavos, $this->moeda);
}
public function subtrair(self $outro): self
{
$this->assertMesmaMoeda($outro);
if ($outro->centavos > $this->centavos) {
throw new \DomainException("Subtração resultaria em valor negativo.");
}
return new self($this->centavos - $outro->centavos, $this->moeda);
}
public function multiplicar(int $fator): self
{
return new self($this->centavos * $fator, $this->moeda);
}
public function aplicarDesconto(float $percentual): self
{
if ($percentual < 0 || $percentual > 100) {
throw new \DomainException("Percentual inválido: {$percentual}");
}
$desconto = (int) round($this->centavos * $percentual / 100);
return new self($this->centavos - $desconto, $this->moeda);
}
public function maiorQue(self $outro): bool
{
$this->assertMesmaMoeda($outro);
return $this->centavos > $outro->centavos;
}
public function equals(self $outro): bool
{
return $this->centavos === $outro->centavos && $this->moeda === $outro->moeda;
}
public function reais(): float { return $this->centavos / 100; }
public function centavos(): int { return $this->centavos; }
public function moeda(): string { return $this->moeda; }
public function formatar(): string
{
return 'R$ ' . number_format($this->reais(), 2, ',', '.');
}
private function assertMesmaMoeda(self $outro): void
{
if ($this->moeda !== $outro->moeda) {
throw new \DomainException(
"Operação entre moedas diferentes: {$this->moeda} e {$outro->moeda}"
);
}
}
}
// ── CPF — Value Object ────────────────────────────────────────────────
final class Cpf
{
private readonly string $numero;
public function __construct(string $cpf)
{
$limpo = preg_replace('/\D/', '', $cpf);
if (!$this->validar($limpo)) {
throw new \DomainException("CPF inválido: '{$cpf}'");
}
$this->numero = $limpo;
}
public function numero(): string { return $this->numero; }
public function formatado(): string
{
return substr($this->numero, 0, 3) . '.'
. substr($this->numero, 3, 3) . '.'
. substr($this->numero, 6, 3) . '-'
. substr($this->numero, 9, 2);
}
public function equals(self $outro): bool
{
return $this->numero === $outro->numero;
}
private function validar(string $cpf): bool
{
if (strlen($cpf) !== 11 || preg_match('/^(\d)\1{10}$/', $cpf)) return false;
for ($t = 9; $t < 11; $t++) {
$soma = 0;
for ($c = 0; $c < $t; $c++) {
$soma += (int) $cpf[$c] * (($t + 1) - $c);
}
$digito = ((10 * $soma) % 11) % 10;
if ((int) $cpf[$t] !== $digito) return false;
}
return true;
}
}
Aggregates — consistência garantida
Um Aggregate é um cluster de objetos de domínio tratado como uma unidade para fins de persistência. O Aggregate Root é o único ponto de entrada — nada acessa os objetos internos diretamente.
<?php
declare(strict_types=1);
namespace App\Domain\Carrinho;
use App\Domain\Shared\{Dinheiro};
// ── Entidade interna do Aggregate — não exposta externamente ─────────
class ItemDoCarrinho
{
public function __construct(
private readonly int $produtoId,
private readonly string $nomeProduto,
private readonly Dinheiro $precoUnitario,
private int $quantidade,
) {}
public function aumentarQuantidade(int $incremento): void
{
if ($incremento <= 0) {
throw new \DomainException("Incremento deve ser positivo.");
}
$this->quantidade += $incremento;
}
public function definirQuantidade(int $quantidade): void
{
if ($quantidade <= 0) {
throw new \DomainException("Quantidade deve ser positiva.");
}
$this->quantidade = $quantidade;
}
public function subtotal(): Dinheiro
{
return $this->precoUnitario->multiplicar($this->quantidade);
}
public function produtoId(): int { return $this->produtoId; }
public function nomeProduto(): string { return $this->nomeProduto; }
public function quantidade(): int { return $this->quantidade; }
public function precoUnitario(): Dinheiro { return $this->precoUnitario; }
}
// ── Carrinho — Aggregate Root ─────────────────────────────────────────
class Carrinho
{
/** @var ItemDoCarrinho[] */
private array $itens = [];
/** @var object[] Domain Events coletados */
private array $eventos = [];
private const LIMITE_ITENS = 50;
public function __construct(
public readonly string $id, // UUID do carrinho
public readonly int $clienteId,
private \DateTimeImmutable $atualizadoEm,
) {}
/**
* Porta de volta do banco. Devolve o Aggregate ao estado em que foi salvo,
* sem revalidar regra que já valeu, sem emitir evento de fato antigo e
* sem tocar no timestamp.
*
* @param ItemDoCarrinho[] $itens
*/
public static function reconstituir(
string $id,
int $clienteId,
\DateTimeImmutable $atualizadoEm,
array $itens = [],
): static {
$carrinho = new static($id, $clienteId, $atualizadoEm);
$carrinho->itens = $itens;
return $carrinho;
}
public function adicionarItem(
int $produtoId,
string $nomeProduto,
Dinheiro $preco,
int $quantidade,
): void {
if ($quantidade <= 0) {
throw new \DomainException("Quantidade deve ser positiva.");
}
// Regra: se o produto já está no carrinho, incrementa a quantidade
foreach ($this->itens as $item) {
if ($item->produtoId() === $produtoId) {
$item->aumentarQuantidade($quantidade);
$this->tocarTimestamp();
// O evento vale aqui também. Para quem escuta, "item adicionado"
// é o mesmo fato, o produto tendo entrado agora ou apenas subido
// de quantidade. Emitindo só no caminho de item novo, o handler
// de cache e o de analytics perdem toda reposição.
$this->eventos[] = new ItemAdicionadoAoCarrinho(
carrinhoId: $this->id,
produtoId: $produtoId,
quantidade: $quantidade,
);
return;
}
}
// Regra: limite de itens distintos
if (count($this->itens) >= self::LIMITE_ITENS) {
throw new \DomainException(
"O carrinho não pode ter mais de " . self::LIMITE_ITENS . " itens diferentes."
);
}
$this->itens[] = new ItemDoCarrinho($produtoId, $nomeProduto, $preco, $quantidade);
$this->tocarTimestamp();
// Registra evento de domínio
$this->eventos[] = new ItemAdicionadoAoCarrinho(
carrinhoId: $this->id,
produtoId: $produtoId,
quantidade: $quantidade,
);
}
public function removerItem(int $produtoId): void
{
$antes = count($this->itens);
$this->itens = array_values(
array_filter($this->itens, fn($i) => $i->produtoId() !== $produtoId)
);
if (count($this->itens) === $antes) {
throw new \DomainException("Produto #{$produtoId} não está no carrinho.");
}
$this->tocarTimestamp();
}
public function atualizarQuantidade(int $produtoId, int $quantidade): void
{
foreach ($this->itens as $item) {
if ($item->produtoId() === $produtoId) {
$item->definirQuantidade($quantidade);
$this->tocarTimestamp();
return;
}
}
throw new \DomainException("Produto #{$produtoId} não está no carrinho.");
}
public function limpar(): void
{
$this->itens = [];
$this->tocarTimestamp();
}
public function total(): Dinheiro
{
return array_reduce(
$this->itens,
fn(Dinheiro $acc, ItemDoCarrinho $i) => $acc->somar($i->subtotal()),
Dinheiro::de(0),
);
}
public function estaVazio(): bool { return empty($this->itens); }
/** @return ItemDoCarrinho[] */
public function itens(): array { return $this->itens; }
public function atualizadoEm(): \DateTimeImmutable { return $this->atualizadoEm; }
/** Retorna e limpa os eventos pendentes (padrão collect-and-clear) */
public function coletarEventos(): array
{
$eventos = $this->eventos;
$this->eventos = [];
return $eventos;
}
private function tocarTimestamp(): void
{
$this->atualizadoEm = new \DateTimeImmutable();
}
}
Domain Events
Domain Events representam algo significativo que aconteceu no domínio. São objetos imutáveis que descrevem o fato no passado — nomeados no passado (PedidoConfirmado, ItemAdicionado).
<?php
declare(strict_types=1);
namespace App\Domain\Carrinho;
// ── Evento base — imutável, timestamp automático ──────────────────────
abstract class EventoDeDominio
{
public readonly \DateTimeImmutable $ocorridoEm;
public readonly string $eventoId;
public function __construct()
{
$this->ocorridoEm = new \DateTimeImmutable();
$this->eventoId = bin2hex(random_bytes(16));
}
}
// ── Eventos concretos ─────────────────────────────────────────────────
final class ItemAdicionadoAoCarrinho extends EventoDeDominio
{
public function __construct(
public readonly string $carrinhoId,
public readonly int $produtoId,
public readonly int $quantidade,
) {
parent::__construct();
}
}
final class CarrinhoFinalizado extends EventoDeDominio
{
public function __construct(
public readonly string $carrinhoId,
public readonly int $clienteId,
public readonly int $totalCentavos,
) {
parent::__construct();
}
}
// ── Dispatcher — publica eventos para os handlers ────────────────────
interface EventoDispatcherInterface
{
public function despachar(EventoDeDominio ...$eventos): void;
}
class EventoDispatcher implements EventoDispatcherInterface
{
/** @var array<string, callable[]> */
private array $handlers = [];
public function escutar(string $classeEvento, callable $handler): void
{
$this->handlers[$classeEvento][] = $handler;
}
public function despachar(EventoDeDominio ...$eventos): void
{
foreach ($eventos as $evento) {
$classe = get_class($evento);
foreach ($this->handlers[$classe] ?? [] as $handler) {
$handler($evento);
}
}
}
}
// ── Uso: registrar handlers e despachar após persistência ────────────
$dispatcher = new EventoDispatcher();
$dispatcher->escutar(
ItemAdicionadoAoCarrinho::class,
function (ItemAdicionadoAoCarrinho $evento): void {
// Atualiza cache de contagem de carrinhos com o produto
// apc_inc("produto:{$evento->produtoId}:carrinhos");
error_log("Item {$evento->produtoId} adicionado ao carrinho {$evento->carrinhoId}");
}
);
$dispatcher->escutar(
CarrinhoFinalizado::class,
function (CarrinhoFinalizado $evento): void {
// Dispara analytics, notificações, etc.
error_log("Carrinho {$evento->carrinhoId} finalizado: R$ " . ($evento->totalCentavos / 100));
}
);
Repository como coleção em memória
No DDD, um Repository simula uma coleção em memória. Você adiciona, busca e remove como se fosse um array — os detalhes de persistência são irrelevantes para o domínio.
<?php
declare(strict_types=1);
namespace App\Domain\Carrinho;
// Interface no domínio — parece uma coleção, não um DAO
interface CarrinhoRepositorioInterface
{
public function adicionar(Carrinho $carrinho): void;
public function buscarPorId(string $id): ?Carrinho;
public function buscarPorCliente(int $clienteId): ?Carrinho;
public function remover(string $id): void;
}
// ── Implementação em Redis (infraestrutura) ───────────────────────────
class CarrinhoRepositorioRedis implements CarrinhoRepositorioInterface
{
private const TTL = 86400 * 7; // 7 dias
public function __construct(private readonly \Redis $redis) {}
public function adicionar(Carrinho $carrinho): void
{
$dados = $this->serializar($carrinho);
$this->redis->setex("carrinho:{$carrinho->id}", self::TTL, json_encode($dados));
$this->redis->setex("carrinho:cliente:{$carrinho->clienteId}", self::TTL, $carrinho->id);
}
public function buscarPorId(string $id): ?Carrinho
{
$raw = $this->redis->get("carrinho:{$id}");
if ($raw === false) return null;
return $this->reconstituir(json_decode($raw, associative: true));
}
public function buscarPorCliente(int $clienteId): ?Carrinho
{
$id = $this->redis->get("carrinho:cliente:{$clienteId}");
if ($id === false) return null;
return $this->buscarPorId($id);
}
public function remover(string $id): void
{
$carrinho = $this->buscarPorId($id);
if ($carrinho !== null) {
$this->redis->del("carrinho:{$id}");
$this->redis->del("carrinho:cliente:{$carrinho->clienteId}");
}
}
private function serializar(Carrinho $c): array
{
return [
'id' => $c->id,
'cliente_id' => $c->clienteId,
'atualizado_em'=> $c->atualizadoEm()->format(\DateTimeInterface::ATOM),
'itens' => array_map(fn(ItemDoCarrinho $i) => [
'produto_id' => $i->produtoId(),
'nome_produto' => $i->nomeProduto(),
'preco_centavos'=> $i->precoUnitario()->centavos(),
'quantidade' => $i->quantidade(),
], $c->itens()),
];
}
private function reconstituir(array $dados): Carrinho
{
// Reconstituir não é adicionar. Passando pelo adicionarItem(), o
// carrinho revalida regras que já valeram, emite eventos de fatos
// antigos e — o mais silencioso dos três — chama tocarTimestamp():
// o atualizado_em que veio do Redis é trocado por "agora", e todo
// carrinho carregado passa a parecer recém-mexido, o que estraga
// qualquer limpeza ou relatório baseado nesse campo.
return Carrinho::reconstituir(
id: $dados['id'],
clienteId: $dados['cliente_id'],
atualizadoEm: new \DateTimeImmutable($dados['atualizado_em']),
itens: array_map(
fn(array $i) => new ItemDoCarrinho(
$i['produto_id'],
$i['nome_produto'],
new Dinheiro($i['preco_centavos']),
$i['quantidade'],
),
$dados['itens'],
),
);
}
}
O que o DDD entrega não é um conjunto de classes-base, é um vocabulário que obriga a decidir. Chamar algo de Value Object é afirmar que dois com o mesmo conteúdo são a mesma coisa; chamar de Aggregate é assumir a responsabilidade de manter uma invariante válida a cada operação. Um Dinheiro que guarda centavos em inteiro e recusa moeda diferente resolve, de uma vez, uma classe inteira de bugs que float solto produziria pelos próximos anos.
A fronteira mais traiçoeira é a da volta do banco. O caminho que monta o objeto pela primeira vez e o que o traz de volta parecem o mesmo e não são: o primeiro valida, registra o que aconteceu e marca o horário; o segundo só precisa recolocar o estado no lugar. Reaproveitar um no outro custa eventos duplicados e um campo de data que passa a mentir sem nunca falhar em teste nenhum.
Fontes e leituras recomendadas
- Eric Evans — Domain-Driven Design (livro): https://www.oreilly.com/library/view/domain-driven-design-tackling/0321125215/
- Vaughn Vernon — Implementing DDD: https://vaughnvernon.com
- Martin Fowler — Value Object: https://martinfowler.com/bliki/ValueObject.html
- Martin Fowler — Domain Event: https://martinfowler.com/eaaDev/DomainEvent.html
- Eric Evans — DDD Reference, o resumo dos padroes liberado de graca pelo autor: https://www.domainlanguage.com/ddd/reference/
- Martin Fowler — DDD Aggregate, sobre onde tracar a fronteira da consistencia: https://martinfowler.com/bliki/DDD_Aggregate.html
Exercícios
Exercício 1
Implemente o Value Object Endereco com campos logradouro, numero, complemento, bairro, cidade, estado (enum EstadoBrasileiro) e cep. Valide o CEP com regex. Implemente equals() e formatado().
Ver resposta
✓ Resposta: O equals() existe porque == e === não servem: o primeiro compara propriedade a propriedade (e "av. paulista" ≠ "Av. Paulista"), o segundo compara identidade de instância. Só o domínio sabe o que torna dois endereços iguais.
<?php
declare(strict_types=1);
namespace Domain\Shared;
enum EstadoBrasileiro: string
{
case AC = 'AC'; case AL = 'AL'; case AP = 'AP'; case AM = 'AM';
case BA = 'BA'; case CE = 'CE'; case DF = 'DF'; case ES = 'ES';
case GO = 'GO'; case MA = 'MA'; case MT = 'MT'; case MS = 'MS';
case MG = 'MG'; case PA = 'PA'; case PB = 'PB'; case PR = 'PR';
case PE = 'PE'; case PI = 'PI'; case RJ = 'RJ'; case RN = 'RN';
case RS = 'RS'; case RO = 'RO'; case RR = 'RR'; case SC = 'SC';
case SP = 'SP'; case SE = 'SE'; case TO = 'TO';
public function regiao(): string
{
return match ($this) {
self::AC, self::AP, self::AM, self::PA, self::RO, self::RR, self::TO => 'Norte',
self::AL, self::BA, self::CE, self::MA, self::PB,
self::PE, self::PI, self::RN, self::SE => 'Nordeste',
self::DF, self::GO, self::MT, self::MS => 'Centro-Oeste',
self::ES, self::MG, self::RJ, self::SP => 'Sudeste',
self::PR, self::RS, self::SC => 'Sul',
};
}
}
/**
* Value Object: sem identidade própria, imutável, comparado por valor.
* Dois endereços com os mesmos dados SÃO o mesmo endereço.
*/
final readonly class Endereco
{
private const CEP = '/^\d{5}-?\d{3}$/';
public function __construct(
public string $logradouro,
public string $numero,
public ?string $complemento,
public string $bairro,
public string $cidade,
public EstadoBrasileiro $estado,
public string $cep,
) {
// Invariante no construtor: um Endereco que existe é sempre válido.
// Não há como criar um inválido e "consertar depois".
if (!preg_match(self::CEP, $cep)) {
throw new \InvalidArgumentException("CEP inválido: {$cep}");
}
foreach (['logradouro' => $logradouro, 'numero' => $numero,
'bairro' => $bairro, 'cidade' => $cidade] as $campo => $valor) {
if (trim($valor) === '') {
throw new \InvalidArgumentException("O campo {$campo} é obrigatório.");
}
}
}
public static function deArray(array $d): self
{
return new self(
logradouro: (string) $d['logradouro'],
numero: (string) $d['numero'],
complemento: $d['complemento'] ?? null,
bairro: (string) $d['bairro'],
cidade: (string) $d['cidade'],
estado: EstadoBrasileiro::from(strtoupper((string) $d['estado'])),
cep: (string) $d['cep'],
);
}
public function cepNormalizado(): string
{
$digitos = preg_replace('/\D/', '', $this->cep);
return substr($digitos, 0, 5) . '-' . substr($digitos, 5);
}
public function formatado(): string
{
$linha1 = "{$this->logradouro}, {$this->numero}";
if ($this->complemento !== null && trim($this->complemento) !== '') {
$linha1 .= " — {$this->complemento}";
}
return implode("\n", [
$linha1,
"{$this->bairro} — {$this->cidade}/{$this->estado->value}",
'CEP ' . $this->cepNormalizado(),
]);
}
/** Igualdade por VALOR, não por identidade — a marca do Value Object. */
public function equals(self $outro): bool
{
return $this->cepNormalizado() === $outro->cepNormalizado()
&& mb_strtolower($this->logradouro) === mb_strtolower($outro->logradouro)
&& $this->numero === $outro->numero
&& ($this->complemento ?? '') === ($outro->complemento ?? '')
&& $this->estado === $outro->estado;
}
/** "Alterar" um VO = criar outro. */
public function comComplemento(?string $complemento): self
{
return new self($this->logradouro, $this->numero, $complemento,
$this->bairro, $this->cidade, $this->estado, $this->cep);
}
public function ehMesmaRegiao(self $outro): bool
{
return $this->estado->regiao() === $outro->estado->regiao();
}
}
$a = new Endereco('Av. Paulista', '1578', null, 'Bela Vista',
'São Paulo', EstadoBrasileiro::SP, '01310100');
$b = new Endereco('av. paulista', '1578', null, 'Bela Vista',
'São Paulo', EstadoBrasileiro::SP, '01310-100');
var_dump($a->equals($b)); // true — mesmo valor, escrito diferente
var_dump($a == $b); // false — o == compara propriedade a propriedade
var_dump($a === $b); // false — instâncias distintas
echo $a->formatado(), PHP_EOL;
echo $a->estado->regiao(), PHP_EOL; // Sudeste
Exercício 2
Crie o Aggregate Estoque com Aggregate Root que gerencia entradas e saídas. Regras: saldo nunca fica negativo; movimentações acima de R$ 10.000 em retirada geram evento MovimentacaoAltaValor. Use o Value Object Dinheiro.
Ver resposta
✓ Resposta: Duas marcas do agregado bem feito: a invariante é verificada antes de qualquer mudança de estado (falhou, o objeto continua íntegro), e o histórico só é acessível por cópia. Se qualquer código puder alterar as movimentações direto, a garantia do saldo evapora.
<?php
declare(strict_types=1);
namespace Domain\Estoque;
use Domain\Shared\Dinheiro;
use Domain\Shared\RegistraEventos;
final readonly class MovimentacaoAltaValor implements EventoDeDominio
{
public function __construct(
public int $produtoId,
public int $quantidade,
public int $valorCentavos,
public string $motivo,
private \DateTimeImmutable $em = new \DateTimeImmutable(),
) {}
public function ocorridoEm(): \DateTimeImmutable { return $this->em; }
public function nome(): string { return 'estoque.movimentacao_alta_valor'; }
}
final readonly class Movimentacao
{
public function __construct(
public string $tipo, // 'entrada' | 'saida'
public int $quantidade,
public Dinheiro $valorUnitario,
public string $motivo,
public \DateTimeImmutable $em = new \DateTimeImmutable(),
) {}
public function valorTotal(): Dinheiro
{
return $this->valorUnitario->multiplicar($this->quantidade);
}
}
final class SaldoInsuficienteException extends \DomainException {}
/**
* Aggregate Root: TODA alteração no estoque passa por aqui. Ninguém mexe
* na lista de movimentações por fora — é o que permite garantir a invariante
* "saldo nunca fica negativo" num lugar só.
*/
final class Estoque
{
use RegistraEventos;
private const LIMITE_ALTA = 1_000_000; // R$ 10.000,00 em centavos
/** @var Movimentacao[] */
private array $movimentacoes = [];
private function __construct(
public readonly int $produtoId,
private int $saldo,
) {}
public static function abrir(int $produtoId, int $saldoInicial = 0): self
{
if ($saldoInicial < 0) {
throw new \InvalidArgumentException('Saldo inicial não pode ser negativo.');
}
return new self($produtoId, $saldoInicial);
}
public function registrarEntrada(int $quantidade, Dinheiro $custoUnitario,
string $motivo = 'compra'): void
{
$this->exigirQuantidadePositiva($quantidade);
$this->movimentacoes[] = new Movimentacao('entrada', $quantidade, $custoUnitario, $motivo);
$this->saldo += $quantidade;
}
public function registrarSaida(int $quantidade, Dinheiro $valorUnitario,
string $motivo = 'venda'): void
{
$this->exigirQuantidadePositiva($quantidade);
// A INVARIANTE do agregado. Verificada antes de qualquer mudança de
// estado: se falhar, o objeto continua consistente.
if ($quantidade > $this->saldo) {
throw new SaldoInsuficienteException(sprintf(
'Saldo insuficiente para o produto %d: pedidas %d, disponíveis %d.',
$this->produtoId, $quantidade, $this->saldo,
));
}
$movimentacao = new Movimentacao('saida', $quantidade, $valorUnitario, $motivo);
$this->movimentacoes[] = $movimentacao;
$this->saldo -= $quantidade;
// Regra de negócio: retirada de alto valor precisa de rastro.
if ($movimentacao->valorTotal()->emCentavos() > self::LIMITE_ALTA) {
$this->registrarEvento(new MovimentacaoAltaValor(
produtoId: $this->produtoId,
quantidade: $quantidade,
valorCentavos: $movimentacao->valorTotal()->emCentavos(),
motivo: $motivo,
));
}
}
public function ajustar(int $novoSaldo, string $motivo): void
{
if ($novoSaldo < 0) {
throw new \InvalidArgumentException('Ajuste não pode deixar saldo negativo.');
}
$diferenca = $novoSaldo - $this->saldo;
if ($diferenca === 0) {
return;
}
$this->movimentacoes[] = new Movimentacao(
$diferenca > 0 ? 'entrada' : 'saida',
abs($diferenca),
Dinheiro::zero(),
"ajuste: {$motivo}",
);
$this->saldo = $novoSaldo;
}
public function saldo(): int { return $this->saldo; }
public function disponivel(int $quantidade): bool { return $this->saldo >= $quantidade; }
/** @return Movimentacao[] cópia: ninguém altera o histórico por fora */
public function movimentacoes(): array { return $this->movimentacoes; }
private function exigirQuantidadePositiva(int $quantidade): void
{
if ($quantidade <= 0) {
throw new \InvalidArgumentException('A quantidade tem de ser positiva.');
}
}
}
// ---------- Uso -----------------------------------------------------------
$estoque = Estoque::abrir(produtoId: 1, saldoInicial: 100);
$estoque->registrarEntrada(50, Dinheiro::deReais(180.00), 'compra NF 4471');
echo $estoque->saldo(), PHP_EOL; // 150
$estoque->registrarSaida(40, Dinheiro::deReais(349.90), 'venda');
echo $estoque->saldo(), PHP_EOL; // 110
// 40 × R$ 349,90 = R$ 13.996,00 → passou do limite, gerou evento
foreach ($estoque->liberarEventos() as $evento) {
echo $evento->nome(), ': R$ ', number_format($evento->valorCentavos / 100, 2, ',', '.'), PHP_EOL;
}
try {
$estoque->registrarSaida(999, Dinheiro::deReais(10.00));
} catch (SaldoInsuficienteException $e) {
echo '✗ ', $e->getMessage(), PHP_EOL;
echo 'Saldo intacto: ', $estoque->saldo(), PHP_EOL; // 110
}
Exercício 3
Implemente o EventoDispatcher completo com suporte a handlers assíncronos: os eventos são armazenados em fila (array) e processados em lote via processarFila(): void. Escreva testes que verificam a ordem de processamento.
Ver resposta
✓ Resposta: Copiar e limpar a fila antes de processar evita o laço infinito quando um handler despacha outro evento — sem isso, processarFila() pode nunca terminar. E o teste de ordenação estável protege contra um usort "otimizado" que embaralhe handlers de mesma prioridade.
<?php
declare(strict_types=1);
namespace Domain\Shared;
final class EventoDispatcher
{
/** @var array<string, array<int, array{handler: callable, prioridade: int, ordem: int, sincrono: bool}>> */
private array $handlers = [];
/** @var EventoDeDominio[] fila dos assíncronos */
private array $fila = [];
private int $sequencia = 0;
/** @var array<string, int> métricas de processamento */
private array $processados = [];
public function registrar(string $evento, callable $handler,
int $prioridade = 0, bool $sincrono = true): void
{
$this->handlers[$evento][] = [
'handler' => $handler,
'prioridade' => $prioridade,
'ordem' => $this->sequencia++,
'sincrono' => $sincrono,
];
}
public function despachar(EventoDeDominio $evento): void
{
// Síncronos rodam já; assíncronos só entram na fila. O evento é
// enfileirado UMA vez, mesmo com vários handlers assíncronos.
foreach ($this->ordenados($evento->nome()) as $registro) {
if ($registro['sincrono']) {
$this->executar($registro['handler'], $evento);
}
}
if ($this->temAssincrono($evento->nome())) {
$this->fila[] = $evento;
}
}
/** Processa a fila inteira, na ordem em que os eventos entraram (FIFO). */
public function processarFila(): int
{
$processados = 0;
// Copia e limpa ANTES de processar: um handler que despacha outro
// evento não pode fazer o laço crescer para sempre.
$lote = $this->fila;
$this->fila = [];
foreach ($lote as $evento) {
foreach ($this->ordenados($evento->nome()) as $registro) {
if (!$registro['sincrono']) {
$this->executar($registro['handler'], $evento);
}
}
$this->processados[$evento->nome()] = ($this->processados[$evento->nome()] ?? 0) + 1;
$processados++;
}
return $processados;
}
public function tamanhoDaFila(): int { return count($this->fila); }
public function metricas(): array { return $this->processados; }
/** @return array<int, array{handler: callable, prioridade: int, ordem: int, sincrono: bool}> */
private function ordenados(string $nomeEvento): array
{
$registros = $this->handlers[$nomeEvento] ?? [];
// Prioridade maior primeiro; empate resolve pela ordem de registro.
usort($registros, static fn(array $a, array $b): int =>
[$b['prioridade'], -$a['ordem']] <=> [$a['prioridade'], -$b['ordem']]);
return $registros;
}
private function temAssincrono(string $nomeEvento): bool
{
foreach ($this->handlers[$nomeEvento] ?? [] as $registro) {
if (!$registro['sincrono']) {
return true;
}
}
return false;
}
private function executar(callable $handler, EventoDeDominio $evento): void
{
try {
$handler($evento);
} catch (\Throwable $e) {
error_log("Handler de {$evento->nome()} falhou: {$e->getMessage()}");
}
}
}
// ---------------------------------------- Tests/Unit/EventoDispatcherTest.php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class EventoDispatcherTest extends TestCase
{
#[Test]
public function handlersSincronosRodamNaOrdemDePrioridade(): void
{
$ordem = [];
$d = new EventoDispatcher();
$d->registrar('teste', function () use (&$ordem): void { $ordem[] = 'baixa'; }, 0);
$d->registrar('teste', function () use (&$ordem): void { $ordem[] = 'alta'; }, 100);
$d->registrar('teste', function () use (&$ordem): void { $ordem[] = 'media'; }, 50);
$d->despachar(new EventoFalso('teste'));
self::assertSame(['alta', 'media', 'baixa'], $ordem);
}
#[Test]
public function mesmaPrioridadeMantemAOrdemDeRegistro(): void
{
$ordem = [];
$d = new EventoDispatcher();
foreach (['a', 'b', 'c'] as $nome) {
$d->registrar('teste', function () use (&$ordem, $nome): void { $ordem[] = $nome; }, 10);
}
$d->despachar(new EventoFalso('teste'));
self::assertSame(['a', 'b', 'c'], $ordem, 'A ordenação precisa ser estável.');
}
#[Test]
public function assincronoSoRodaAoProcessarAFila(): void
{
$executou = false;
$d = new EventoDispatcher();
$d->registrar('teste', function () use (&$executou): void { $executou = true; },
sincrono: false);
$d->despachar(new EventoFalso('teste'));
self::assertFalse($executou, 'Não deveria rodar no despacho.');
self::assertSame(1, $d->tamanhoDaFila());
self::assertSame(1, $d->processarFila());
self::assertTrue($executou);
self::assertSame(0, $d->tamanhoDaFila());
}
#[Test]
public function filaEhProcessadaNaOrdemDeEntrada(): void
{
$ordem = [];
$d = new EventoDispatcher();
$d->registrar('teste', function ($e) use (&$ordem): void { $ordem[] = $e->id; },
sincrono: false);
foreach ([1, 2, 3] as $id) {
$d->despachar(new EventoFalso('teste', $id));
}
$d->processarFila();
self::assertSame([1, 2, 3], $ordem);
}
#[Test]
public function handlerQueFalhaNaoInterrompeOsDemais(): void
{
$executados = [];
$d = new EventoDispatcher();
$d->registrar('teste', function (): void { throw new \RuntimeException('boom'); }, 100);
$d->registrar('teste', function () use (&$executados): void { $executados[] = 'ok'; }, 50);
$d->despachar(new EventoFalso('teste'));
self::assertSame(['ok'], $executados);
}
}
Exercício 4
Crie CarrinhoRepositorioEmMemoria para uso em testes: armazena carrinhos em array estático, implementa todos os métodos da interface. Escreva testes de integração do Carrinho usando este repositório.
Ver resposta
✓ Resposta: O array static facilita o compartilhamento entre instâncias, mas cobra o preço: sem limpar() no setUp(), o estado vaza de um teste para o outro e a suíte passa a depender da ordem — o tipo de falha que só aparece quando alguém roda um teste isolado.
<?php
declare(strict_types=1);
namespace Tests\Doubles;
use Domain\Carrinho\Carrinho;
use Domain\Carrinho\CarrinhoRepositorioInterface;
final class CarrinhoRepositorioEmMemoria implements CarrinhoRepositorioInterface
{
/** @var array<string, Carrinho> */
private static array $carrinhos = [];
public function salvar(Carrinho $carrinho): void
{
self::$carrinhos[$carrinho->id()] = clone $carrinho;
}
public function buscarPorId(string $id): ?Carrinho
{
return isset(self::$carrinhos[$id]) ? clone self::$carrinhos[$id] : null;
}
public function buscarPorCliente(int $clienteId): ?Carrinho
{
foreach (self::$carrinhos as $carrinho) {
if ($carrinho->clienteId() === $clienteId) {
return clone $carrinho;
}
}
return null;
}
public function remover(string $id): bool
{
if (!isset(self::$carrinhos[$id])) {
return false;
}
unset(self::$carrinhos[$id]);
return true;
}
public function abandonadosAntesDe(\DateTimeImmutable $data): array
{
return array_values(array_filter(
self::$carrinhos,
static fn(Carrinho $c): bool => $c->atualizadoEm() < $data && !$c->estaVazio(),
));
}
/**
* O array é estático: sem esta limpeza, um teste enxerga o carrinho
* deixado pelo anterior e a suíte passa a depender da ordem de execução.
* Chame no setUp() — sempre.
*/
public static function limpar(): void
{
self::$carrinhos = [];
}
public static function contar(): int { return count(self::$carrinhos); }
}
// ---------------------------------------- Tests/Integration/CarrinhoTest.php
declare(strict_types=1);
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CarrinhoTest extends TestCase
{
private CarrinhoRepositorioEmMemoria $repositorio;
protected function setUp(): void
{
CarrinhoRepositorioEmMemoria::limpar();
$this->repositorio = new CarrinhoRepositorioEmMemoria();
}
#[Test]
public function carrinhoPersisteEntreBuscas(): void
{
$carrinho = Carrinho::novo(clienteId: 7);
$carrinho->adicionar(produtoId: 1, quantidade: 2, preco: Dinheiro::deReais(349.90));
$this->repositorio->salvar($carrinho);
$recuperado = $this->repositorio->buscarPorId($carrinho->id());
self::assertNotNull($recuperado);
self::assertSame(69980, $recuperado->total()->emCentavos());
self::assertCount(1, $recuperado->itens());
}
#[Test]
public function alterarOObjetoRecuperadoNaoAfetaOArmazenado(): void
{
$carrinho = Carrinho::novo(clienteId: 7);
$carrinho->adicionar(1, 1, Dinheiro::deReais(100.00));
$this->repositorio->salvar($carrinho);
$copia = $this->repositorio->buscarPorId($carrinho->id());
$copia->adicionar(2, 5, Dinheiro::deReais(50.00));
// Sem salvar, a alteração não pode ter vazado para o repositório.
self::assertCount(1, $this->repositorio->buscarPorId($carrinho->id())->itens());
}
#[Test]
public function fluxoCompletoDeCompra(): void
{
$carrinho = Carrinho::novo(clienteId: 7);
$carrinho->adicionar(1, 2, Dinheiro::deReais(349.90));
$carrinho->adicionar(2, 1, Dinheiro::deReais(129.50));
$this->repositorio->salvar($carrinho);
$carrinho = $this->repositorio->buscarPorCliente(7);
$carrinho->remover(produtoId: 2);
$this->repositorio->salvar($carrinho);
self::assertSame(69980, $this->repositorio->buscarPorCliente(7)->total()->emCentavos());
$this->repositorio->remover($carrinho->id());
self::assertNull($this->repositorio->buscarPorCliente(7));
}
#[Test]
public function carrinhoAbandonadoEhEncontradoPelaData(): void
{
$antigo = Carrinho::novo(clienteId: 7);
$antigo->adicionar(1, 1, Dinheiro::deReais(100.00));
$antigo->marcarAtualizadoEm(new \DateTimeImmutable('-10 days'));
$recente = Carrinho::novo(clienteId: 8);
$recente->adicionar(1, 1, Dinheiro::deReais(100.00));
$this->repositorio->salvar($antigo);
$this->repositorio->salvar($recente);
$abandonados = $this->repositorio->abandonadosAntesDe(new \DateTimeImmutable('-7 days'));
self::assertCount(1, $abandonados);
self::assertSame(7, $abandonados[0]->clienteId());
}
}
Exercício 5
Desafio: implemente o padrão Outbox para Domain Events: ao salvar uma entidade, persista os eventos numa tabela outbox_events dentro da mesma transação. Um processo separado (simulado por um método processarOutbox()) lê os eventos não processados e os despacha, marcando-os como processados.
Ver resposta
✓ Resposta: O Outbox entrega at-least-once, não exactly-once: se o processo cair entre publicar e marcar como processado, o evento sai de novo. Por isso todo consumidor precisa ser idempotente — e é aí que o id do evento vira chave de deduplicação.
<?php
declare(strict_types=1);
// CREATE TABLE outbox_events (
// id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
// evento VARCHAR(120) NOT NULL,
// payload JSON NOT NULL,
// ocorrido_em DATETIME(6) NOT NULL,
// processado_em DATETIME(6) NULL,
// tentativas INT UNSIGNED NOT NULL DEFAULT 0,
// ultimo_erro TEXT NULL,
// INDEX idx_outbox_pendentes (processado_em, id)
// ) ENGINE=InnoDB;
declare(strict_types=1);
namespace Infrastructure\Outbox;
/**
* O problema que o Outbox resolve: salvar no banco e publicar o evento são
* dois sistemas diferentes. Se o commit passa e a publicação falha, o evento
* some; se publica e o commit falha, o evento mente. Não há transação
* distribuída entre MySQL e Redis.
*
* A saída: gravar o evento NA MESMA TRANSAÇÃO do dado. Se o commit passou,
* o evento existe; se não passou, nem o dado nem o evento existem. Um
* processo separado publica depois.
*/
final class OutboxRepositorio
{
public function __construct(private readonly \PDO $pdo) {}
/** Chamado DENTRO da transação de quem salva a entidade. */
public function registrar(EventoDeDominio $evento): void
{
if (!$this->pdo->inTransaction()) {
throw new \LogicException(
'registrar() precisa rodar dentro da transação da entidade — '
. 'fora dela, o Outbox não garante nada.'
);
}
$stmt = $this->pdo->prepare(
'INSERT INTO outbox_events (evento, payload, ocorrido_em)
VALUES (:evento, :payload, :em)'
);
$stmt->execute([
':evento' => $evento->nome(),
':payload' => json_encode($evento, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
':em' => $evento->ocorridoEm()->format('Y-m-d H:i:s.u'),
]);
}
/** @return array<int, array{id: int, evento: string, payload: array}> */
public function pendentes(int $limite = 100): array
{
$stmt = $this->pdo->prepare(
'SELECT id, evento, payload FROM outbox_events
WHERE processado_em IS NULL
ORDER BY id
LIMIT :limite
FOR UPDATE SKIP LOCKED'
);
$stmt->bindValue(':limite', $limite, \PDO::PARAM_INT);
$stmt->execute();
return array_map(
static fn(array $l): array => [
'id' => (int) $l['id'],
'evento' => $l['evento'],
'payload' => json_decode($l['payload'], true),
],
$stmt->fetchAll(\PDO::FETCH_ASSOC),
);
}
public function marcarProcessado(int $id): void
{
$this->pdo->prepare(
'UPDATE outbox_events SET processado_em = NOW(6) WHERE id = :id'
)->execute([':id' => $id]);
}
public function registrarFalha(int $id, string $erro): void
{
$this->pdo->prepare(
'UPDATE outbox_events
SET tentativas = tentativas + 1, ultimo_erro = :erro
WHERE id = :id'
)->execute([':id' => $id, ':erro' => mb_substr($erro, 0, 1000)]);
}
public function limparProcessados(int $dias = 7): int
{
$stmt = $this->pdo->prepare(
'DELETE FROM outbox_events
WHERE processado_em IS NOT NULL
AND processado_em < DATE_SUB(NOW(), INTERVAL :dias DAY)'
);
$stmt->bindValue(':dias', $dias, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->rowCount();
}
}
// ---------------------------------------- o repositório que usa o Outbox
final class PedidoRepositorioComOutbox implements PedidoRepositorioInterface
{
public function __construct(
private readonly \PDO $pdo,
private readonly PedidoRepositorioPDO $interno,
private readonly OutboxRepositorio $outbox,
) {}
public function salvar(Pedido $pedido): Pedido
{
$this->pdo->beginTransaction();
try {
$salvo = $this->interno->salvar($pedido);
// Mesma transação: ou os dois entram, ou nenhum entra.
foreach ($pedido->liberarEventos() as $evento) {
$this->outbox->registrar($evento);
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $salvo;
}
}
// ---------------------------------------- o publicador (processo separado)
final class ProcessadorOutbox
{
public function __construct(
private readonly \PDO $pdo,
private readonly OutboxRepositorio $outbox,
private readonly EventDispatcherInterface $dispatcher,
) {}
public function processarOutbox(int $limite = 100): int
{
$publicados = 0;
$this->pdo->beginTransaction();
try {
// FOR UPDATE SKIP LOCKED: vários processadores podem rodar em
// paralelo sem pegar o mesmo evento nem ficar travados.
$pendentes = $this->outbox->pendentes($limite);
foreach ($pendentes as $registro) {
try {
$this->dispatcher->despacharBruto($registro['evento'], $registro['payload']);
$this->outbox->marcarProcessado($registro['id']);
$publicados++;
} catch (\Throwable $e) {
// Falha de um evento não impede os outros; ele fica
// pendente e volta na próxima passada.
$this->outbox->registrarFalha($registro['id'], $e->getMessage());
}
}
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
return $publicados;
}
}
// * * * * * php outbox.php (a cada minuto, ou em laço com sleep curto)
$processador = new ProcessadorOutbox($pdo, new OutboxRepositorio($pdo), $dispatcher);
while (true) {
$publicados = $processador->processarOutbox();
if ($publicados === 0) {
sleep(1);
}
}