DDD: Repositories - Abstraindo o acesso aos dados (Parte 7)

DDD11 min de leitura

Este é o sétimo artigo da série sobre Domain-Driven Design. Nos artigos anteriores, exploramos conceitos fundamentais, Value Objects, Entities, Agregados e Services. Agora chegou o momento de entender um padrão essencial para manter a pureza do modelo de domínio: Repository Pattern.

O Repository é um dos padrões mais importantes - e ao mesmo tempo mais mal compreendidos - do DDD. Ele não é apenas uma abstração sobre um banco de dados; é a ponte entre o modelo de domínio e a camada de persistência.

O que é o Padrão Repository?

O Repository Pattern fornece uma abstração sobre a camada de acesso a dados, encapsulando a lógica necessária para acessar fontes de dados. Martin Fowler o define assim:

"Um Repository encapsula o conjunto de objetos persistidos em um armazenamento de dados e as operações realizadas sobre eles, fornecendo uma visão mais orientada a objeto da camada de persistência."

Eric Evans, no contexto do DDD, vai além:

"Um Repository representa todos os objetos de um certo tipo como uma coleção conceitual (geralmente emulada). Ele age como uma coleção em memória de todos os objetos desse tipo."

A Diferença entre Repository e DAO

É crucial entender que Repository Pattern não é a mesma coisa que Data Access Object (DAO). Embora ambos lidem com persistência, suas responsabilidades são bem diferentes:

DAO (Data Access Object):

  • Foca em operações CRUD básicas
  • Geralmente mapeia 1:1 com tabelas do banco
  • Expõe detalhes da persistência
  • Trabalha com registros/linhas

Repository:

  • Foca em coleções de objetos de domínio
  • Trabalha com Aggregate Roots completos
  • Esconde detalhes da persistência
  • Trabalha com objetos de domínio ricos

Por que Usar Repository no DDD?

O Repository no DDD tem objetivos específicos que vão além da simples abstração de dados:

1. Preserve a Ignorância da Persistência

O modelo de domínio deve ser Persistence Ignorant - ele não deve saber como é persistido. O Repository mantém essa separação:

// ❌ Modelo de domínio ciente da persistência
class Usuario
{
    private $pdo;
    
    public function salvar(): void
    {
        $sql = "UPDATE usuarios SET nome = ?, email = ? WHERE id = ?";
        $this->pdo->prepare($sql)->execute([$this->nome, $this->email, $this->id]);
    }
}

// ✅ Modelo de domínio puro
class Usuario
{
    public function alterarNome(string $novoNome): void
    {
        $this->validarNome($novoNome);
        $this->nome = $novoNome;
        // Domínio puro - sem SQL ou persistência
    }
}

2. Trabalhe com Aggregate Roots

Repositories sempre operam no nível de Aggregate Roots. Eles carregam e salvam agregados completos, respeitando suas fronteiras de consistência:

interface PedidoRepository
{
    public function buscarPorId(PedidoId $id): ?Pedido;
    public function salvar(Pedido $pedido): void;
    public function remover(PedidoId $id): void;
    public function buscarPedidosPendentes(): array;
}

3. Forneça uma Interface Rica de Consulta

Repositories devem expressar consultas na linguagem do domínio, não em termos técnicos:

interface ClienteRepository
{
    // ❌ Interface técnica
    public function findByStatus(string $status): array;
    public function findByDateRange(DateTime $start, DateTime $end): array;
    
    // ✅ Interface de domínio
    public function buscarClientesAtivos(): array;
    public function buscarClientesComPedidosRecentes(): array;
    public function buscarClientesElegiveisParaDesconto(): array;
}

Implementando Repository Pattern

Definindo a Interface

A interface do Repository deve ser definida na camada de domínio e expressar conceitos do negócio:

interface ContaBancariaRepository
{
    public function proximoId(): ContaId;
    public function buscarPorId(ContaId $id): ?ContaBancaria;
    public function buscarPorCpf(Cpf $cpf): ?ContaBancaria;
    public function buscarContasAtivas(): array;
    public function buscarContasComSaldoNegativo(): array;
    public function salvar(ContaBancaria $conta): void;
    public function remover(ContaId $id): void;
}

Implementação com Abstração de Dados

A implementação concreta fica na camada de infraestrutura:

