PHP

Domain-Driven Design Já leu

11 min de leitura

Domain-Driven Design
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 preci

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 anterior 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
{
    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,
    ) {}

    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();
                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
    {
        $carrinho = new Carrinho(
            id:           $dados['id'],
            clienteId:    $dados['cliente_id'],
            atualizadoEm: new \DateTimeImmutable($dados['atualizado_em']),
        );
        foreach ($dados['itens'] as $item) {
            // Reconstitui sem disparar eventos — bypass via reflexão ou método interno
            $carrinho->adicionarItem(
                produtoId:   $item['produto_id'],
                nomeProduto: $item['nome_produto'],
                preco:       new Dinheiro($item['preco_centavos']),
                quantidade:  $item['quantidade'],
            );
            // Limpa eventos gerados durante reconstituição
            $carrinho->coletarEventos();
        }
        return $carrinho;
    }
}

Resumo do artigo

Conceito Característica principal
Value Object Igualdade por valor, imutável, validação no construtor
Entity Tem identidade (ID), mutável com regras de negócio
Aggregate Root Único ponto de entrada — protege invariantes do cluster
Domain Event Fato ocorrido no passado, imutável, nome no passado
Repository Simula coleção em memória — abstrai persistência

Exercício da semana

  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().

  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.

  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.

  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.

  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.


Referências

  • Eric Evans — Domain-Driven Design (livro): https://www.oreilly.com/library/view/domain-driven-design-tackling/0321125215/
  • Vaughn Vernon — Implementing DDD: https://vaughnvernon.com
  • Martin Fowler — Value Object: https://martinfowler.com/bliki/ValueObject.html
  • Martin Fowler — Domain Event: https://martinfowler.com/eaaDev/DomainEvent.html
Comentários

Mais em PHP

Strings em Profundidade
Strings em Profundidade

Strings s&atilde;o o tipo de dado mais presente em qualquer aplica&ccedil;&at...

Artigo 49 — Performance e Otimização
Artigo 49 — Performance e Otimização

Performance não é premature optimization — é entender onde estão os gargalos...

Artigo 48 — Deploy e Ambiente de Produção
Artigo 48 — Deploy e Ambiente de Produção

Saber desenvolver é necessário, mas não suficiente. Um sistema que funciona p...