DDD: Agregados e Aggregate Roots - Garantindo consistência no modelo de domínio (Parte 5)
Este é o quinto artigo da série sobre Domain-Driven Design. Nos artigos anteriores, exploramos os conceitos fundamentais do DDD, a Linguagem Ubíqua, Bounded Contexts e os pilares do modelo de domínio com Value Objects e Entities. Agora chegou o momento de entender um dos conceitos mais importantes e, ao mesmo tempo, mais mal compreendidos do DDD: Agregados e Aggregate Roots.
O que são Agregados?
Um Agregado é um cluster (agrupamento) de objetos relacionados que devem ser tratados como uma única unidade para fins de mudanças de dados. Martin Fowler define de forma clara:
"Um agregado DDD é um cluster de objetos de domínio que pode ser tratado como uma única unidade."
O conceito de Agregado não é sobre hierarquia ou relacionamentos de dados - é sobre consistência e invariantes de negócio. Um Agregado define uma fronteira de consistência ao redor de um grupo de objetos relacionados, garantindo que as regras de negócio sejam sempre respeitadas.
A Confusão Comum
Muitos desenvolvedores pensam em Agregados como hierarquias de objetos ou relacionamentos de banco de dados. Essa é uma visão incorreta que leva a designs problemáticos. Como Derek Comartin destaca:
"Agregados não são sobre hierarquia e relacionamentos. São sobre comportamentos e invariantes que você precisa aplicar."
Erro comum:
// ❌ Pensando apenas em relacionamentos
class Pedido // Aggregate Root
{
private Cliente $cliente; // Relacionamento
private array $itens; // Relacionamento
private Endereco $endereco; // Relacionamento
// Apenas getters e setters - modelo anêmico
}
Abordagem correta:
// ✅ Focando em comportamentos e invariantes
class Pedido // Aggregate Root
{
// Dados necessários para aplicar invariantes
private PedidoId $id;
private array $itens;
private StatusPedido $status;
private Dinheiro $total;
// Comportamentos que aplicam regras de negócio
public function adicionarItem(Produto $produto, int $quantidade): void
{
$this->garantirPedidoEditavel();
$this->validarQuantidadeMaxima($quantidade);
$item = new ItemPedido($produto, $quantidade);
$this->itens[] = $item;
$this->recalcularTotal();
}
// Invariantes do agregado
private function garantirPedidoEditavel(): void
{
if ($this->status === StatusPedido::CONFIRMADO) {
throw new InvalidOperationException("Pedido confirmado não pode ser editado");
}
}
}
Aggregate Root: O Guardião da Consistência
Cada Agregado possui uma Aggregate Root (Raiz do Agregado) - uma Entity que serve como o único ponto de entrada para o Agregado. Como Eric Evans explica:
"A raiz do agregado é a única entrada para qualquer acesso ao agregado, e ela deve garantir a integridade de todo o agregado."
Responsabilidades da Aggregate Root
- Controlar acesso: Todo acesso ao agregado deve passar pela root
- Aplicar invariantes: Garantir que regras de negócio sejam sempre respeitadas
- Coordenar operações: Orquestrar mudanças entre objetos internos
- Manter identidade: Fornecer identidade única para todo o agregado
Exemplo: Agregado de Conta Bancária
class ContaBancaria // Aggregate Root
{
private ContaId $id;
private Cpf $titularCpf;
private Dinheiro $saldo;
private array $transacoes;
private StatusConta $status;
private DateTimeImmutable $dataCriacao;
public function __construct(ContaId $id, Cpf $titularCpf, Dinheiro $saldoInicial)
{
$this->id = $id;
$this->titularCpf = $titularCpf;
$this->saldo = $saldoInicial;
$this->transacoes = [];
$this->status = StatusConta::ATIVA;
$this->dataCriacao = new DateTimeImmutable();
// Registra transação inicial
$this->adicionarTransacao(TipoTransacao::DEPOSITO_INICIAL, $saldoInicial);
}
// Comportamento principal: sacar dinheiro
public function sacar(Dinheiro $valor): void
{
$this->garantirContaAtiva();
$this->validarValorPositivo($valor);
$this->garantirSaldoSuficiente($valor);
$this->saldo = $this->saldo->subtrair($valor);
$this->adicionarTransacao(TipoTransacao::SAQUE, $valor);
// Invariante: não pode ficar com saldo negativo
if ($this->saldo->valor < 0) {
throw new InvalidOperationException("Saldo insuficiente após saque");
}
}
// Comportamento: depositar dinheiro
public function depositar(Dinheiro $valor): void
{
$this->garantirContaAtiva();
$this->validarValorPositivo($valor);
$this->saldo = $this->saldo->somar($valor);
$this->adicionarTransacao(TipoTransacao::DEPOSITO, $valor);
}
// Comportamento: transferir para outra conta
public function transferirPara(ContaBancaria $contaDestino, Dinheiro $valor): void
{
$this->sacar($valor); // Aplica todas as validações de saque
$contaDestino->receberTransferencia($valor, $this->id);
}
// Método interno para receber transferência
public function receberTransferencia(Dinheiro $valor, ContaId $contaOrigem): void
{
$this->garantirContaAtiva();
$this->saldo = $this->saldo->somar($valor);
$this->adicionarTransacao(TipoTransacao::TRANSFERENCIA_RECEBIDA, $valor, $contaOrigem);
}
// Comportamento: bloquear conta
public function bloquear(MotivosBloqueio $motivo): void
{
if ($this->status === StatusConta::BLOQUEADA) {
return; // Já está bloqueada
}
$this->status = StatusConta::BLOQUEADA;
$this->adicionarTransacao(TipoTransacao::BLOQUEIO, Dinheiro::zero(), null, $motivo);
}
// Invariantes do agregado
private function garantirContaAtiva(): void
{
if ($this->status !== StatusConta::ATIVA) {
throw new InvalidOperationException("Conta não está ativa para operações");
}
}
private function validarValorPositivo(Dinheiro $valor): void
{
if ($valor->valor <= 0) {
throw new InvalidArgumentException("Valor deve ser positivo");
}
}
private function garantirSaldoSuficiente(Dinheiro $valor): void
{
if ($this->saldo->valor < $valor->valor) {
throw new InvalidOperationException("Saldo insuficiente");
}
}
// Método privado para adicionar transação
private function adicionarTransacao(
TipoTransacao $tipo,
Dinheiro $valor,
?ContaId $contaRelacionada = null,
?MotivosBloqueio $motivo = null
): void {
$transacao = new Transacao(
TransacaoId::gerar(),
$tipo,
$valor,
new DateTimeImmutable(),
$contaRelacionada,
$motivo
);
$this->transacoes[] = $transacao;
}
// Métodos de consulta
public function getSaldo(): Dinheiro
{
return $this->saldo;
}
public function getExtrato(int $ultimasDias = 30): array
{
$dataLimite = (new DateTimeImmutable())->modify("-{$ultimasDias} days");
return array_filter(
$this->transacoes,
fn($transacao) => $transacao->getData() >= $dataLimite
);
}
public function getId(): ContaId
{
return $this->id;
}
// Igualdade baseada na identidade
public function equals(ContaBancaria $outra): bool
{
return $this->id->equals($outra->getId());
}
}
// Entity interna ao agregado
class Transacao
{
public function __construct(
private readonly TransacaoId $id,
private readonly TipoTransacao $tipo,
private readonly Dinheiro $valor,
private readonly DateTimeImmutable $data,
private readonly ?ContaId $contaRelacionada = null,
private readonly ?MotivosBloqueio $motivo = null
) {}
// Getters...
public function getId(): TransacaoId { return $this->id; }
public function getTipo(): TipoTransacao { return $this->tipo; }
public function getValor(): Dinheiro { return $this->valor; }
public function getData(): DateTimeImmutable { return $this->data; }
}
// Enums e Value Objects de apoio
enum StatusConta: string
{
case ATIVA = 'ATIVA';
case BLOQUEADA = 'BLOQUEADA';
case ENCERRADA = 'ENCERRADA';
}
enum TipoTransacao: string
{
case DEPOSITO_INICIAL = 'DEPOSITO_INICIAL';
case DEPOSITO = 'DEPOSITO';
case SAQUE = 'SAQUE';
case TRANSFERENCIA_ENVIADA = 'TRANSFERENCIA_ENVIADA';
case TRANSFERENCIA_RECEBIDA = 'TRANSFERENCIA_RECEBIDA';
case BLOQUEIO = 'BLOQUEIO';
}
enum MotivosBloqueio: string
{
case SUSPEITA_FRAUDE = 'SUSPEITA_FRAUDE';
case SOLICITACAO_CLIENTE = 'SOLICITACAO_CLIENTE';
case ORDEM_JUDICIAL = 'ORDEM_JUDICIAL';
}
Definindo Fronteiras de Agregados
Uma das decisões mais críticas no DDD é onde desenhar as fronteiras dos agregados. Fronteiras muito grandes causam problemas de concorrência e performance, enquanto fronteiras muito pequenas podem não conseguir aplicar invariantes adequadamente.
Regras para Definir Fronteiras
-
Invariantes sempre dentro do agregado: Se uma regra precisa ser aplicada, os dados necessários devem estar no mesmo agregado
-
Transações não devem cruzar agregados: Cada agregado é uma fronteira transacional
-
Agregados se referenciam por identidade: Use IDs, não referências diretas
-
Modele ao redor de casos de uso: Pense em como os dados são modificados juntos
Exemplo: Sistema de E-commerce
Agregados Separados:
// Agregado 1: Produto
class Produto // Aggregate Root
{
private ProdutoId $id;
private string $nome;
private Dinheiro $preco;
private int $estoqueDisponivel;
private StatusProduto $status;
public function reduzirEstoque(int $quantidade): void
{
if ($this->estoqueDisponivel < $quantidade) {
throw new InvalidOperationException("Estoque insuficiente");
}
$this->estoqueDisponivel -= $quantidade;
if ($this->estoqueDisponivel === 0) {
$this->status = StatusProduto::SEM_ESTOQUE;
}
}
public function podeVender(int $quantidade): bool
{
return $this->status === StatusProduto::DISPONIVEL
&& $this->estoqueDisponivel >= $quantidade;
}
}
// Agregado 2: Pedido
class Pedido // Aggregate Root
{
private PedidoId $id;
private ClienteId $clienteId; // Referência por ID
private array $itens;
private StatusPedido $status;
private Dinheiro $total;
private DateTimeImmutable $dataCriacao;
public function adicionarItem(ProdutoId $produtoId, int $quantidade, Dinheiro $precoUnitario): void
{
$this->garantirPedidoEditavel();
// Não valida estoque aqui - isso é responsabilidade do agregado Produto
// A validação acontece no momento da confirmação via Domain Service
$item = new ItemPedido($produtoId, $quantidade, $precoUnitario);
$this->itens[] = $item;
$this->recalcularTotal();
}
public function confirmar(): void
{
if (empty($this->itens)) {
throw new InvalidOperationException("Pedido vazio não pode ser confirmado");
}
$this->status = StatusPedido::CONFIRMADO;
}
private function garantirPedidoEditavel(): void
{
if ($this->status !== StatusPedido::RASCUNHO) {
throw new InvalidOperationException("Pedido não pode ser editado");
}
}
private function recalcularTotal(): void
{
$this->total = array_reduce(
$this->itens,
fn($total, $item) => $total->somar($item->getSubtotal()),
Dinheiro::zero()
);
}
// Getters para acesso aos dados internos
public function getItens(): array { return $this->itens; }
public function getTotal(): Dinheiro { return $this->total; }
public function getStatus(): StatusPedido { return $this->status; }
}
// Entity interna do agregado Pedido
class ItemPedido
{
public function __construct(
private readonly ProdutoId $produtoId,
private int $quantidade,
private readonly Dinheiro $precoUnitario
) {
if ($quantidade <= 0) {
throw new InvalidArgumentException("Quantidade deve ser positiva");
}
}
public function getSubtotal(): Dinheiro
{
return $this->precoUnitario->multiplicar($this->quantidade);
}
public function alterarQuantidade(int $novaQuantidade): void
{
if ($novaQuantidade <= 0) {
throw new InvalidArgumentException("Quantidade deve ser positiva");
}
$this->quantidade = $novaQuantidade;
}
// Getters...
public function getProdutoId(): ProdutoId { return $this->produtoId; }
public function getQuantidade(): int { return $this->quantidade; }
public function getPrecoUnitario(): Dinheiro { return $this->precoUnitario; }
}
Domain Services: Coordenando Agregados
Quando uma operação envolve múltiplos agregados, usamos Domain Services. Eles coordenam operações entre agregados enquanto mantêm a lógica de domínio encapsulada.
class ProcessarPedidoService
{
public function __construct(
private readonly ProdutoRepository $produtoRepository,
private readonly PedidoRepository $pedidoRepository
) {}
public function confirmarPedido(PedidoId $pedidoId): void
{
$pedido = $this->pedidoRepository->buscarPorId($pedidoId);
if (!$pedido) {
throw new PedidoNaoEncontradoException();
}
// Valida estoque para todos os itens
foreach ($pedido->getItens() as $item) {
$produto = $this->produtoRepository->buscarPorId($item->getProdutoId());
if (!$produto) {
throw new ProdutoNaoEncontradoException($item->getProdutoId());
}
if (!$produto->podeVender($item->getQuantidade())) {
throw new EstoqueInsuficienteException(
$produto->getId(),
$item->getQuantidade()
);
}
}
// Se chegou até aqui, tudo válido - reduz estoque e confirma pedido
foreach ($pedido->getItens() as $item) {
$produto = $this->produtoRepository->buscarPorId($item->getProdutoId());
$produto->reduzirEstoque($item->getQuantidade());
$this->produtoRepository->salvar($produto);
}
$pedido->confirmar();
$this->pedidoRepository->salvar($pedido);
}
}
Repositories: Um por Agregado
Repositories devem operar no nível do Agregado, não das entidades individuais. Cada Aggregate Root tem seu próprio Repository.
interface PedidoRepository
{
public function buscarPorId(PedidoId $id): ?Pedido;
public function buscarPorCliente(ClienteId $clienteId): array;
public function salvar(Pedido $pedido): void;
public function remover(PedidoId $id): void;
}
interface ProdutoRepository
{
public function buscarPorId(ProdutoId $id): ?Produto;
public function buscarDisponiveis(): array;
public function salvar(Produto $produto): void;
}
// ❌ Não fazer: Repository para entidade interna
interface ItemPedidoRepository // ERRADO!
{
// Itens são gerenciados pelo agregado Pedido
}
Invariantes: As Regras de Ouro
Invariantes são as regras de negócio que devem sempre ser verdadeiras dentro de um agregado. Elas são a razão principal para a existência dos agregados.
Tipos de Invariantes
- Invariantes Simples: Aplicadas dentro de uma única entity
- Invariantes de Agregado: Precisam de múltiplos objetos para serem validadas
Exemplo: Agregado de Conta Corrente com Limite
class ContaCorrente // Aggregate Root
{
private ContaId $id;
private Dinheiro $saldo;
private Dinheiro $limite;
private array $transacoes;
public function __construct(ContaId $id, Dinheiro $limite)
{
$this->id = $id;
$this->saldo = Dinheiro::zero();
$this->limite = $limite;
$this->transacoes = [];
}
public function sacar(Dinheiro $valor): void
{
$novoSaldo = $this->saldo->subtrair($valor);
// Invariante: Saldo + Limite sempre >= 0
$saldoComLimite = $novoSaldo->somar($this->limite);
if ($saldoComLimite->valor < 0) {
throw new InvalidOperationException(
"Saque excede limite disponível. " .
"Limite: {$this->limite->valor}, " .
"Saldo atual: {$this->saldo->valor}, " .
"Tentativa: {$valor->valor}"
);
}
$this->saldo = $novoSaldo;
$this->adicionarTransacao(TipoTransacao::SAQUE, $valor);
// Invariante sempre mantida após operação
$this->validarInvariantes();
}
public function ajustarLimite(Dinheiro $novoLimite): void
{
$limiteMinimoNecessario = $this->saldo->valor < 0
? new Dinheiro(abs($this->saldo->valor), $this->saldo->moeda)
: Dinheiro::zero();
if ($novoLimite->valor < $limiteMinimoNecessario->valor) {
throw new InvalidOperationException(
"Novo limite é insuficiente para manter conta consistente"
);
}
$this->limite = $novoLimite;
$this->validarInvariantes();
}
// Método que valida todas as invariantes do agregado
private function validarInvariantes(): void
{
$saldoComLimite = $this->saldo->somar($this->limite);
if ($saldoComLimite->valor < 0) {
throw new InvariantViolationException(
"Invariante violada: Saldo + Limite deve ser >= 0"
);
}
}
public function getSaldoDisponivel(): Dinheiro
{
return $this->saldo->somar($this->limite);
}
}
Problemas Comuns e Como Evitá-los
1. Agregados Muito Grandes
Problema:
// ✗ Agregado muito grande
class Empresa // Aggregate Root problemática
{
private EmpresaId $id;
private array $funcionarios; // 1000+ funcionários
private array $projetos; // 100+ projetos
private array $produtos; // 500+ produtos
private array $vendas; // 10000+ vendas
// Carrega tudo na memória - performance terrível
// Contenção em transações concorrentes
}
Solução:
// ✓ Agregados menores e focados
class Empresa // Aggregate Root
{
private EmpresaId $id;
private string $razaoSocial;
private Cnpj $cnpj;
private StatusEmpresa $status;
// Apenas dados essenciais para invariantes da empresa
}
class Funcionario // Aggregate Root separado
{
private FuncionarioId $id;
private EmpresaId $empresaId; // Referência por ID
private string $nome;
private Cargo $cargo;
}
2. Falta de Invariantes Claras
Problema:
// ✗ Sem invariantes claras
class Turma
{
private array $alunos;
public function adicionarAluno(Aluno $aluno): void
{
$this->alunos[] = $aluno; // Sem validações
}
}
Solução:
// ✓ Invariantes bem definidas
class Turma // Aggregate Root
{
private const CAPACIDADE_MAXIMA = 30;
private TurmaId $id;
private array $alunos;
private StatusTurma $status;
public function adicionarAluno(Aluno $aluno): void
{
$this->garantirTurmaAberta();
$this->validarCapacidadeMaxima();
$this->validarAlunoNaoInscrito($aluno);
$this->alunos[] = $aluno;
}
private function garantirTurmaAberta(): void
{
if ($this->status !== StatusTurma::ABERTA) {
throw new InvalidOperationException("Turma não está aberta para inscrições");
}
}
private function validarCapacidadeMaxima(): void
{
if (count($this->alunos) >= self::CAPACIDADE_MAXIMA) {
throw new InvalidOperationException("Turma já atingiu capacidade máxima");
}
}
private function validarAlunoNaoInscrito(Aluno $aluno): void
{
foreach ($this->alunos as $alunoExistente) {
if ($alunoExistente->equals($aluno)) {
throw new InvalidOperationException("Aluno já está inscrito na turma");
}
}
}
}
3. Violação do Princípio "Um Repository por Agregado"
Problema:
// ✗ Repositories para entidades internas
interface ItemPedidoRepository
{
public function buscarPorPedido(PedidoId $pedidoId): array;
public function salvar(ItemPedido $item): void;
}
Solução:
// ✓ Repository apenas para Aggregate Root
interface PedidoRepository
{
public function buscarPorId(PedidoId $id): ?Pedido; // Traz o agregado completo
public function salvar(Pedido $pedido): void; // Salva o agregado completo
}
Relacionamentos Entre Agregados
Agregados diferentes se referenciam apenas por identidade, nunca por referências diretas de objetos.
Referências por Identidade
class Pedido // Aggregate Root
{
private PedidoId $id;
private ClienteId $clienteId; // ✓ Referência por ID
private EnderecoId $enderecoId; // ✓ Referência por ID
// ✗ NUNCA fazer isso:
// private Cliente $cliente; // Referência direta - ERRADO!
// private Endereco $endereco; // Referência direta - ERRADO!
}
Navegação Entre Agregados
class PedidoService
{
public function __construct(
private readonly ClienteRepository $clienteRepository,
private readonly EnderecoRepository $enderecoRepository
) {}
public function obterDetalhesPedido(Pedido $pedido): DetalhesPedido
{
// Busca agregados relacionados quando necessário
$cliente = $this->clienteRepository->buscarPorId($pedido->getClienteId());
$endereco = $this->enderecoRepository->buscarPorId($pedido->getEnderecoId());
return new DetalhesPedido($pedido, $cliente, $endereco);
}
}
Exemplo Completo: Sistema de Hotel
Vamos ver um exemplo completo de um sistema de reservas de hotel com múltiplos agregados:
// Agregado 1: Quarto
class Quarto // Aggregate Root
{
private QuartoId $id;
private string $numero;
private TipoQuarto $tipo;
private StatusQuarto $status;
private array $reservas; // Apenas reservas confirmadas
public function __construct(QuartoId $id, string $numero, TipoQuarto $tipo)
{
$this->id = $id;
$this->numero = $numero;
$this->tipo = $tipo;
$this->status = StatusQuarto::DISPONIVEL;
$this->reservas = [];
}
public function isDisponivelPara(DateTimeImmutable $checkIn, DateTimeImmutable $checkOut): bool
{
if ($this->status !== StatusQuarto::DISPONIVEL) {
return false;
}
foreach ($this->reservas as $reserva) {
if ($reserva->conflitaCom($checkIn, $checkOut)) {
return false;
}
}
return true;
}
public function adicionarReserva(ReservaId $reservaId, DateTimeImmutable $checkIn, DateTimeImmutable $checkOut): void
{
if (!$this->isDisponivelPara($checkIn, $checkOut)) {
throw new InvalidOperationException("Quarto não disponível para o período");
}
$ocupacao = new OcupacaoQuarto($reservaId, $checkIn, $checkOut);
$this->reservas[] = $ocupacao;
}
public function marcarComoManutencao(): void
{
if (!empty($this->reservas)) {
throw new InvalidOperationException("Não é possível marcar para manutenção com reservas ativas");
}
$this->status = StatusQuarto::MANUTENCAO;
}
}
// Entity interna do agregado Quarto
class OcupacaoQuarto
{
public function __construct(
private readonly ReservaId $reservaId,
private readonly DateTimeImmutable $checkIn,
private readonly DateTimeImmutable $checkOut
) {}
public function conflitaCom(DateTimeImmutable $checkIn, DateTimeImmutable $checkOut): bool
{
return !($checkOut <= $this->checkIn || $checkIn >= $this->checkOut);
}
public function getReservaId(): ReservaId { return $this->reservaId; }
public function getCheckIn(): DateTimeImmutable { return $this->checkIn; }
public function getCheckOut(): DateTimeImmutable { return $this->checkOut; }
}
// Agregado 2: Reserva
class Reserva // Aggregate Root
{
private ReservaId $id;
private HospedeId $hospedeId; // Referência por ID
private QuartoId $quartoId; // Referência por ID
private DateTimeImmutable $checkIn;
private DateTimeImmutable $checkOut;
private StatusReserva $status;
private Dinheiro $valorTotal;
private array $servicos;
public function __construct(
ReservaId $id,
HospedeId $hospedeId,
QuartoId $quartoId,
DateTimeImmutable $checkIn,
DateTimeImmutable $checkOut,
Dinheiro $valorDiaria
) {
$this->validarDatas($checkIn, $checkOut);
$this->id = $id;
$this->hospedeId = $hospedeId;
$this->quartoId = $quartoId;
$this->checkIn = $checkIn;
$this->checkOut = $checkOut;
$this->status = StatusReserva::PENDENTE;
$this->servicos = [];
$this->calcularValorTotal($valorDiaria);
}
public function confirmar(): void
{
if ($this->status !== StatusReserva::PENDENTE) {
throw new InvalidOperationException("Apenas reservas pendentes podem ser confirmadas");
}
$this->status = StatusReserva::CONFIRMADA;
}
public function adicionarServico(ServicoId $servicoId, Dinheiro $valor): void
{
if ($this->status === StatusReserva::CANCELADA) {
throw new InvalidOperationException("Não é possível adicionar serviços a reserva cancelada");
}
$servico = new ServicoReserva($servicoId, $valor, new DateTimeImmutable());
$this->servicos[] = $servico;
$this->recalcularValorTotal();
}
public function cancelar(): void
{
if ($this->status === StatusReserva::FINALIZADA) {
throw new InvalidOperationException("Reserva já foi finalizada");
}
$this->status = StatusReserva::CANCELADA;
}
private function validarDatas(DateTimeImmutable $checkIn, DateTimeImmutable $checkOut): void
{
if ($checkIn >= $checkOut) {
throw new InvalidArgumentException("Data de check-in deve ser anterior ao check-out");
}
if ($checkIn < new DateTimeImmutable('today')) {
throw new InvalidArgumentException("Data de check-in não pode ser no passado");
}
}
private function calcularValorTotal(Dinheiro $valorDiaria): void
{
$dias = $this->checkIn->diff($this->checkOut)->days;
$this->valorTotal = $valorDiaria->multiplicar($dias);
}
private function recalcularValorTotal(): void
{
$valorServicos = array_reduce(
$this->servicos,
fn($total, $servico) => $total->somar($servico->getValor()),
Dinheiro::zero()
);
$dias = $this->checkIn->diff($this->checkOut)->days;
$valorDiarias = $this->valorTotal->dividir($dias);
$this->valorTotal = $valorDiarias->somar($valorServicos);
}
// Getters...
public function getId(): ReservaId { return $this->id; }
public function getQuartoId(): QuartoId { return $this->quartoId; }
public function getCheckIn(): DateTimeImmutable { return $this->checkIn; }
public function getCheckOut(): DateTimeImmutable { return $this->checkOut; }
public function getStatus(): StatusReserva { return $this->status; }
}
// Domain Service para coordenar os agregados
class ReservaService
{
public function __construct(
private readonly QuartoRepository $quartoRepository,
private readonly ReservaRepository $reservaRepository,
private readonly HospedeRepository $hospedeRepository
) {}
public function criarReserva(
HospedeId $hospedeId,
QuartoId $quartoId,
DateTimeImmutable $checkIn,
DateTimeImmutable $checkOut
): ReservaId {
// Busca hospede para validar
$hospede = $this->hospedeRepository->buscarPorId($hospedeId);
if (!$hospede) {
throw new HospedeNaoEncontradoException();
}
// Busca quarto e verifica disponibilidade
$quarto = $this->quartoRepository->buscarPorId($quartoId);
if (!$quarto) {
throw new QuartoNaoEncontradoException();
}
if (!$quarto->isDisponivelPara($checkIn, $checkOut)) {
throw new QuartoIndisponivelException($quartoId, $checkIn, $checkOut);
}
// Cria reserva
$reservaId = ReservaId::gerar();
$valorDiaria = $quarto->getValorDiaria();
$reserva = new Reserva($reservaId, $hospedeId, $quartoId, $checkIn, $checkOut, $valorDiaria);
// Salva reserva e atualiza quarto
$this->reservaRepository->salvar($reserva);
$quarto->adicionarReserva($reservaId, $checkIn, $checkOut);
$this->quartoRepository->salvar($quarto);
return $reservaId;
}
public function confirmarReserva(ReservaId $reservaId): void
{
$reserva = $this->reservaRepository->buscarPorId($reservaId);
if (!$reserva) {
throw new ReservaNaoEncontradaException();
}
$reserva->confirmar();
$this->reservaRepository->salvar($reserva);
}
}
Testes de Agregados
Agregados são naturalmente testáveis porque encapsulam comportamentos e invariantes:
class ContaBancariaTest extends TestCase
{
public function test_deve_permitir_saque_com_saldo_suficiente(): void
{
// Arrange
$conta = new ContaBancaria(
ContaId::gerar(),
new Cpf('123.456.789-10'),
Dinheiro::reais(1000)
);
// Act
$conta->sacar(Dinheiro::reais(300));
// Assert
$this->assertEquals(700, $conta->getSaldo()->valor);
}
public function test_deve_impedir_saque_com_saldo_insuficiente(): void
{
// Arrange
$conta = new ContaBancaria(
ContaId::gerar(),
new Cpf('123.456.789-10'),
Dinheiro::reais(100)
);
// Act & Assert
$this->expectException(InvalidOperationException::class);
$this->expectExceptionMessage("Saldo insuficiente");
$conta->sacar(Dinheiro::reais(200));
}
public function test_transferencia_deve_atualizar_ambas_contas(): void
{
// Arrange
$contaOrigem = new ContaBancaria(
ContaId::gerar(),
new Cpf('111.111.111-11'),
Dinheiro::reais(1000)
);
$contaDestino = new ContaBancaria(
ContaId::gerar(),
new Cpf('222.222.222-22'),
Dinheiro::reais(500)
);
// Act
$contaOrigem->transferirPara($contaDestino, Dinheiro::reais(300));
// Assert
$this->assertEquals(700, $contaOrigem->getSaldo()->valor);
$this->assertEquals(800, $contaDestino->getSaldo()->valor);
}
}
Conclusão
Agregados e Aggregate Roots são conceitos fundamentais do DDD que ajudam a manter a consistência e integridade do modelo de domínio. Eles não são sobre hierarquias ou relacionamentos de dados, mas sim sobre comportamentos e invariantes que precisam ser aplicados consistentemente.
Pontos-chave para lembrar:
- Agregados definem fronteiras de consistência, não estruturas de dados
- Aggregate Roots são os únicos pontos de entrada para modificar o agregado
- Invariantes devem sempre ser aplicadas dentro dos limites do agregado
- Agregados se referenciam apenas por identidade, nunca por objetos diretos
- Um Repository por agregado, operando sempre no nível da Aggregate Root
- Domain Services coordenam operações entre múltiplos agregados
- Transações não devem cruzar fronteiras de agregados
Quando bem implementados, Agregados resultam em um modelo de domínio mais robusto, consistente e fácil de manter. Eles encapsulam a complexidade do negócio e garantem que as regras fundamentais sejam sempre respeitadas.
No próximo artigo da série, exploraremos Domain Services vs Application Services, entendendo quando usar cada tipo de serviço e como organizá-los adequadamente na arquitetura.
Referências
- Evans, Eric. "Domain-Driven Design: Tackling Complexity in the Heart of Software". Addison-Wesley, 2003.
- Fowler, Martin. "DDD_Aggregate". martinfowler.com
- Vernon, Vaughn. "Implementing Domain-Driven Design". Addison-Wesley, 2013.
- Comartin, Derek. "Aggregate (DDD) isn't hierarchy & relationships". CodeOpinion, 2023.
- Sharma, Ankit. "Domain-Driven Design: Aggregates in Practice". Medium, 2023.
- Stemmler, Khalil. "How to Design & Persist Aggregates - Domain-Driven Design w/ TypeScript". khalilstemmler.com
- Lukasik, Konrad. "Take your CRUD to the next level with DDD concepts". Simple Talk, 2014.
- Dahan, Udi. "Don't Create Aggregate Roots". udidahan.com
- Young, Greg. "Effective Aggregate Design". goodenoughsoftware.net
- Nilsson, Jimmy. "Applying Domain-Driven Design and Patterns". Addison-Wesley, 2006.