class PDOContaBancariaRepository implements ContaBancariaRepository
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly ContaBancariaMapper $mapper
    ) {}
    
    public function proximoId(): ContaId
    {
        return ContaId::gerar();
    }
    
    public function buscarPorId(ContaId $id): ?ContaBancaria
    {
        $sql = "
            SELECT c.*, t.* 
            FROM contas_bancarias c 
            LEFT JOIN transacoes t ON c.id = t.conta_id 
            WHERE c.id = ? AND c.ativa = 1
        ";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$id->valor]);
        
        $dados = $stmt->fetchAll(PDO::FETCH_ASSOC);
        
        if (empty($dados)) {
            return null;
        }
        
        return $this->mapper->arrayParaContaBancaria($dados);
    }
    
    public function buscarPorCpf(Cpf $cpf): ?ContaBancaria
    {
        $sql = "
            SELECT c.*, t.* 
            FROM contas_bancarias c 
            LEFT JOIN transacoes t ON c.id = t.conta_id 
            WHERE c.cpf_titular = ? AND c.ativa = 1
        ";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$cpf->valor]);
        
        $dados = $stmt->fetchAll(PDO::FETCH_ASSOC);
        
        if (empty($dados)) {
            return null;
        }
        
        return $this->mapper->arrayParaContaBancaria($dados);
    }
    
    public function buscarContasAtivas(): array
    {
        $sql = "
            SELECT c.*, t.* 
            FROM contas_bancarias c 
            LEFT JOIN transacoes t ON c.id = t.conta_id 
            WHERE c.ativa = 1 
            ORDER BY c.created_at DESC
        ";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute();
        
        return $this->mapper->criarContasDeResultados($stmt->fetchAll(PDO::FETCH_ASSOC));
    }
    
    public function buscarContasComSaldoNegativo(): array
    {
        $sql = "
            SELECT c.*, t.* 
            FROM contas_bancarias c 
            LEFT JOIN transacoes t ON c.id = t.conta_id 
            WHERE c.saldo_atual < 0 AND c.ativa = 1
        ";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute();
        
        return $this->mapper->criarContasDeResultados($stmt->fetchAll(PDO::FETCH_ASSOC));
    }
    
    public function salvar(ContaBancaria $conta): void
    {
        $this->pdo->beginTransaction();
        
        try {
            $this->salvarConta($conta);
            $this->salvarTransacoes($conta);
            
            $this->pdo->commit();
        } catch (Exception $e) {
            $this->pdo->rollback();
            throw new RepositoryException("Erro ao salvar conta bancária", 0, $e);
        }
    }
    
    public function remover(ContaId $id): void
    {
        // Soft delete - marcamos como inativa
        $sql = "UPDATE contas_bancarias SET ativa = 0 WHERE id = ?";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$id->valor]);
    }
    
    private function salvarConta(ContaBancaria $conta): void
    {
        if ($this->contaExiste($conta->getId())) {
            $this->atualizarConta($conta);
        } else {
            $this->inserirConta($conta);
        }
    }
    
    private function salvarTransacoes(ContaBancaria $conta): void
    {
        // Aqui salvamos apenas transações novas
        // Em uma implementação real, você poderia usar eventos de domínio
        // ou outro mecanismo para identificar mudanças
        $transacoesNovas = $conta->getTransacoesNaoSalvas();
        
        foreach ($transacoesNovas as $transacao) {
            $this->inserirTransacao($transacao);
        }
    }
    
    // ... outros métodos privados para operações SQL específicas
}

Usando Specification Pattern

Para consultas complexas, podemos combinar Repository com o Specification Pattern:

interface Specification
{
    public function satisfeita($objeto): bool;
    public function toSqlWhere(): string;
    public function getParametros(): array;
}

class ClienteComMaisDeXPedidosSpecification implements Specification
{
    public function __construct(private readonly int $minimoPedidos) {}
    
    public function satisfeita($cliente): bool
    {
        return $cliente->getQuantidadePedidos() >= $this->minimoPedidos;
    }
    
    public function toSqlWhere(): string
    {
        return "
            EXISTS (
                SELECT COUNT(*) 
                FROM pedidos p 
                WHERE p.cliente_id = c.id 
                HAVING COUNT(*) >= ?
            )
        ";
    }
    
    public function getParametros(): array
    {
        return [$this->minimoPedidos];
    }
}

// No Repository
public function buscarClientesPorSpecification(Specification $spec): array
{
    $sql = "SELECT * FROM clientes c WHERE " . $spec->toSqlWhere();
    $stmt = $this->pdo->prepare($sql);
    $stmt->execute($spec->getParametros());
    
    return $this->mapper->criarClientesDeResultados($stmt->fetchAll());
}

Repository vs ORM

Uma questão comum é a relação entre Repository Pattern e ORMs como Eloquent, Hibernate ou Doctrine.

Repository com ORM

