Implementando Repository Pattern com C#, Entity Framework e .NET

O repository pattern é um padrão de projeto que abstrai a camada de acesso a dados e concentra as operações de persistência em uma interface bem definida. Com ele, o código fica mais fácil de manter, os testes de unidade ficam mais simples e cada parte da aplicação cuida apenas da sua responsabilidade.

Neste artigo, você vai implementar esse padrão passo a passo com C#, Entity Framework Core e .NET. Ao final, o mesmo repositório será consumido por uma Minimal API e por um controller.

O que é o repository pattern

O repository pattern encapsula a lógica de acesso a dados e cria uma abstração entre a aplicação e o banco. Em outras palavras, o restante do código pede dados ao repositório sem saber como eles são lidos ou gravados.

Dessa forma, a lógica de persistência fica separada do domínio da aplicação. Por isso, o código se torna mais organizado, testável e fácil de evoluir.

O padrão é especialmente útil em aplicações com Entity Framework Core. Afinal, ele permite controlar leitura e escrita sem expor a infraestrutura do banco de dados para serviços e endpoints.

Vale lembrar que o próprio DbContext já combina os padrões Unit of Work e Repository, como explica a documentação da Microsoft. Mesmo assim, um repositório próprio continua útil quando você quer um contrato explícito, focado nas operações que o domínio realmente precisa.

Motivações para usar o repository pattern

O uso do repository pattern traz vários benefícios para a organização do código. As principais motivações são estas:

  • Separação de responsabilidades: a lógica de acesso a dados não fica espalhada por serviços e controllers.
  • Facilidade de testes: você cria mocks e stubs para testar regras de negócio sem um banco real.
  • Reutilização e manutenção: o código se repete menos e os ajustes futuros ficam concentrados.
  • Flexibilidade para mudar o banco: a troca de SQL Server por PostgreSQL ou MySQL afeta o repositório, e não o restante da aplicação.

Na prática, essas vantagens aparecem mais à medida que o projeto cresce. Em um CRUD muito pequeno, usar o DbContext direto também é uma escolha válida.

Criando os models

Antes de aplicar o repository pattern, você precisa definir os modelos que representam as entidades no banco. Os models são classes que correspondem às tabelas e que o EF Core usa para mapear os dados.

Com eles, você trabalha com objetos em C# em vez de escrever SQL diretamente. Assim, a manutenção e a reutilização do código ficam mais simples.

Definição do modelo

Neste exemplo, a aplicação gerencia usuários. Por isso, o único modelo é a classe User:

public class User
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

A propriedade Id será a chave primária. Já Name e Email começam com string vazia, o que evita avisos de referência nula do compilador.

Instalando o Entity Framework Core

O Entity Framework Core é o ORM (Object-Relational Mapper) da Microsoft para .NET. Ele permite interagir com o banco de dados usando classes C#, sem SQL escrito à mão na maior parte dos casos.

Antes de definir o contexto e o repository pattern, instale os pacotes necessários. Para isso, execute os comandos abaixo no terminal, dentro da pasta do projeto:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools

O primeiro pacote traz o provedor do SQL Server. Já o segundo adiciona as ferramentas de linha de comando, como as migrations.

Se você usa outro banco, como PostgreSQL ou MySQL, troque Microsoft.EntityFrameworkCore.SqlServer pelo provedor correspondente. Todo o restante do código continua igual.

Este tutorial usa o SQL Server. Caso precise de ajuda para subir o banco localmente, consulte o guia de SQL Server no Docker.

Criando o contexto do Entity Framework

O DbContext é a peça central do Entity Framework Core e funciona como uma ponte entre a aplicação e o banco de dados. Ele gerencia as operações de leitura e gravação e também oferece um ambiente transacional para manipular os dados.

Segundo a documentação oficial, cada instância de DbContext representa uma sessão com o banco. Além disso, ela foi pensada para uma única unidade de trabalho, ou seja, tem vida curta.

Criando o DbContext

O contexto da aplicação herda de DbContext e expõe a tabela de usuários:

public class AppDbContext : DbContext
{
    public DbSet<User> Users { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Define explicitamente a chave primária
        modelBuilder.Entity<User>().HasKey(u => u.Id);
    }
}

A classe AppDbContext herda de DbContext, a classe base do EF Core responsável por gerenciar a conexão com o banco. Em seguida, vamos entender cada parte.

Explicação do código

