ArquiteturaIntermediário8 min de leitura7 seções2003, Data aproximada

Repository

Uma coleção de objetos de domínio que finge não ter banco atrás. O repositório expõe operações na linguagem do negócio — 'pedidos pendentes há mais de uma hora' — e é o único lugar que sabe como isso vira consulta.

Em 10 segundos

O domínio declara uma interface na linguagem dele — `pendentesHaMaisDe(1h)` — e a infraestrutura implementa. Nenhum tipo do ORM atravessa essa fronteira. O ganho não é 'trocar de banco um dia': é a regra de negócio ter um lugar só e o teste rodar sem container.

Antes de ter nome

A ideia de uma coleção em memória que esconde a persistência já aparecia no mapeamento objeto-relacional dos anos 1990, antes de ganhar o nome de Repository.

  • #ddd
  • #persistencia
  • #fronteira
  • #dominio
  • #teste
Analogia

O bibliotecário

Você pede 'os livros de ficção científica publicados antes de 1970'. Não pede a estante 4, prateleira B. O bibliotecário sabe onde as coisas estão e devolve livros — não coordenadas de prateleira. Se a biblioteca for reorganizada amanhã, o seu pedido continua igual. E se ele te entregasse um mapa da estante em vez do livro, você teria ganhado um intermediário sem ganhar independência nenhuma.

01Conceito

A regra que mora na consulta

Toda consulta com filtro carrega uma decisão de negócio. status IN ('AGUARDANDO','EM_ANALISE') AND criado_em < agora - 1h não é técnica: é a definição de 'pedido pendente' que alguém tomou.

Quando essa condição é escrita no serviço, ela é copiada para o próximo serviço que precisa da mesma resposta — e um dia três cópias são atualizadas e a quarta não.

O repositório dá um nome a essa decisão. pendentesHaMaisDe(1, agora) é um lugar só onde a definição vive, e a próxima pessoa que precisar da mesma lista não vai reinventar o filtro — vai encontrar o método. Isso é mais valioso, no dia a dia, do que a independência de banco que costuma ser usada para justificar o padrão.

A segunda consequência é o teste. Se o serviço depende de uma interface, o teste injeta uma implementação em memória e roda em milissegundos, sem container, sem migração, sem estado sujo entre casos. É a mesma inversão do DIP e a mesma fronteira que a Arquitetura Hexagonal desenha — Repository é uma porta, no vocabulário dela.

Vale ser honesto sobre o argumento de trocar de banco: quase ninguém troca. Ele aparece em toda justificativa e raramente se realiza. Os benefícios que de fato se realizam são o nome único para a regra, o teste rápido, e a capacidade de mudar o *como* (adicionar cache, trocar a consulta por uma view materializada, particionar) sem tocar em quem chama.

Como se organiza
  • Domínionão conhece banco, ORM nem SQL

    • Pedidoobjeto com as regras

    • PedidosRepositorio (interface)fala a linguagem do negócio — onde a fronteira é declarada

  • Infraestruturaa única camada que sabe que existe banco

    • PedidosNoPostgrestraduz linha ↔ objeto de domínio

    • PedidosEmMemoriamesma interface, para teste

A seta de dependência aponta da infraestrutura para o domínio, nunca o contrário. É por isso que o teste pode trocar a implementação sem que o domínio perceba.
02Conceito

Um repositório por agregado, não por tabela

O erro mais comum de granularidade é criar um repositório para cada tabela. Isso reproduz o esquema do banco dentro do domínio — exatamente o que o padrão existia para evitar.

O escopo certo é o agregado: a unidade que é salva e carregada inteira, com suas invariantes.

Um Pedido com seus ItemDePedido é um agregado: não faz sentido carregar um item solto e alterá-lo sem passar pelo pedido, porque a regra 'o total do pedido é a soma dos itens' só pode ser garantida com os dois juntos. Então existe PedidosRepositorio, e não existe ItensDePedidoRepositorio.

A pergunta prática que resolve quase todos os casos: quem é dono da invariante? Se salvar A sozinho pode deixar B inconsistente, A e B são um agregado só, e há um repositório para o conjunto.

