Repository Pattern in C# With Entity Framework Core

Repository Pattern in C# With Entity Framework Core

7 min read··

clean-architecturecsharpdesign-patternsef-core

EF Core already abstracts the database, tracks identity, and coordinates writes. Adding a repository can still protect an aggregate boundary, but a generic CRUD wrapper usually hides useful query capabilities without creating a meaningful seam. The decision should follow the domain boundary, not a rule that every DbSet needs an interface.

What Is the Repository Pattern?

The Repository pattern mediates between the domain and data mapping layers. It provides a collection-like interface for accessing domain objects, hiding the details of how data is persisted or retrieved.

In simpler terms: instead of your business logic talking directly to the database, it talks to a repository. The repository handles the data access behind the scenes.

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task<List<Order>> GetByCustomerAsync(int customerId, CancellationToken cancellationToken = default);
    void Add(Order order);
    void Remove(Order order);
}

The caller doesn't know (or care) whether you're using EF Core, Dapper, or a flat file. That's the whole point.

Why Use the Repository Pattern?

There are a few practical reasons to add repositories on top of EF Core:

1. Abstraction over data access - Your domain and application layers don't depend on DbContext. If you're following Clean Architecture, this keeps the Dependency Rule intact.

2. Testability - You can mock IOrderRepository in unit tests without setting up a database. Testing use cases in Clean Architecture becomes straightforward. For the repository implementations themselves, use real databases in integration tests.

3. Encapsulation of query logic - Complex queries live inside the repository, not scattered across your application. This makes them easier to find, optimize, and reuse.

4. Consistent data access patterns - Repositories give your team a clear pattern to follow. Every developer knows where data access code lives.

Implementing a Repository With EF Core

Here's a concrete implementation of the IOrderRepository:

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _dbContext;

    public OrderRepository(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<Order?> GetByIdAsync(
        int id,
        CancellationToken cancellationToken = default)
    {
        return await _dbContext.Orders
            .Include(o => o.LineItems)
            .FirstOrDefaultAsync(o => o.Id == id, cancellationToken);
    }

    public async Task<List<Order>> GetByCustomerAsync(
        int customerId,
        CancellationToken cancellationToken = default)
    {
        return await _dbContext.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(cancellationToken);
    }

    public void Add(Order order)
    {
        _dbContext.Orders.Add(order);
    }

    public void Remove(Order order)
    {
        _dbContext.Orders.Remove(order);
    }
}

Notice that Add and Remove don't call SaveChanges. That's intentional - saving is the responsibility of the Unit of Work, not the repository.

The Unit of Work Pattern

Repositories handle individual aggregate persistence. The Unit of Work coordinates saving changes across multiple repositories in a single transaction.

EF Core's DbContext already implements the Unit of Work pattern internally. But exposing a clean interface keeps your code decoupled (I cover the full pattern in Unit of Work with EF Core):

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _dbContext;

    public UnitOfWork(AppDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        return await _dbContext.SaveChangesAsync(cancellationToken);
    }
}

Now your use case can orchestrate multiple repositories and save once:

public async Task Handle(PlaceOrderCommand command, CancellationToken cancellationToken)
{
    var customer = await _customerRepository.GetByIdAsync(command.CustomerId, cancellationToken);

    var order = Order.Create(customer, command.Items);

    _orderRepository.Add(order);

    customer.IncrementOrderCount();

    await _unitOfWork.SaveChangesAsync(cancellationToken);
}

The Generic Repository Debate

You'll find many tutorials suggesting a generic repository:

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
    void Add(T entity);
    void Update(T entity);
    void Remove(T entity);
}

I'd advise against this approach for most projects. Here's why:

It's a leaky abstraction. You end up exposing methods that don't make sense for every entity. Should you really be able to call GetAll() on a table with millions of rows?

It pushes query logic into the wrong place. Callers end up writing LINQ against IQueryable<T>, which defeats the purpose of the repository.

It mirrors DbSet. If your generic repository just wraps DbSet<T>, you haven't gained anything meaningful - you've just added a layer of indirection.

Better alternative: Write specific repository interfaces per aggregate root. Each interface exposes only the operations that make sense for that aggregate.

// ✅ Specific repository - clear intent
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id);
    Task<List<Order>> GetPendingOrdersAsync();
    void Add(Order order);
}

// ❌ Generic repository - unclear intent
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync(); // Millions of rows?
    void Add(T entity);
    void Update(T entity); // EF Core tracks changes automatically
    void Remove(T entity);
}

Should Repositories Return IQueryable?

A common shortcut is exposing IQueryable<T> from the repository:

// ❌ Avoid this
public interface IOrderRepository
{
    IQueryable<Order> Orders { get; }
}

