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
-
Implemente o Value Object
Enderecocom camposlogradouro,numero,complemento,bairro,cidade,estado(enumEstadoBrasileiro) ecep. Valide o CEP com regex. Implementeequals()eformatado(). -
Crie o Aggregate
Estoquecom Aggregate Root que gerencia entradas e saídas. Regras: saldo nunca fica negativo; movimentações acima de R$ 10.000 em retirada geram eventoMovimentacaoAltaValor. Use o Value ObjectDinheiro. -
Implemente o
EventoDispatchercompleto com suporte a handlers assíncronos: os eventos são armazenados em fila (array) e processados em lote viaprocessarFila(): void. Escreva testes que verificam a ordem de processamento. -
Crie
CarrinhoRepositorioEmMemoriapara uso em testes: armazena carrinhos em array estático, implementa todos os métodos da interface. Escreva testes de integração doCarrinhousando este repositório. -
Desafio: implemente o padrão Outbox para Domain Events: ao salvar uma entidade, persista os eventos numa tabela
outbox_eventsdentro da mesma transação. Um processo separado (simulado por um métodoprocessarOutbox()) 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