EF Core em Clean Architecture: a abordagem pragmática
Usar EF Core em Clean Architecture costuma começar com uma regra quase dogmática: esconder o DbContext atrás de uma interface de repositório. Neste artigo, você vê por que essa convenção acumula custos e como uma abordagem pragmática mantém os mesmos princípios com muito menos cerimônia.
O EF Core é o ORM mais usado no ecossistema .NET. Ao mesmo tempo, a Clean Architecture é um dos estilos arquiteturais mais adotados em projetos ASP.NET Core.
Por anos, a combinação das duas seguiu a mesma receita. A camada de Application nunca podia depender do ORM, então cada agregado ganhava um repositório próprio.
Essa receita é coerente na teoria. Na prática, porém, ela cobra um preço que cresce junto com o projeto.
Clean Architecture: princípios e camadas
A Clean Architecture organiza a aplicação em quatro camadas concêntricas, cada uma com uma responsabilidade bem definida.
A camada Domain concentra as entidades, os objetos de valor e as regras de negócio centrais. Ela não depende de nenhum framework externo.
A camada Application contém os casos de uso, os commands, as queries e os handlers. Para executar suas operações, ela depende apenas do Domain.
A camada Infrastructure implementa os detalhes externos, como banco de dados, cache, mensageria, identidade e integrações com terceiros.
A camada Presentation é o ponto de entrada da aplicação. Pode ser uma Web API, um serviço gRPC, um app Blazor, um projeto MVC ou qualquer outra interface.
A regra de dependência
A regra fundamental é a Dependency Rule: as dependências sempre apontam para dentro. Assim, o Domain não conhece nenhuma outra camada.
Por sua vez, Application depende apenas do Domain. Já Infrastructure e Presentation dependem de Application e Domain.
A documentação de arquiteturas web da Microsoft descreve esse mesmo modelo. Nela, o núcleo da aplicação define abstrações e a infraestrutura fornece as implementações.
A grande pergunta é onde o EF Core se encaixa nesse desenho. A resposta tradicional diz que ele fica restrito à Infrastructure.
EF Core em Clean Architecture com repositórios
Na configuração clássica de EF Core em Clean Architecture, a camada de Application define contratos de repositório. Em seguida, a Infrastructure implementa essas interfaces.
public interface IShipmentRepository
{
Task<Shipment?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
Task<bool> ExistsForOrderAsync(string orderId, CancellationToken cancellationToken);
Task AddAsync(Shipment shipment, CancellationToken cancellationToken);
}
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
A implementação na Infrastructure usa o EF Core internamente.
internal sealed class ShipmentRepository(ShipmentsDbContext context) : IShipmentRepository
{
public Task<Shipment?> GetByIdAsync(Guid id, CancellationToken cancellationToken)
=> context.Shipments
.Include(x => x.Items)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
public Task<bool> ExistsForOrderAsync(string orderId, CancellationToken cancellationToken)
=> context.Shipments.AnyAsync(x => x.OrderId == orderId, cancellationToken);
public async Task AddAsync(Shipment shipment, CancellationToken cancellationToken)
=> await context.Shipments.AddAsync(shipment, cancellationToken);
}
Os handlers recebem as abstrações por injeção de dependência e nunca tocam no DbContext.
internal sealed class CreateShipmentHandler(
IShipmentRepository repository,
IUnitOfWork unitOfWork,
ILogger<CreateShipmentHandler> logger)
{
public async Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentCommand command,
CancellationToken cancellationToken)
{
var exists = await repository.ExistsForOrderAsync(command.OrderId, cancellationToken);
if (exists)
return ShipmentErrors.AlreadyExists(command.OrderId);
var shipment = command.MapToShipment();
await repository.AddAsync(shipment, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
return shipment.MapToResponse();
}
}
À primeira vista, tudo parece correto. A Dependency Rule fica preservada, e os testes podem trocar a implementação real por um mock.
Se você quer revisar como esse padrão funciona em detalhes, o artigo sobre Repository Pattern com C# e Entity Framework cobre a implementação clássica.
Os problemas dos repositórios sobre EF Core em Clean Architecture
À medida que o projeto evolui, essa abordagem de EF Core em Clean Architecture começa a mostrar problemas recorrentes.
Abstração sobre abstração
A própria documentação da classe DbContext afirma que ela é uma combinação dos padrões Unit of Work e Repository. O DbSet faz o papel do repositório, enquanto o SaveChanges fecha a unidade de trabalho.
Portanto, criar um IShipmentRepository sobre o DbContext significa envolver uma abstração existente com outra camada. Em muitos casos, essa nova camada só repassa chamadas.
Explosão de métodos e includes duplicados
O que começa com três métodos logo vira vinte ou mais. Cada nova funcionalidade adiciona um método ao repositório, e depois de alguns meses ninguém sabe o que já existe.
public interface IShipmentRepository
{
Task<Shipment?> GetByIdAsync(...);
Task<List<Shipment>> GetByOrderIdAsync(...);
Task<List<Shipment>> GetByCarrierAsync(...);
Task<List<Shipment>> GetActiveAsync(...);
Task<List<Shipment>> GetByStatusAsync(...);
Task<List<Shipment>> GetCreatedBetweenAsync(...);
Task<List<Shipment>> GetWithItemsAsync(...);
// muitos outros...
}
Os includes agravam o quadro. Alguns callers precisam de Include(x => x.Items) e outros não, então surgem métodos como GetByIdWithItemsAsync, GetByIdSlimAsync e GetByIdForReportAsync.
A alternativa costuma ser pior. Nesse caso, todas as chamadas carregam dados desnecessários só para reaproveitar um único método.
Queries entre agregados e entrega mais lenta
Quando uma funcionalidade precisa de dados de vários agregados, não existe resposta clara sobre em qual repositório o método deve morar. Surgem então interfaces híbridas ou lógica duplicada.
Além disso, cada caso de uso novo toca três arquivos: a interface, a implementação na Infrastructure e o handler. Para uma simples query nova, isso representa muita cerimônia.
Testes com falsa segurança
Mockar o IShipmentRepository leva pouco tempo. No entanto, esse teste não verifica a tradução de LINQ para SQL, não confirma se os Include funcionam e não valida as constraints de unicidade do banco.
Em outras palavras, o mock aprova exatamente os pontos que costumam falhar em produção.
EF Core em Clean Architecture com DbContext nos casos de uso
A abordagem pragmática de EF Core em Clean Architecture preserva os princípios essenciais da arquitetura. Contudo, ela elimina a camada de repositório onde essa camada não agrega valor real.
A mudança central é simples. A camada de Application passa a depender do pacote Microsoft.EntityFrameworkCore e do DbContext da aplicação.
O Domain continua livre de frameworks. Já a Infrastructure mantém a configuração do DbContext, as migrations, os interceptors e as integrações externas.
O mesmo CreateShipmentHandler fica assim nessa abordagem:
internal sealed class CreateShipmentHandler(
ShipmentsDbContext context,
ILogger<CreateShipmentHandler> logger)
{
public async Task<Result<ShipmentResponse>> HandleAsync(
CreateShipmentCommand command,
CancellationToken cancellationToken)
{
var exists = await context.Shipments
.AnyAsync(x => x.OrderId == command.OrderId, cancellationToken);
if (exists)
{
logger.LogInformation("Shipment for order '{OrderId}' already exists", command.OrderId);
return ShipmentErrors.AlreadyExists(command.OrderId);
}
var shipment = command.MapToShipment();
await context.Shipments.AddAsync(shipment, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created shipment: {@Shipment}", shipment);
return shipment.MapToResponse();
}
}
A quantidade de código no caso de uso permanece a mesma. Por outro lado, a interface do repositório e sua implementação deixam de existir.
Onde fica a configuração do DbContext
Mesmo com o DbContext na Application, a configuração continua na Infrastructure. Em outras palavras, o EF Core em Clean Architecture separa quem usa o contexto de quem decide como ele se conecta ao banco.
public static class InfrastructureServiceCollectionExtensions
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("Shipments")
?? throw new InvalidOperationException("Connection string 'Shipments' not found.");
// registra o contexto como scoped, um por requisição
services.AddDbContext<ShipmentsDbContext>(options =>
options.UseNpgsql(connectionString));
return services;
}
}
O AddDbContext registra o contexto com tempo de vida scoped. Assim, segundo a documentação do EF Core, cada requisição HTTP recebe uma instância própria, que funciona como uma unidade de trabalho.
Por isso, o handler não precisa de um IUnitOfWork separado. O SaveChangesAsync do próprio contexto fecha a operação de forma atômica.
As configurações de mapeamento, as migrations e os interceptors também ficam na Infrastructure. Dessa forma, o EF Core em Clean Architecture mantém os detalhes de persistência fora dos casos de uso.
Handlers de query mais diretos
Um handler de query passa pela mesma simplificação:
internal sealed class GetShipmentByNumberHandler(ShipmentsDbContext context)
{
public async Task<Result<ShipmentResponse>> HandleAsync(
string shipmentNumber,
CancellationToken cancellationToken)
{
var shipment = await context.Shipments
.Include(x => x.Items)
.FirstOrDefaultAsync(x => x.Number == shipmentNumber, cancellationToken);
if (shipment is null)
return ShipmentErrors.NotFound(shipmentNumber);
return shipment.MapToResponse();
}
}
Agora o acesso a dados fica visível ao lado da lógica do caso de uso. Com isso, o código fica mais fácil de ler, depurar e modificar.
Cada handler também decide o formato exato da consulta. Assim, ele pode usar AsNoTracking, projeções com Select ou includes específicos, sem criar um método novo em uma interface compartilhada.
O que a Microsoft diz sobre repositórios
O guia de arquitetura de microsserviços da Microsoft tem uma seção chamada "Repositories shouldn't be mandatory". Segundo o texto, repositórios podem ser úteis, mas não são essenciais em um design DDD.
O guia também cita Jimmy Bogard, que prefere usar todo o poder da camada de persistência e manter o comportamento de domínio nos agregados. Ele ainda lembra que continua precisando de testes de integração com o banco real.
Na mesma linha, a documentação admite usar o DbContext diretamente quando você busca o código mais simples possível.
EF Core em Clean Architecture e a troca de banco
Esse é o argumento mais comum a favor do repositório no EF Core em Clean Architecture, e ele merece uma resposta direta.
Na prática, a grande maioria dos projetos nunca troca de banco em produção. Além disso, quando a troca acontece entre bancos relacionais, como de SQL Server para PostgreSQL, o próprio EF Core já faz o papel de abstração por meio dos providers.
Migrar de um banco relacional para um banco de documentos, como MongoDB ou Cosmos DB, exige muito mais. O modelo de dados muda, as regras de consistência mudam e os padrões de consulta também mudam.
Nesse cenário, os casos de uso precisam de reescrita de qualquer forma, com ou sem repositório. Por isso, um repositório criado só para essa hipótese raramente paga o próprio custo.
Testando EF Core em Clean Architecture sem repositórios
Remover repositórios não prejudica a testabilidade do EF Core em Clean Architecture. Na verdade, essa decisão muda a forma de testar, e a mudança tende a ser positiva.
A estratégia de testes recomendada pela Microsoft começa justamente por boa cobertura contra o banco de produção. Segundo a documentação, esses testes são necessários em qualquer caso, então vale começar por eles.
Testes de integração com Testcontainers
Com Testcontainers, o handler roda contra um PostgreSQL ou SQL Server real dentro de um container Docker. Dessa forma, os testes exercitam a query real, o schema real e os índices reais.
[Fact]
public async Task CreateShipment_AlreadyExists_ReturnsConflict()
{
await using var factory = new ShipmentsApiFactory();
using var scope = factory.Services.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<ShipmentsDbContext>();
var handler = scope.ServiceProvider.GetRequiredService<CreateShipmentHandler>();
// prepara um envio já existente para o mesmo pedido
context.Shipments.Add(ShipmentFactory.New("ORDER-1"));
await context.SaveChangesAsync();
var result = await handler.HandleAsync(
new CreateShipmentCommand(OrderId: "ORDER-1"),
CancellationToken.None);
result.IsError.Should().BeTrue();
result.Errors.Should().ContainSingle(e => e.Type == ErrorType.Conflict);
}
Esses testes são mais lentos que testes unitários com mocks. Em troca, eles entregam confiança real sobre o que vai rodar em produção.
E o provider InMemory?
O provider InMemory do EF Core parece uma saída rápida para testar a lógica do handler. Contudo, a documentação oficial desencoraja fortemente esse uso.
Segundo a Microsoft, o InMemory não suporta transações nem SQL puro e se comporta de forma diferente do banco real em vários pontos. Se você precisa de um banco falso, a recomendação é preferir o SQLite em memória.
A documentação vai além. Quando um test double é indispensável, ela recomenda justamente o padrão de repositório, porque ele permite mockar a camada de dados com confiabilidade.
Ou seja, a escolha entre DbContext direto e repositório também depende da sua estratégia de testes. Se a equipe aposta em integração com banco real, o DbContext direto encaixa melhor.
Reuso de queries no EF Core em Clean Architecture
No EF Core em Clean Architecture, queries complexas usadas em vários lugares não precisam de um repositório para evitar duplicação.
A primeira alternativa são extension methods sobre IQueryable<T>, que encapsulam filtros e includes reutilizáveis de forma componível.
public static class ShipmentQueryableExtensions
{
public static IQueryable<Shipment> WithItems(this IQueryable<Shipment> query)
=> query.Include(x => x.Items);
public static IQueryable<Shipment> ActiveOnly(this IQueryable<Shipment> query)
=> query.Where(x => x.Status != ShipmentStatus.Cancelled);
}
// Em um handler:
var shipment = await context.Shipments
.WithItems()
.ActiveOnly()
.FirstOrDefaultAsync(x => x.Number == number, ct);
Como as extensões trabalham sobre IQueryable<T>, o EF Core traduz a composição inteira para um único SQL. Se a diferença entre IQueryable e IEnumerable ainda gera dúvida, o artigo sobre IEnumerable vs IQueryable no EF explica o impacto na tradução.
A segunda alternativa é o Specification Pattern, adequado para queries dinâmicas e combináveis. Cada especificação é uma pequena classe que descreve um filtro e uma ordenação.
Os casos de uso combinam essas especificações conforme a necessidade. Assim, as duas alternativas entregam reuso sem acoplar todos os handlers a uma interface monolítica.
Quando o EF Core em Clean Architecture ainda pede repositórios
A abordagem pragmática de EF Core em Clean Architecture não elimina repositórios em todos os cenários. Em algumas situações, eles continuam sendo a escolha correta.
Queries muito complexas e amplamente reutilizadas. Quando uma query percorre vários agregados, envolve joins pesados e aparece em dezenas de features, centralizar essa lógica reduz duplicação.
Convenções do time. Em organizações com diretrizes que exigem repositórios para manter a consistência entre equipes, seguir a convenção pode ser a decisão pragmática.
Preocupações transversais de infraestrutura. Se você precisa decorar o acesso a dados com cache, logging ou auditoria, e os interceptors do EF Core não atendem, um wrapper pode ser a solução mais simples.
Múltiplas fontes de dados. Quando o projeto combina EF Core com uma API externa e um banco legado, um repositório funciona como fachada para unificar essas fontes.
Uso de Dapper. Com Dapper, o repositório continua essencial para isolar o SQL do restante da aplicação.
O artigo sobre Dapper com Unit of Work e Repository Pattern mostra esse cenário.
Testes com dublês. Como vimos, a Microsoft recomenda repositórios quando a estratégia de testes depende de mocks da camada de dados.
Conclusão
A abordagem pragmática de EF Core em Clean Architecture preserva o que realmente importa. O Domain permanece livre de frameworks, os limites entre camadas continuam claros e as regras de negócio ficam protegidas.
Por outro lado, ela elimina a cerimônia desnecessária. Saem as interfaces que duplicam abstrações do EF Core, os arquivos que crescem a cada feature e os testes que criam uma falsa sensação de segurança.
Com o DbContext direto na Application, o EF Core em Clean Architecture tende a gerar menos código, entregas mais rápidas e testes mais confiáveis.
Enfim, a regra de dependência continua valendo onde ela de fato protege o sistema. A diferença é que ela deixa de virar ritual onde não traz benefício concreto.
FAQ
Usar o DbContext na camada de Application viola a Clean Architecture?
Não viola o espírito da Clean Architecture, porque a Dependency Rule existe para proteger o Domain. O EF Core na Application é a escolha do ORM do projeto, e trocá-lo exigiria reescrita com ou sem repositórios.
Como evitar queries duplicadas sem um repositório?
Use extension methods sobre IQueryable<T> ou o Specification Pattern. Os dois são componíveis e reutilizáveis sem interfaces monolíticas.
Testes de integração com Testcontainers não são lentos demais?
Eles são mais lentos que testes com mocks, mas validam a tradução para SQL, os includes e as constraints. A Microsoft recomenda começar por testes contra o banco real.
Ainda posso usar repositórios nessa abordagem?
Sim. A abordagem pragmática não proíbe repositórios, apenas os torna opcionais e os aplica onde geram valor mensurável.
Posso usar o provider InMemory para testar os handlers?
A Microsoft desencoraja esse uso, porque o InMemory não suporta transações nem SQL e se comporta diferente do banco real. Se precisar de um banco falso, prefira o SQLite em memória.
Essa abordagem funciona bem com CQRS?
Sim. As queries usam o DbContext com projeções e joins otimizados, e os commands seguem o mesmo padrão.
A recomendação vale também para Dapper?
Não. Com Dapper, os repositórios continuam recomendados, porque o SQL precisa ficar isolado do restante da aplicação.