ORMs podem ser usados dentro da implementação do Repository, mas não devem vazar para o domínio:

class DoctrineUsuarioRepository implements UsuarioRepository
{
    public function __construct(private readonly EntityManager $em) {}
    
    public function buscarPorId(UsuarioId $id): ?Usuario
    {
        return $this->em->find(Usuario::class, $id->valor);
    }
    
    public function buscarUsuariosAtivos(): array
    {
        return $this->em->createQueryBuilder()
            ->select('u')
            ->from(Usuario::class, 'u')
            ->where('u.ativo = :ativo')
            ->setParameter('ativo', true)
            ->getQuery()
            ->getResult();
    }
    
    public function salvar(Usuario $usuario): void
    {
        $this->em->persist($usuario);
        // Note: não chamamos flush aqui - isso é responsabilidade da camada de aplicação
    }
}

Repository Sem ORM

Também é perfeitamente válido implementar Repository sem ORM:

class PDOUsuarioRepository implements UsuarioRepository
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly UsuarioMapper $mapper
    ) {}
    
    public function buscarPorId(UsuarioId $id): ?Usuario
    {
        $sql = "SELECT * FROM usuarios WHERE id = ? AND ativo = 1";
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$id->valor]);
        
        $dados = $stmt->fetch(PDO::FETCH_ASSOC);
        
        return $dados ? $this->mapper->arrayParaUsuario($dados) : null;
    }
    
    public function salvar(Usuario $usuario): void
    {
        $dados = $this->mapper->usuarioParaArray($usuario);
        
        if ($this->usuarioExiste($usuario->getId())) {
            $this->atualizar($dados);
        } else {
            $this->inserir($dados);
        }
    }
}

Repository e Aggregate Roots

Um dos princípios fundamentais é que cada Aggregate Root deve ter seu próprio Repository:

// ✅ Correto - Repository por Aggregate Root
interface PedidoRepository 
{
    public function buscarPorId(PedidoId $id): ?Pedido;
    public function salvar(Pedido $pedido): void;
}

interface ClienteRepository 
{
    public function buscarPorId(ClienteId $id): ?Cliente;
    public function salvar(Cliente $cliente): void;
}

// ❌ Incorreto - Repository para entidades filhas
interface ItemPedidoRepository  // ItemPedido é parte do agregado Pedido
{
    public function buscarPorId(ItemId $id): ?ItemPedido;
}

Carregando Agregados Completos

O Repository deve sempre carregar o agregado completo, incluindo suas entidades filhas:

class PDOPedidoRepository implements PedidoRepository
{
    public function buscarPorId(PedidoId $id): ?Pedido
    {
        // Carrega pedido + itens + pagamentos em uma única operação
        $sql = "
            SELECT 
                p.*,
                i.id as item_id, i.produto_id, i.quantidade, i.preco_unitario,
                pg.id as pagamento_id, pg.tipo, pg.valor, pg.status
            FROM pedidos p
            LEFT JOIN itens_pedido i ON p.id = i.pedido_id
            LEFT JOIN pagamentos pg ON p.id = pg.pedido_id
            WHERE p.id = ?
        ";
        
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute([$id->valor]);
        
        $dados = $stmt->fetchAll(PDO::FETCH_ASSOC);
        
        return empty($dados) ? null : $this->mapper->arrayParaPedido($dados);
    }
}

Testabilidade com Repository

Repository Pattern facilita muito os testes, permitindo implementações in-memory:

class InMemoryUsuarioRepository implements UsuarioRepository
{
    private array $usuarios = [];
    
    public function buscarPorId(UsuarioId $id): ?Usuario
    {
        return $this->usuarios[$id->valor] ?? null;
    }
    
    public function buscarPorEmail(Email $email): ?Usuario
    {
        foreach ($this->usuarios as $usuario) {
            if ($usuario->getEmail()->valor === $email->valor) {
                return $usuario;
            }
        }
        return null;
    }
    
    public function salvar(Usuario $usuario): void
    {
        $this->usuarios[$usuario->getId()->valor] = $usuario;
    }
    
    public function remover(UsuarioId $id): void
    {
        unset($this->usuarios[$id->valor]);
    }
}

// No teste
class CriarUsuarioServiceTest extends TestCase
{
    public function testCriarUsuarioComEmailUnico(): void
    {
        // Arrange
        $repository = new InMemoryUsuarioRepository();
        $service = new CriarUsuarioService($repository);
        
        // Act
        $usuario = $service->criar('João Silva', 'joao@email.com');
        
        // Assert
        $this->assertNotNull($repository->buscarPorId($usuario->getId()));
        $this->assertEquals('João Silva', $usuario->getNome()->valor);
    }
}