A propriedade DbSet<User> representa a tabela Users no banco. Com ela, a aplicação consulta e altera registros usando LINQ e outras APIs do EF Core.

O construtor recebe um DbContextOptions<AppDbContext>, que traz a string de conexão e o provedor de banco. Depois, o base(options) repassa essas configurações para a classe DbContext.

O método OnModelCreating permite configurar detalhes do mapeamento entre a classe e o banco. Aqui, ele define Id como chave primária com HasKey(u => u.Id).

O EF Core já infere essa chave por convenção, porque a propriedade se chama Id. Mesmo assim, deixar a configuração explícita ajuda quando surgirem ajustes adicionais.

No fim, essa classe permite que a aplicação converse com o banco sem SQL manual. Ela gerencia conexões, transações e operações de CRUD sobre as entidades mapeadas.

Criando o repositório de usuário

Agora você vai aplicar o repository pattern na entidade User. O repositório será a camada intermediária entre a aplicação e o banco, encapsulando toda a lógica de acesso e persistência.

Com o repository pattern, as operações de usuários ficam centralizadas em um único lugar. Portanto, a lógica de dados não se espalha pelo código.

Interface do repository pattern para usuários

Criar uma interface define um contrato claro para as operações do repositório. Esse contrato traz quatro vantagens:

  • Desacoplamento: quem consome o repositório conhece apenas a interface, e não a implementação.
  • Facilidade de testes: você cria mocks da interface sem precisar de banco real.
  • Flexibilidade: é possível trocar a implementação sem alterar o código consumidor.
  • Organização: um contrato bem definido facilita a manutenção e a evolução.

A interface fica assim:

public interface IUserRepository
{
    Task<User?> GetByIdAsync(int id);
    Task<User?> GetByEmailAsync(string email);
    Task<IEnumerable<User>> GetAllAsync();
    Task AddAsync(User user);
    Task UpdateAsync(User user);
    Task DeleteAsync(int id);
}

Repare que as buscas por id e por e-mail retornam User?. Afinal, o usuário pode não existir, e o tipo anulável deixa isso explícito para quem chama o método.

Implementação do repository pattern com EF Core

A classe UserRepository implementa o contrato e conversa diretamente com o banco por meio do Entity Framework Core. Assim, qualquer classe que dependa de IUserRepository usa o repositório de forma desacoplada.

O construtor recebe uma instância de AppDbContext e a guarda no campo _context:

public UserRepository(AppDbContext context)
{
    _context = context;
}

Em seguida, os métodos de leitura. O GetByIdAsync usa FindAsync, que é otimizado para buscas por chave primária e consulta primeiro as entidades já rastreadas pelo contexto:

public async Task<User?> GetByIdAsync(int id)
{
    return await _context.Users.FindAsync(id);
}

O GetByEmailAsync busca pelo e-mail com FirstOrDefaultAsync. Esse método retorna o primeiro usuário encontrado ou null quando não há correspondência:

public async Task<User?> GetByEmailAsync(string email)
{
    return await _context.Users.FirstOrDefaultAsync(u => u.Email == email);
}

Já o GetAllAsync retorna todos os usuários com ToListAsync:

public async Task<IEnumerable<User>> GetAllAsync()
{
    return await _context.Users.ToListAsync();
}

Depois, os métodos de escrita. O AddAsync adiciona o usuário ao contexto e chama SaveChangesAsync para persistir a mudança:

public async Task AddAsync(User user)
{
    await _context.Users.AddAsync(user);
    await _context.SaveChangesAsync();
}

O UpdateAsync marca o usuário como alterado e salva:

public async Task UpdateAsync(User user)
{
    _context.Users.Update(user);
    await _context.SaveChangesAsync();
}

Por fim, o DeleteAsync busca o usuário pelo id e, se encontrar, o remove:

public async Task DeleteAsync(int id)
{
    var user = await GetByIdAsync(id);
    if (user != null)
    {
        _context.Users.Remove(user);
        await _context.SaveChangesAsync();
    }
}

O Remove apenas marca a entidade como excluída. Então, o SaveChangesAsync executa de fato o comando de exclusão no banco.

Código completo do repositório

Juntando todas as partes, a implementação do repository pattern para usuários fica assim:

public class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;

    public UserRepository(AppDbContext context)
    {
        _context = context;
    }

    public async Task<User?> GetByIdAsync(int id)
    {
        return await _context.Users.FindAsync(id);
    }

    public async Task<User?> GetByEmailAsync(string email)
    {
        return await _context.Users.FirstOrDefaultAsync(u => u.Email == email);
    }

    public async Task<IEnumerable<User>> GetAllAsync()
    {
        return await _context.Users.ToListAsync();
    }

    public async Task AddAsync(User user)
    {
        await _context.Users.AddAsync(user);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateAsync(User user)
    {
        _context.Users.Update(user);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteAsync(int id)
    {
        var user = await GetByIdAsync(id);
        if (user != null)
        {
            _context.Users.Remove(user);
            await _context.SaveChangesAsync();
        }
    }
}

Em resumo, com o repository pattern, o UserRepository gerencia todas as operações da entidade User e deixa o restante da aplicação mais limpo. Além disso, os métodos assíncronos evitam bloquear threads enquanto o banco responde.

Configurando o DbContext

Antes de registrar o DbContext, a aplicação precisa ler a string de conexão do arquivo appsettings.json. Dessa forma, dados sensíveis não ficam fixos no código-fonte.

Lendo a string de conexão

No appsettings.json, defina a string de conexão:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost,1433;Database=YOUR_DATABASE;User ID=sa;Password=1q2w3e4r@#$;Trusted_Connection=False;TrustServerCertificate=True;"
  }
}

Em seguida, recupere o valor no Program.cs usando a configuração da aplicação:

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

Depois, registre o AppDbContext informando o provedor e a string de conexão:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

Com essa configuração, o EF Core sabe como se conectar ao banco que o repository pattern vai usar. Além disso, o AddDbContext registra o contexto com tempo de vida scoped, ou seja, uma instância por requisição HTTP.

Em produção, prefira guardar a senha em variáveis de ambiente, User Secrets ou um cofre de segredos. Afinal, o appsettings.json costuma ir para o controle de versão.

Registrando o repository pattern na injeção de dependência

A injeção de dependência (Dependency Injection, ou DI) é um padrão que gerencia a criação e a entrega de dependências dentro da aplicação. No .NET, ela fornece instâncias de classes para outras classes sem que você precise criá-las manualmente.

Ao registrar o repositório no container, ele passa a ser injetado automaticamente em serviços, endpoints e controllers. Isso traz três benefícios:

  • Gerenciamento do ciclo de vida: o container garante que as instâncias corretas sejam usadas.
  • Facilidade para testes: você substitui a implementação por um mock com facilidade.
  • Código desacoplado: as classes não criam suas dependências por conta própria.

Para registrar o repositório, adicione a linha abaixo no Program.cs:

builder.Services.AddScoped<IUserRepository, UserRepository>();

No repository pattern, o tempo de vida scoped não é por acaso. Segundo o guia de arquitetura da Microsoft, repositório e DbContext devem compartilhar o mesmo escopo, porque um repositório singleton com um contexto scoped causa sérios problemas de concorrência.

Se quiser revisar os tempos de vida disponíveis, o artigo sobre injeção de dependência no ASP.NET Core detalha cada um deles.

Usando o repository pattern com Minimal APIs

Com o repository pattern registrado, você pode usar o repositório diretamente em Minimal APIs. Nesse modelo, os endpoints recebem as dependências como parâmetros, sem controllers:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddScoped<IUserRepository, UserRepository>();

var app = builder.Build();

app.MapGet("/users", async (IUserRepository repository) =>
{
    return Results.Ok(await repository.GetAllAsync());
});

app.MapGet("/users/{id}", async (int id, IUserRepository repository) =>
{
    var user = await repository.GetByIdAsync(id);
    return user != null ? Results.Ok(user) : Results.NotFound();
});

app.MapPost("/users", async (User user, IUserRepository repository) =>
{
    await repository.AddAsync(user);
    return Results.Created($"/users/{user.Id}", user);
});

app.Run();

Cada endpoint declara IUserRepository como parâmetro, e o ASP.NET Core resolve a instância pelo container. Assim, nenhum endpoint conhece o AppDbContext diretamente.

As Minimal APIs oferecem uma forma mais concisa de expor endpoints HTTP. Para conhecer melhor esse modelo, veja o artigo sobre Minimal APIs no ASP.NET.

Usando o repository pattern no controller

O repository pattern também funciona em uma API baseada em controllers. Nesse caso, a dependência chega pelo construtor:

[ApiController]
[Route("api/[controller]")]
public class UserController : ControllerBase
{
    private readonly IUserRepository _userRepository;