Há uma exceção legítima e frequente: consultas de leitura para tela. Uma lista com filtros, ordenação e paginação, juntando cinco tabelas, não quer agregados — quer uma projeção achatada. Forçar isso no repositório de domínio produz um método com dez parâmetros e um objeto que não é o do domínio. O caminho honesto ali é uma consulta de leitura separada, que é exatamente o que CQRS propõe.

03No mundo real

Como isso aparece na prática

A definição de 'cliente ativo' que divergiu

Cenário

Três serviços precisavam listar clientes ativos. Cada um escreveu o próprio filtro. Quando a regra mudou para excluir clientes em inadimplência, dois foram atualizados e o de cobrança não — e passou a cobrar quem já tinha sido excluído.

Como o padrão entra

ClientesRepositorio.ativos() passa a ser o único lugar que define 'ativo'. Mudar a regra é mudar um método.

Trade-off: Nem toda variação cabe num método só, e a tentação seguinte é adicionar parâmetros até ele virar um construtor de consulta. Manter poucos métodos com nomes de negócio exige disciplina contínua.

A suíte que dependia de container

Cenário

Os testes de regra de negócio subiam Postgres em Docker, aplicavam migrações e limpavam tabelas entre casos. A suíte levava sete minutos e falhava de forma intermitente quando dois casos disputavam a mesma linha.

Como o padrão entra

Com a regra dependendo da interface, os testes de domínio passaram a usar uma implementação em memória. Só os testes do próprio repositório continuam tocando o banco.

Trade-off: A implementação em memória pode divergir da real — ela não valida constraint, não tem transação de verdade e ordena diferente. Isso exige um conjunto de testes de contrato rodando contra as duas.

O repositório que só repassa o ORM

Compila, roda, passa no code review. E quebra depois.

A classe tem o nome do padrão, mas os métodos aceitam e devolvem os tipos do ORM — e há um get raw() para quando isso não bastar. Ganhou-se uma camada de indireção sem ganhar nenhuma fronteira.

// "Repositorio" que so repassa o ORM: nao esconde nada.
class PedidoRepository {
  constructor(private db: PrismaClient) {}

  // Devolve o tipo do ORM. Quem chama fica acoplado ao Prisma.
  findMany(where: Prisma.PedidoWhereInput) {
    return this.db.pedido.findMany({ where });
  }

  // E aqui a fronteira ja se dissolveu de vez:
  get raw() { return this.db.pedido; }
}

// O servico monta a consulta — ou seja, a regra de "quais pedidos
// contam como pendentes" mora na aplicacao, espalhada:
const pendentes = await repo.findMany({
  status: { in: ["AGUARDANDO", "EM_ANALISE"] },
  criadoEm: { lt: new Date(Date.now() - 3600_000) },
});

// A mesma condicao reaparece em outros quatro lugares,
// e um dia tres deles sao atualizados e o quarto nao.

O que quebra

  • Ao trocar o ORMComo os tipos dele estão nas assinaturas públicas, a migração toca todos os chamadores — exatamente o que o repositório prometia evitar.
  • No testeSimular o repositório exige construir objetos do ORM, o que é quase tão trabalhoso quanto usar o banco de verdade.
  • Ao mudar a regraA condição de negócio continua espalhada por quem monta o where, então a mudança precisa ser caçada em todos os pontos de chamada.

A saída: A fronteira é a assinatura, não o nome da classe. Método com nome de negócio, parâmetros primitivos ou de domínio, retorno de domínio — e nenhum escape hatch para o objeto cru. Se traduzir tudo parecer caro demais, talvez o caso seja CRUD e o padrão não se pague ali.

05Cuidado