Padrões e Anti-Padrões

✅ Boas Práticas

  1. Uma interface por Aggregate Root
interface PedidoRepository 
{
    public function buscarPorId(PedidoId $id): ?Pedido;
    public function salvar(Pedido $pedido): void;
}
  1. Métodos expressivos da linguagem de domínio
public function buscarPedidosPendentesDeAprovacao(): array;
public function buscarClientesComRiscoDeCredito(): array;
  1. Repository retorna objetos de domínio completos
public function buscarPorId(PedidoId $id): ?Pedido
{
    // Carrega pedido completo com itens, pagamentos, etc.
}

❌ Anti-Padrões

  1. Repository genérico
// ❌ Muito genérico
interface Repository<T>
{
    public function find(int $id): ?T;
    public function save(T $entity): void;
}
  1. Vazamento de detalhes de persistência
// ❌ Expõe SQL/ORM para o domínio
public function buscarPorQuery(string $sql): array;
public function buscarComJoin(array $joins): array;
  1. Repository para entidades não-root
// ❌ ItemPedido faz parte do agregado Pedido
interface ItemPedidoRepository 
{
    public function salvar(ItemPedido $item): void;
}

Repository em Arquiteturas Modernas

CQRS e Repository

Em arquiteturas CQRS, Repository geralmente é usado apenas no lado Command:

// Command Side - usa Repository
class ProcessarPedidoCommandHandler
{
    public function __construct(
        private readonly PedidoRepository $pedidoRepository,
        private readonly ClienteRepository $clienteRepository
    ) {}
    
    public function handle(ProcessarPedidoCommand $command): void
    {
        $cliente = $this->clienteRepository->buscarPorId($command->clienteId);
        $pedido = Pedido::criar($cliente, $command->itens);
        
        $this->pedidoRepository->salvar($pedido);
    }
}

// Query Side - acesso direto aos dados otimizado
class ListarPedidosQueryHandler
{
    public function __construct(private readonly PDO $pdo) {}
    
    public function handle(ListarPedidosQuery $query): array
    {
        // Query otimizada específica para leitura
        $sql = "
            SELECT p.id, p.numero, c.nome as cliente_nome, p.valor_total, p.status
            FROM pedidos p
            JOIN clientes c ON p.cliente_id = c.id
            WHERE p.cliente_id = ?
            ORDER BY p.created_at DESC
        ";
        
        return $this->pdo->prepare($sql)->execute([$query->clienteId])->fetchAll();
    }
}

Event Sourcing e Repository

Com Event Sourcing, Repository trabalha com streams de eventos:

interface EventSourcedRepository
{
    public function carregarEventos(AggregateId $id): EventStream;
    public function salvarEventos(AggregateId $id, EventStream $eventos): void;
}

class EventSourcedPedidoRepository implements EventSourcedRepository
{
    public function buscarPorId(PedidoId $id): ?Pedido
    {
        $eventos = $this->carregarEventos($id);
        
        if ($eventos->isEmpty()) {
            return null;
        }
        
        return Pedido::reconstituirDeEventos($eventos);
    }
    
    public function salvar(Pedido $pedido): void
    {
        $eventosNovos = $pedido->getEventosNaoComitados();
        $this->salvarEventos($pedido->getId(), $eventosNovos);
        $pedido->marcarEventosComoComitados();
    }
}

Conclusão

O Repository Pattern no DDD é muito mais que uma abstração sobre banco de dados. É o guardião da integridade do modelo de domínio, mantendo-o puro e focado na lógica de negócio.

Pontos-chave para lembrar:

  1. Repository trabalha com Aggregate Roots, não com tabelas
  2. Interface definida no domínio, implementação na infraestrutura
  3. Expressa conceitos de negócio nas operações disponíveis
  4. Mantém o domínio persistence-ignorant
  5. Um Repository por Aggregate Root
  6. Carrega e salva agregados completos
  7. Facilita testes com implementações in-memory

Quando bem implementado, Repository permite que seu modelo de domínio seja rico, expressivo e completamente independente de detalhes de persistência. Ele é a ponte que permite que seu código de negócio permaneça limpo enquanto ainda oferece persistência robusta e flexível.

No próximo artigo da série, exploraremos Domain Events, entendendo como comunicar mudanças entre diferentes partes do sistema de forma desacoplada.


Anterior

DDD: Domain Services vs Application Services - Organizando a lógica de negócio (Parte 6)

Próximo

DDD: Domain Events - Comunicação entre contextos (Parte 8)


Referências