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

Blade — Templates no Laravel
Blade — Templates no Laravel

Sintaxe, herança de layouts, componentes com slots e formulários com erros de…

Segurança em Formulários: XSS, CSRF e SQL Injection
Segurança em Formulários: XSS, CSRF e SQL Injection

Como cada um dos três ataques funciona por dentro e por que as defesas acabam…

Strings em Profundidade
Strings em Profundidade

Texto em PHP é sequência de bytes, e é dessa decisão que vêm quase todas as…