Repositórios Genéricos: “Bala de Prata” ou Anti-Pattern?
O repositório genérico divide opiniões no ecossistema .NET, porque promete eliminar código repetido, mas também pode esconder o domínio atrás de operações que não deveriam existir. No desenvolvimento de software não existe bala de prata para os problemas que o negócio precisa resolver.
Além disso, muitas soluções populares parecem eficazes no início. Com o tempo, porém, elas geram dívida técnica e trazem mais malefícios do que benefícios.
O repositório genérico se encaixa exatamente nesse cenário. Algumas equipes o reprovam, enquanto outras o toleram e até o recomendam.
Neste artigo, você vai ver os argumentos dos dois lados, comparar implementações com e sem a abstração genérica e conhecer um modelo híbrido. Assim, a decisão passa a ser técnica, e não apenas uma questão de gosto.
O que é o Repository Pattern
Antes de discutir a versão genérica, vale lembrar a definição original. Martin Fowler descreve o repositório como um intermediário entre o domínio e o mapeamento de dados, que se comporta como uma coleção de objetos em memória.
Na prática, o repositório encapsula a lógica de acesso a dados. Dessa forma, o domínio não precisa conhecer SQL, conexões ou detalhes do ORM.
O guia de arquitetura de microsserviços da Microsoft reforça essa ideia. Segundo o material, você deve criar um repositório por agregado, e nunca um repositório por tabela do banco.
Se você quer revisar a implementação clássica do padrão, o artigo sobre como implementar o Repository Pattern com C# e Entity Framework mostra o passo a passo completo.
O que muda com o repositório genérico
O repositório genérico usa os generics do C# para concentrar as operações comuns em uma única classe. Em vez de escrever AddAsync, UpdateAsync e DeleteAsync para cada entidade, você escreve uma vez e reaproveita.
A própria documentação da Microsoft cita essa possibilidade. Ela afirma que você pode aproveitar generics para reduzir o número de classes concretas que precisa manter.
Contudo, existe um detalhe importante. O DbContext do Entity Framework Core já implementa os padrões Repository e Unit of Work, segundo a mesma documentação.
Por isso, um repositório genérico sobre o EF Core cria uma abstração em cima de outra abstração. Esse é o ponto de partida da maior parte das críticas.
Argumentos contra o repositório genérico
Quem reprova o repositório genérico costuma apresentar cinco argumentos principais. Cada um deles aponta um risco real, e não apenas uma preferência estética.
Operações que a entidade não deveria ter
O primeiro problema é atribuir operações a entidades que não deveriam tê-las. Por exemplo, um pedido faturado talvez nunca possa ser excluído fisicamente.
Mesmo assim, o repositório genérico oferece um DeleteAsync para todas as entidades. Assim, a regra de negócio fica a um método de distância de ser violada.
Violação do princípio da segregação de interfaces
O segundo argumento envolve o ISP, o Interface Segregation Principle dos princípios SOLID. Esse princípio diz que nenhum cliente deve depender de métodos que não usa.
Quando todo repositório expõe o mesmo contrato completo, os consumidores recebem métodos que nunca vão chamar. Em outras palavras, a interface fica gorda por definição.
Abstrações sobre abstrações
O terceiro ponto é o over-engineering. Como o DbContext já é um repositório com unidade de trabalho, embrulhá-lo de novo pode adicionar camadas sem ganho proporcional.
Jimmy Bogard, em comentário publicado no guia da Microsoft, resume essa visão. Ele afirma que os repositórios escondem detalhes importantes do mecanismo de persistência.
Perda de expressividade do domínio
O quarto argumento trata da linguagem do domínio. Um método FindAsync genérico diz muito menos do que um FindProductsInStockAsync.
Portanto, quando o repositório genérico vira a única porta de acesso, o código perde a linguagem ubíqua. Consequentemente, a intenção de cada consulta fica escondida em filtros espalhados pela aplicação.
Retorno de IQueryable
O quinto problema surge quando o repositório genérico retorna IQueryable para permitir consultas complexas. Nesse caso, a abstração vaza, porque o chamador continua compondo LINQ que o EF Core vai traduzir.
A documentação de testes do EF Core é explícita sobre isso. Para que o repositório seja um bom dublê de teste, ele não pode expor métodos que retornam IQueryable, e sim IEnumerable.
O artigo sobre IEnumerable vs IQueryable no EF aprofunda essa diferença e o impacto dela na tradução para SQL.
Argumentos a favor do repositório genérico
Do outro lado, quem tolera o repositório genérico também apresenta argumentos sólidos. Em geral, eles giram em torno de produtividade e governança.
O primeiro é o reuso de código. Afinal, ninguém quer duplicar a mesma lógica de inclusão para dez entidades diferentes.
O segundo é a padronização organizacional. Por exemplo, a classe base pode garantir AsNoTracking nas consultas de leitura e paginação em todas as listagens.
O terceiro é a facilidade para tratar preocupações transversais, os chamados cross-cutting concerns. Soft delete, auditoria, filtro de multi-tenant e logging ficam centralizados em um único ponto.
Vale notar, porém, que o EF Core também oferece recursos nativos para isso. Os global query filters, por exemplo, resolvem soft delete e multi-tenancy direto no modelo, como mostra o artigo sobre soft delete com ASP.NET e EF.
O quarto argumento é a manutenção. Quando uma versão nova do C# traz um recurso útil, você aplica a mudança em um único lugar e todos os repositórios se beneficiam.
Repository Pattern sem repositório genérico
Para comparar as abordagens, considere primeiro uma entidade Product com um repositório específico. O exemplo usa construtores primários, disponíveis a partir do C# 12.
public class Product
{
public Guid Id { get; private set; } = Guid.NewGuid();
public string Name { get; private set; } = null!;
public decimal Price { get; private set; }
}
public interface IProductRepository
{
Task<Product> AddAsync(
Product product,
CancellationToken cancellationToken = default);
Task UpdateAsync(
Product product,
CancellationToken cancellationToken = default);
Task DeleteAsync(
Guid id,
CancellationToken cancellationToken = default);
Task<Product?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
Task<IEnumerable<Product>> GetAllAsync(
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default);
}
public class ProductRepository(AppDbContext context)
: IProductRepository
{
public async Task<Product> AddAsync(
Product product,
CancellationToken cancellationToken = default)
{
await context.Products.AddAsync(product, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return product;
}
public async Task UpdateAsync(
Product product,
CancellationToken cancellationToken = default)
{
context.Products.Update(product);
await context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(
Guid id,
CancellationToken cancellationToken = default)
{
var product = await context.Products
.FindAsync([id], cancellationToken);
if (product is not null)
{
context.Products.Remove(product);
await context.SaveChangesAsync(cancellationToken);
}
}
public async Task<Product?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
=> await context.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
public async Task<IEnumerable<Product>> GetAllAsync(
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default)
=> await context.Products
.AsNoTracking()
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
Nessa implementação, cada entidade ganha seu próprio contrato. Assim, você controla exatamente quais operações existem e preserva a expressividade de cada método.
Por outro lado, entidades como Category, Brand, Supplier e Vendor exigiriam o mesmo código repetido. Com isso, surge a verbosidade e, com o tempo, a perda de padronização entre os repositórios.
Repository Pattern com repositório genérico
Agora considere a mesma funcionalidade com um repositório genérico. A interface recebe o tipo da entidade como parâmetro.
public interface IRepository<TEntity> where TEntity : class
{
Task<TEntity> AddAsync(
TEntity entity,
CancellationToken cancellationToken = default);
Task UpdateAsync(
TEntity entity,
CancellationToken cancellationToken = default);
Task DeleteAsync(
Guid id,
CancellationToken cancellationToken = default);
Task<TEntity?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> GetAllAsync(
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default);
}
public abstract class Repository<TEntity>(DbContext context)
: IRepository<TEntity> where TEntity : class
{
// Atalho para o DbSet da entidade
private DbSet<TEntity> Entities => context.Set<TEntity>();
public async Task<TEntity> AddAsync(
TEntity entity,
CancellationToken cancellationToken = default)
{
await Entities.AddAsync(entity, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
return entity;
}
public async Task UpdateAsync(
TEntity entity,
CancellationToken cancellationToken = default)
{
Entities.Update(entity);
await context.SaveChangesAsync(cancellationToken);
}
public async Task DeleteAsync(
Guid id,
CancellationToken cancellationToken = default)
{
var entity = await Entities.FindAsync([id], cancellationToken);
if (entity is not null)
{
Entities.Remove(entity);
await context.SaveChangesAsync(cancellationToken);
}
}
public async Task<TEntity?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default)
=> await Entities.FindAsync([id], cancellationToken);
public async Task<IEnumerable<TEntity>> GetAllAsync(
int skip = 0,
int take = 50,
CancellationToken cancellationToken = default)
=> await Entities
.AsNoTracking()
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
}
public interface IProductRepository : IRepository<Product>;
public class ProductRepository(AppDbContext context)
: Repository<Product>(context), IProductRepository;
Com essa abordagem, cada nova entidade herda a interface IRepository<TEntity> e a classe base. Dessa forma, você reaproveita o código padronizado e elimina a verbosidade.
No entanto, o preço aparece logo. Todo repositório passa a expor todas as operações, inclusive aquelas que o domínio proíbe.
Modelo híbrido com repositório genérico
Depois de analisar os dois lados, surge uma pergunta natural. Por que não combinar o melhor de cada abordagem?
A proposta do modelo híbrido é simples. O repositório genérico continua como classe base de infraestrutura, mas o domínio só enxerga interfaces específicas com os métodos permitidos.
public interface IProductRepository
{
Task<Product> AddAsync(
Product product,
CancellationToken cancellationToken = default);
Task<Product?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
}
public class ProductRepository(AppDbContext context)
: Repository<Product>(context), IProductRepository;
public class Category
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = null!;
}
public interface ICategoryRepository
{
Task<Category> AddAsync(
Category category,
CancellationToken cancellationToken = default);
Task UpdateAsync(
Category category,
CancellationToken cancellationToken = default);
}
public class CategoryRepository(AppDbContext context)
: Repository<Category>(context), ICategoryRepository;
Repare que IProductRepository não herda de IRepository<Product>. Assim, quem depende dessa interface só consegue adicionar e consultar produtos, e nunca excluir.
O truque está na forma como o C# resolve interfaces. A classe ProductRepository herda de Repository<Product> métodos públicos com a mesma assinatura, então o compilador os usa para satisfazer o contrato específico.
Métodos expressivos no repositório genérico híbrido
O modelo híbrido também resolve a perda de expressividade. Isso acontece porque a classe concreta pode somar métodos próprios do domínio aos métodos herdados.
Para isso, a classe base precisa liberar o acesso ao DbSet para as classes filhas. Basta trocar o modificador da propriedade Entities de private para protected.
public abstract class Repository<TEntity>(DbContext context)
: IRepository<TEntity> where TEntity : class
{
// Agora as classes filhas também enxergam o DbSet
protected DbSet<TEntity> Entities => context.Set<TEntity>();
// Demais métodos continuam iguais
}
public interface IProductRepository
{
Task<Product> AddAsync(
Product product,
CancellationToken cancellationToken = default);
Task<Product?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken = default);
Task<IEnumerable<Product>> FindProductsInStockAsync(
CancellationToken cancellationToken = default);
}
public class ProductRepository(AppDbContext context)
: Repository<Product>(context), IProductRepository
{
public async Task<IEnumerable<Product>> FindProductsInStockAsync(
CancellationToken cancellationToken = default)
=> await Entities
.AsNoTracking()
.Where(p => p.Stock > 0)
.ToListAsync(cancellationToken);
}
Nesse exemplo, a entidade Product ganhou uma propriedade Stock para ilustrar a consulta. Dessa forma, AddAsync e GetByIdAsync vêm prontos da base, enquanto FindProductsInStockAsync expressa uma regra do negócio.
Com isso, o contrato fala a língua do domínio. Quem lê IProductRepository entende o que o sistema faz com produtos sem abrir nenhuma implementação.
Além disso, a consulta específica retorna IEnumerable<Product>, e não IQueryable. Assim, o filtro fica encapsulado no repositório e o método continua fácil de simular em testes.
Esse equilíbrio é justamente o objetivo do modelo híbrido. O código repetitivo mora na classe genérica, e o código que carrega significado mora na classe específica.
Cuidados ao aplicar o modelo híbrido
O ponto mais importante é a assinatura. Os métodos expostos no domínio precisam ter o mesmo nome, os mesmos parâmetros e o mesmo tipo de retorno do método existente no repositório genérico.
Por exemplo, se a interface declarar Task AddAsync enquanto a base retorna Task<Product>, o compilador vai acusar que o contrato não foi implementado. Nesse caso, você precisa ajustar a assinatura ou escrever o método na classe concreta.
Além disso, registre no container apenas as interfaces específicas. Se a injeção de dependência expuser IRepository<Product>, a proteção do domínio desaparece.
O artigo sobre injeção de dependência no ASP.NET Core explica como registrar serviços com o tempo de vida correto.
// Apenas os contratos do domínio chegam ao container
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<ICategoryRepository, CategoryRepository>();
Por fim, lembre que a classe concreta ainda tem todos os métodos públicos da base. O modelo híbrido protege quem depende da abstração, mas não impede um desenvolvedor de instanciar a classe concreta diretamente.
Repositório genérico e testes
A testabilidade é um argumento frequente a favor de repositórios. A documentação do EF Core recomenda o padrão quando você precisa de dublês de teste confiáveis para a camada de dados.
Segundo a Microsoft, mockar DbSet para consultas deve ser evitado, porque os operadores LINQ são métodos de extensão estáticos. Da mesma forma, o provedor in-memory é desencorajado para testes.
Nesse sentido, interfaces pequenas como IProductRepository ficam muito fáceis de simular. Um mock só precisa implementar os dois métodos que o caso de uso realmente usa.
Entretanto, a mesma documentação faz um alerta. Os testes contra o banco real continuam necessários, porque as consultas do repositório também precisam de validação.
Se você quer revisar a base desse assunto, o artigo sobre fundamentos dos testes de unidade é um bom ponto de partida.
Quando usar o repositório genérico
A resposta honesta é: depende do contexto. O repositório genérico não é uma bala de prata, e também não é um anti-pattern por definição.
Para decidir, avalie pelo menos quatro fatores. São eles a maturidade do time, a complexidade do domínio, a governança da organização e o nível de autonomia das equipes.
Em um domínio simples, quase todo CRUD, o ganho de produtividade costuma compensar. Já em um domínio rico, com agregados e invariantes, as interfaces específicas tendem a valer mais.
Em times grandes, a padronização da classe base reduz divergências entre projetos. Por outro lado, times pequenos e experientes podem preferir usar o DbContext diretamente.
Além disso, considere se você realmente precisa da camada. A própria Microsoft afirma que repositórios não são obrigatórios em DDD nem no desenvolvimento .NET em geral.
Na prática, algumas perguntas ajudam a tomar a decisão. Seu domínio tem entidades que nunca podem ser excluídas ou alteradas livremente?
Se a resposta for sim, prefira interfaces específicas, com ou sem a classe base genérica. Caso contrário, o contrato completo talvez não cause dano algum.
Também pergunte quantas entidades compartilham exatamente o mesmo comportamento de persistência. Quanto maior esse número, maior o retorno do reuso.
Por fim, verifique se os recursos nativos do EF Core já resolvem suas preocupações transversais. Se resolverem, o repositório genérico perde parte da sua justificativa.
Próximos passos
O modelo híbrido resolve a exposição do domínio, mas não resolve tudo. Consultas complexas ainda exigem filtros, ordenação, projeção para DTO e atenção à performance.
Uma evolução natural é evitar a exportação de IQueryable com técnicas mais avançadas. O padrão Specification, por exemplo, encapsula critérios de consulta em objetos reutilizáveis.
Outra alternativa é separar leitura e escrita. O artigo sobre CQRS e Mediator no ASP.NET mostra como essa divisão funciona na prática.
Conclusão
O repositório genérico entrega reuso, padronização e um ponto único para preocupações transversais. Ao mesmo tempo, ele pode violar o ISP, expor operações indevidas e apagar a linguagem do domínio.
Por isso, o modelo híbrido aparece como uma alternativa equilibrada. Ele reaproveita a implementação genérica na infraestrutura e expõe ao domínio apenas os métodos que fazem sentido.
No fim, nenhuma abordagem é universal. Entenda o contexto do seu time e do seu domínio antes de adotar ou descartar o repositório genérico.
FAQ
O repositório genérico é um anti-pattern?
Não por definição. Ele vira um problema quando expõe operações indevidas ao domínio ou retorna IQueryable, mas pode ser útil como classe base de infraestrutura.
Preciso de repositório se uso EF Core?
Não obrigatoriamente. O DbContext já implementa os padrões Repository e Unit of Work, então a camada extra só se justifica por testes, encapsulamento ou padronização.
Por que não retornar IQueryable no repositório?
Porque o chamador continua compondo consultas que o EF Core precisa traduzir. Isso vaza a abstração e impede que o repositório seja simulado com facilidade em testes.
Como o modelo híbrido esconde métodos do domínio?
A interface específica declara apenas os métodos permitidos. A classe concreta herda a implementação genérica, mas o consumidor só enxerga o contrato reduzido.
O que acontece se a assinatura não for igual à da base?
O compilador acusa que a interface não foi implementada. Nesse caso, ajuste a assinatura ou implemente o método diretamente na classe concreta.