    public UserController(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var users = await _userRepository.GetAllAsync();
        return Ok(users);
    }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetById(int id)
    {
        var user = await _userRepository.GetByIdAsync(id);
        if (user == null) return NotFound();
        return Ok(user);
    }

    [HttpPost]
    public async Task<IActionResult> Create(User user)
    {
        await _userRepository.AddAsync(user);
        return CreatedAtAction(nameof(GetById), new { id = user.Id }, user);
    }
}

Lembre-se de registrar os controllers com builder.Services.AddControllers() e mapeá-los com app.MapControllers(). Depois disso, o controller passa a responder em /api/user.

Note que o controller não tem nenhuma referência ao Entity Framework Core. Portanto, trocar a tecnologia de persistência exigiria apenas uma nova implementação de IUserRepository.

Testando código que usa o repository pattern

Uma das maiores vantagens do repository pattern aparece nos testes. Como serviços e endpoints dependem apenas da interface, você substitui o repositório real por uma implementação falsa.

Por exemplo, uma implementação em memória pode guardar usuários em uma lista. Dessa forma, o teste valida a regra de negócio sem abrir conexão com o SQL Server.

Bibliotecas como Moq ou NSubstitute geram esses dublês automaticamente a partir de IUserRepository. Se quiser revisar os conceitos, o artigo sobre fundamentos dos testes de unidade é um bom ponto de partida.

Contudo, os testes do próprio UserRepository devem usar um banco real ou um container. Afinal, o comportamento das consultas depende do provedor configurado.

Cuidados ao aplicar o repository pattern

O guia de arquitetura de microsserviços da Microsoft lembra que o DbContext já é, por si só, uma combinação de Unit of Work e Repository. Por isso, em aplicações muito simples, usar o contexto diretamente produz menos código.

Outro cuidado envolve o SaveChangesAsync dentro de cada método. Quando uma operação precisa alterar várias entidades de forma atômica, vale centralizar o salvamento em uma unidade de trabalho, em vez de salvar a cada chamada.

Também evite expor IQueryable na interface do repository pattern. Caso contrário, detalhes do EF Core vazam para as camadas superiores e o desacoplamento se perde.

Para se aprofundar, consulte o capítulo sobre a camada de persistência com Entity Framework Core na documentação oficial.

Conclusão

O repository pattern ajuda a organizar a lógica de acesso a dados, separar responsabilidades e facilitar os testes. Com C# e Entity Framework Core, você cria repositórios reutilizáveis e robustos para manipular dados com eficiência.

Neste artigo, você criou o model, o DbContext, a interface e a implementação do repositório. Em seguida, registrou tudo na injeção de dependência e consumiu o repositório em Minimal APIs e em controllers.

Com essa implementação, você tem uma base flexível e de fácil manutenção para trabalhar com banco de dados em aplicações .NET. Agora, basta aplicar o repository pattern às demais entidades do seu projeto.

FAQ

O repository pattern é obrigatório com Entity Framework Core?

Não, porque o DbContext já combina os padrões Repository e Unit of Work. Um repositório próprio vale a pena quando você quer um contrato explícito e testes mais simples.

Qual tempo de vida usar para o repositório na DI?

Use scoped, o mesmo tempo de vida padrão do DbContext. Um repositório singleton com contexto scoped causa problemas de concorrência.

Posso usar o repository pattern com PostgreSQL?

Sim, basta trocar o provedor do EF Core e o método UseSqlServer por UseNpgsql. A interface e os consumidores continuam iguais.

Devo criar um repositório genérico?

Um repositório genérico reduz repetição em CRUDs simples. No entanto, repositórios específicos expressam melhor as operações de cada entidade.

Por que usar métodos assíncronos no repositório?

Os métodos assíncronos liberam a thread enquanto o banco responde. Assim, a API atende mais requisições com os mesmos recursos.

Glossário

Termo Significado
Repository pattern Padrão que encapsula o acesso a dados atrás de uma interface focada nas operações do domínio.
ORM Ferramenta que mapeia tabelas do banco para objetos da linguagem de programação.
DbContext Classe do EF Core que representa uma sessão com o banco de dados.
DbSet Coleção do EF Core que representa uma tabela e permite consultas com LINQ.
Injeção de dependência Técnica em que um container cria e entrega as dependências de cada classe.
Scoped Tempo de vida em que o container cria uma instância por requisição.
Unit of Work Padrão que agrupa alterações e as grava no banco em uma única operação.
Mock Objeto falso usado em testes para simular o comportamento de uma dependência.

Fontes