Domain-Driven Design

Domain-Driven Design

Value Objects que recusam estado inválido no construtor, um Aggregate que protege a própria invariante, eventos que descrevem o que já aconteceu e um repositório com cara de coleção. Com atenção à fronteira mais escorregadia do modelo: a volta do banco.
PHP

• • 28 min de leitura

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

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);
    }
}
Comentários

Mais em PHP

SQLite com PHP
SQLite com PHP

Um arquivo, zero configuração e uma busca textual com FTS5 que dispensa…

Testes Automatizados com PHPUnit
Testes Automatizados com PHPUnit

Do teste unitário de uma entidade ao de integração com SQLite em memória…

Autenticação JWT
Autenticação JWT

Assinar e conferir o token à mão para enxergar o mecanismo, depois passar para…