It looks flexible, but it defeats the purpose of the pattern:

The abstraction leaks. Callers can compose any query, including ones that don't translate to SQL. The exception surfaces far from the code that caused it, at enumeration time.

Query logic scatters. The whole point was to centralize data access. With IQueryable, every handler writes its own Include, filtering, and paging logic.

You can't test the contract. An in-memory IQueryable behaves differently from the EF Core provider (case sensitivity, null handling, unsupported translations). Your mocks pass while production fails.

Return materialized results (List<T>, T?) or accept a specification object instead. If a handler needs a truly one-off query, that's a sign it should use DbContext directly - and that's fine.

When to Skip the Repository Pattern

The repository pattern isn't always necessary. Skip it when:

You're building a simple CRUD app. If your app mostly does basic create-read-update-delete operations, the repository adds overhead without meaningful benefit. Just use DbContext directly.

You're using Vertical Slice Architecture. In VSA, each slice owns its data access. Adding a repository per slice is over-engineering - your handler is the data access boundary.

You'll never swap your ORM. The "what if we switch from EF Core to Dapper" argument rarely materializes. If you're committed to EF Core, the abstraction may not justify its cost.

Your team is small and co-located. Conventions and code reviews can enforce consistency without a formal pattern.

Repository Pattern in Clean Architecture

In Clean Architecture, repository interfaces live in the Domain layer (or Application layer) and implementations live in the Infrastructure layer.

The application handler depends on the IOrderRepository interface in the Domain layer, while the OrderRepository EF Core implementation in the Infrastructure layer implements that interface
Domain/
  Entities/
    Order.cs
  Repositories/
    IOrderRepository.cs      ← Interface here

Infrastructure/
  Repositories/
    OrderRepository.cs        ← Implementation here

This keeps your domain free of EF Core dependencies while still allowing rich data access behind the scenes.

Register the repository in your DI container:

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

Or use Scrutor for automatic registration by convention.

Repository Pattern With the Specification Pattern

For complex query scenarios, combine repositories with the Specification pattern. Specifications encapsulate query criteria into reusable objects:

public class PendingOrdersSpecification : Specification<Order>
{
    public override Expression<Func<Order, bool>> ToExpression()
    {
        return order => order.Status == OrderStatus.Pending
                     && order.CreatedAt >= DateTime.UtcNow.AddDays(-30);
    }
}

The abstract Specification<T> base class only needs to declare the ToExpression method. The repository applies the specification to the query:

public async Task<List<Order>> GetAsync(
    Specification<Order> specification,
    CancellationToken cancellationToken = default)
{
    return await _dbContext.Orders
        .Where(specification.ToExpression())
        .ToListAsync(cancellationToken);
}

This avoids query logic leaking out of the repository while keeping things flexible.

Summary

The repository pattern is a useful abstraction when:

  • You're following Clean Architecture and need to enforce the Dependency Rule
  • You want to isolate query logic in a consistent, testable way
  • Your domain has complex data access requirements beyond simple CRUD

Skip it when:

  • You're building simple CRUD applications
  • You're using Vertical Slice Architecture where each handler owns its data access
  • The abstraction adds complexity without meaningful benefit

Avoid generic CRUD repositories. When a repository protects a real aggregate boundary, expose only the operations and query intent that boundary needs. EF Core already supplies a unit of work and identity map, so the repository should organize domain-facing access rather than recreate the ORM.

Frequently Asked Questions

Is the repository pattern still needed with EF Core?

It depends on your architecture. EF Core's DbContext already implements the Unit of Work and Repository patterns internally, so for simple CRUD apps a repository adds little. It earns its place when you follow Clean Architecture, need to keep EF Core out of your domain layer, or want to encapsulate complex query logic per aggregate.

Should I use a generic repository with EF Core?

Generally no. A generic repository ends up mirroring DbSet, exposes methods that make no sense for some entities (like GetAll on a huge table), and pushes query logic to callers. Specific repository interfaces per aggregate root with intention-revealing methods work better.

Should a repository call SaveChanges?

No. Repositories should only add, remove, and query entities. Committing is the Unit of Work's job, which lets a single use case modify several aggregates through different repositories and save them in one transaction.

Should repositories return IQueryable?

Avoid it. Returning IQueryable leaks the query provider to callers, spreads data access logic across the codebase, and makes it impossible to test the repository contract in isolation. Return materialized results or accept a specification instead.

Where do repository interfaces belong in Clean Architecture?

Interfaces live in the Domain layer (or Application layer), next to the aggregates they persist. Implementations live in the Infrastructure layer, where the EF Core dependency is allowed.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.