Onde isto dá errado

  1. Um repositório por tabela

    Reproduzir o esquema do banco dentro do domínio devolve ao código exatamente o acoplamento que o padrão existia para remover. O escopo é o agregado — quem é dono da invariante —, não a tabela.

  2. Método genérico de consulta

    Um buscar(criterio) que aceita qualquer condição transforma o repositório num construtor de consultas com outro nome. A regra volta a morar em quem chama, e a fronteira existe só no diagrama.

  3. Vazar o tipo do ORM

    Entrada, saída e erro precisam ser do domínio. Um método que devolve a entidade do ORM, ou que deixa escapar a exceção do driver, mantém o acoplamento intacto sob uma camada a mais.

  4. Implementação em memória que mente

    A versão de teste não valida constraint, não tem transação e ordena diferente do banco. Sem um conjunto de testes de contrato rodando contra as duas implementações, a suíte fica verde enquanto a produção quebra.

  5. Forçar relatório no repositório de domínio

    Listagem com filtros, ordenação e junção de cinco tabelas quer projeção, não agregado. Espremer isso no repositório produz métodos com dez parâmetros e objetos que não são os do domínio.

  6. N+1 escondido pela fronteira

    A boa notícia do repositório é que ele esconde o acesso; a má é a mesma. Um laço que chama porId mil vezes parece código de domínio limpo e é uma consulta por iteração. A fronteira precisa vir acompanhada de observabilidade.

06Implementação

Interface no domínio, implementação na infra

// A interface mora no DOMINIO e fala a linguagem dele.
// Nada de Prisma, SQL, tabela ou coluna aqui.
export interface PedidosRepositorio {
  porId(id: PedidoId): Promise<Pedido | null>;
  pendentesHaMaisDe(horas: number, agora: Date): Promise<Pedido[]>;
  salvar(pedido: Pedido): Promise<void>;
}

// A implementacao mora na INFRA e e a unica que conhece o banco.
export class PedidosNoPostgres implements PedidosRepositorio {
  constructor(private db: PrismaClient) {}

  async pendentesHaMaisDe(horas: number, agora: Date): Promise<Pedido[]> {
    const linhas = await this.db.pedido.findMany({
      where: {
        status: { in: ["AGUARDANDO", "EM_ANALISE"] },
        criadoEm: { lt: new Date(agora.getTime() - horas * 3600_000) },
      },
    });
    // traduz linha -> objeto de dominio; o ORM para aqui
    return linhas.map(paraDominio);
  }

  async salvar(pedido: Pedido): Promise<void> {
    const dados = paraLinha(pedido);
    await this.db.pedido.upsert({
      where: { id: dados.id }, create: dados, update: dados,
    });
  }

  async porId(id: PedidoId) {
    const l = await this.db.pedido.findUnique({ where: { id: String(id) } });
    return l ? paraDominio(l) : null;
  }
}

// No teste, uma implementacao em memoria — sem banco, sem container.
export class PedidosEmMemoria implements PedidosRepositorio { /* ... */ }
07Decisão

Quando usar × quando evitar

Use quando

3
  • Quando há modelo de domínio com regras próprias, não só CRUD.
  • Quando a mesma pergunta de negócio aparece em vários lugares.
  • Quando testar a regra sem subir banco é objetivo real.

Evite quando

3
  • Em CRUD honesto, onde o repositório vira repasse método a método.
  • Para relatórios e listagens — ali cabe consulta de leitura separada.
  • Quando a equipe já aceita o ORM como parte do domínio.

Testar o que ficou

6 armadilhas de Repository viram perguntas, com o nome do padrão escondido, misturadas com padrões parecidos.

Onde isto aparece de verdade

Você já usa isto, talvez sem saber o nome.

  • JpaRepository do Spring DataVocê declara a interface e o framework gera a implementação — o padrão virou infraestrutura de framework.
  • Repository do TypeORMA classe tem o nome do padrão e o papel dele: mediar entre o objeto e a tabela.
  • O Manager do DjangoPedido.objects é o ponto único de consulta, e Manager customizado é onde os filtros de negócio se concentram.

O padrão em uma linha

Sem nome de domínio, sem cerimônia. Só a ideia.

// Metodo com nome de negocio, retorno de dominio.
const pendentes = await pedidos.pendentesHaMaisDe(1, agora);

O que ele cobra

1 salto a mais entre a chamada e o efeito.

  • Uma interface e uma implementação por agregado
  • Consultas de tela não cabem bem, e forçá-las distorce a interface
  • O acesso ao banco fica escondido — um N+1 parece código limpo

Não vale se: é CRUD honesto. O repositório vira repasse método a método, sem esconder nada.

Costuma ser confundido com

Onde isto aparece

Continue por aqui