DDD: Agregados e Aggregate Roots - Garantindo consistência no modelo de domínio (Parte 5)

DDD17 min de leitura

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

  1. Controlar acesso: Todo acesso ao agregado deve passar pela root
  2. Aplicar invariantes: Garantir que regras de negócio sejam sempre respeitadas
  3. Coordenar operações: Orquestrar mudanças entre objetos internos
  4. 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

  1. Invariantes sempre dentro do agregado: Se uma regra precisa ser aplicada, os dados necessários devem estar no mesmo agregado

  2. Transações não devem cruzar agregados: Cada agregado é uma fronteira transacional

  3. Agregados se referenciam por identidade: Use IDs, não referências diretas

  4. 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

  1. Invariantes Simples: Aplicadas dentro de uma única entity
  2. 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:

  1. Agregados definem fronteiras de consistência, não estruturas de dados
  2. Aggregate Roots são os únicos pontos de entrada para modificar o agregado
  3. Invariantes devem sempre ser aplicadas dentro dos limites do agregado
  4. Agregados se referenciam apenas por identidade, nunca por objetos diretos
  5. Um Repository por agregado, operando sempre no nível da Aggregate Root
  6. Domain Services coordenam operações entre múltiplos agregados
  7. 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.