Skip to main content
SEMastery

The Repository Pattern in .NET: A Friendly, Complete Guide

Learn the Repository Pattern in .NET with simple real-life examples, EF Core code, diagrams, and honest advice on when to use it and when to skip it.

11 min readUpdated October 9, 2025

A medicine shop and the helpful chemist

Imagine your neighbourhood medicine shop. Behind the counter, there are hundreds of small boxes on tall shelves. You do not climb the shelves yourself. You just walk up to the chemist and say, "Bhaiya, I need paracetamol." The chemist knows exactly which shelf, which box, and which row to check. He brings the medicine to you.

You never touch the shelves. You never read the labels in the back. You only talk to the chemist.

The Repository Pattern works the same way. Your shelves are the database. The medicines are your data. And the chemist is the repository. Your main code (the customer) only says what it wants. The repository (the chemist) knows how to find it, save it, and bring it back.

This keeps your code clean. The customer at the counter does not need to learn how the shelves are arranged. And if the shop owner rearranges the shelves tomorrow, the customer still asks the same way. Only the chemist changes.

Let us learn this pattern step by step, write real .NET code, and also talk honestly about when you should not use it.

The problem we are trying to solve

Look at this common piece of code. The business logic reaches straight into the database.

public class OrderService
{
    private readonly AppDbContext _db;
 
    public OrderService(AppDbContext db) => _db = db;
 
    public async Task<Order?> GetExpensiveOrder(int customerId)
    {
        // Database query rules are mixed into business code
        return await _db.Orders
            .Where(o => o.CustomerId == customerId)
            .Where(o => o.Total > 1000)
            .Where(o => !o.IsDeleted)
            .OrderByDescending(o => o.CreatedAt)
            .FirstOrDefaultAsync();
    }
}

This works, but it has hidden problems:

  • The rule "ignore deleted orders" is written here. The same rule may be copy-pasted in ten other places. If you forget it once, you show deleted data.
  • The business code now depends directly on EF Core. Testing it means spinning up a database or a fake one.
  • The query details leak everywhere. There is no single home for "how we read an order".

The Repository Pattern gives those queries one home.

Without a repository, business code talks straight to the database and the rules get scattered.

Adding the repository layer

Now we put a chemist in the middle. The service talks to an interface, not to EF Core.

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<Order?> GetExpensiveOrderAsync(int customerId, CancellationToken ct = default);
    Task AddAsync(Order order, CancellationToken ct = default);
    void Remove(Order order);
}

The interface lives in your core or domain project. It says what you can do, not how. The "how" lives in the infrastructure project.

public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _db;
 
    public OrderRepository(AppDbContext db) => _db = db;
 
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct = default) =>
        await _db.Orders.FirstOrDefaultAsync(o => o.Id == id && !o.IsDeleted, ct);
 
    public async Task<Order?> GetExpensiveOrderAsync(int customerId, CancellationToken ct = default) =>
        await _db.Orders
            .Where(o => o.CustomerId == customerId && o.Total > 1000 && !o.IsDeleted)
            .OrderByDescending(o => o.CreatedAt)
            .FirstOrDefaultAsync(ct);
 
    public async Task AddAsync(Order order, CancellationToken ct = default) =>
        await _db.Orders.AddAsync(order, ct);
 
    public void Remove(Order order) => _db.Orders.Remove(order);
}

Notice that the rule !o.IsDeleted now lives in one place. If the rule changes, you change it here once. Every part of the app that reads orders gets the new rule for free.

With a repository, every service goes through one safe door to the data.

How a request flows through the layers

When a web request comes in, it travels through clear layers. The repository is the only part that knows about the database.

A read request through the layers

Controller
Service
Repository
DbContext
Database

Steps

1

Controller

Receives the HTTP request

2

Service

Runs business rules

3

Repository

Knows how to query

4

DbContext

Builds the SQL

5

Database

Returns rows

The controller never touches the database. Only the repository does.

The service does not care if the data comes from SQL Server, PostgreSQL, or even a flat file. It just asks the repository. This is the real gift of the pattern: your business code stops depending on the storage details. A request like GET /orders/{id} lands on a controller, the controller asks the service, and the service asks the repository. Each layer has one clear job.

Registering it with dependency injection

In .NET you wire the interface to the class in Program.cs. This is where the magic of swapping implementations happens.

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
 
// Tie the interface to the real class
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
 
var app = builder.Build();
app.Run();

Because the service depends on IOrderRepository and not on OrderRepository, you can later swap the real one for a fake one in tests, or for a Dapper-based one in production, without touching the service at all.

The Unit of Work: saving everything together

One repository handles one kind of thing. But a real action often changes many things at once. Placing an order may add an Order, reduce Stock, and add a LoyaltyPoint. You want all three to save together, or none at all.

This is the job of the Unit of Work. It collects all the changes and commits them in one transaction.

Here is the good news: in EF Core, the DbContext already is a Unit of Work. When you call SaveChangesAsync(), it saves every tracked change in one transaction. So a Unit of Work in EF Core is often just a thin wrapper.

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}
 
public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;
    public UnitOfWork(AppDbContext db) => _db = db;
 
    public Task<int> SaveChangesAsync(CancellationToken ct = default) =>
        _db.SaveChangesAsync(ct);
}

Your service uses repositories to make changes, then calls the Unit of Work once to commit them.

The Unit of Work commits many repository changes in one transaction.

A small example of the service tying it together:

public class PlaceOrderHandler
{
    private readonly IOrderRepository _orders;
    private readonly IStockRepository _stock;
    private readonly IUnitOfWork _uow;
 
    public PlaceOrderHandler(IOrderRepository orders, IStockRepository stock, IUnitOfWork uow)
    {
        _orders = orders;
        _stock = stock;
        _uow = uow;
    }
 
    public async Task Handle(Order order, CancellationToken ct)
    {
        await _orders.AddAsync(order, ct);
        _stock.Reduce(order.ProductId, order.Quantity);
        await _uow.SaveChangesAsync(ct);   // one transaction
    }
}

Generic repository: helpful or harmful?

Many teams write a generic repository so they do not repeat GetById and Add for every entity. It looks neat:

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
    Task AddAsync(T entity, CancellationToken ct = default);
    void Remove(T entity);
}
 
public class Repository<T> : IRepository<T> where T : class
{
    protected readonly AppDbContext Db;
    public Repository(AppDbContext db) => Db = db;
 
    public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default) =>
        await Db.Set<T>().FindAsync(new object?[] { id }, ct);
 
    public async Task AddAsync(T entity, CancellationToken ct = default) =>
        await Db.Set<T>().AddAsync(entity, ct);
 
    public void Remove(T entity) => Db.Set<T>().Remove(entity);
}

This removes copy-paste. But it has a trap. People add a GetAll() method, and soon code loads the whole table into memory just to find one row. That is slow and dangerous.

A safer middle path: keep a tiny generic base for the boring actions, then write specific repositories that expose only the smart queries each entity truly needs.

ApproachGood pointsBad points
Specific repository onlyClear, query rules in one placeMore classes to write
Generic repository onlyLess repeated codeTempts GetAll, leaks bad queries
Generic base + specificBest of both, focused methodsSlightly more thinking up front

The honest part: when NOT to use it

This is important, and many tutorials skip it. EF Core's DbContext is already a repository and a unit of work. The DbSet<T> already behaves like an in-memory collection. So putting a repository on top can simply be one extra layer that gives little back.

You may skip the repository when:

  • Your app is small and the team is comfortable with EF Core.
  • You use CQRS, where each query handler is already a focused, testable unit.
  • You rarely need to fake the database, because you test with an in-memory or test database.

You should use it when:

  • You want one safe home for tricky query rules (like soft-delete or tenant filters).
  • You follow Domain-Driven Design and load whole aggregates through a repository.
  • You may change the data source later, or you want easy fakes in unit tests.
QuestionIf yes, lean toward
Do many places repeat the same query rules?Use a repository
Is the app small with one developer?Skip it, use DbContext
Do you need rich aggregate logic (DDD)?Use a repository
Do you already test with a test database?You may skip it

Should I add a repository?

Start
Repeated rules?
Need fakes or DDD?
Decide

Steps

1

Start

You use EF Core

2

Repeated rules?

Same query in many files

3

Need fakes or DDD?

Tests or aggregates

4

Decide

Add only if it pays off

A simple decision path before you add the layer.

Testing becomes easy

One of the biggest wins is testing. Because the service depends on IOrderRepository, you can give it a fake during a test. No database needed.

public class FakeOrderRepository : IOrderRepository
{
    private readonly List<Order> _store = new();
 
    public Task<Order?> GetByIdAsync(int id, CancellationToken ct = default) =>
        Task.FromResult(_store.FirstOrDefault(o => o.Id == id));
 
    public Task<Order?> GetExpensiveOrderAsync(int customerId, CancellationToken ct = default) =>
        Task.FromResult(_store
            .Where(o => o.CustomerId == customerId && o.Total > 1000)
            .OrderByDescending(o => o.CreatedAt)
            .FirstOrDefault());
 
    public Task AddAsync(Order order, CancellationToken ct = default)
    {
        _store.Add(order);
        return Task.CompletedTask;
    }
 
    public void Remove(Order order) => _store.Remove(order);
}

Now your test runs in milliseconds and never touches a real database. This speed is a real reason teams keep the pattern.

Common mistakes to avoid

A few traps catch new developers. Keep these in mind.

  • Returning IQueryable. If your repository returns IQueryable<Order>, the caller can attach any query it wants, and your rules leak out. Return finished results like Order or List<Order> instead.
  • A giant GetAll. Loading the full table is almost never needed. Add paging or filters.
  • One repository per table, forced. You do not need a repository for every tiny table. Group by aggregate, not by table.
  • Hiding EF Core features you actually need. Things like Include, query splitting, and projections are useful. A heavy abstraction can block them. Keep the layer thin.
  • Wrapping the DbContext just because a blog said so. Always ask, "What does this layer give me?" If the answer is nothing, leave it out.

A quick look at the full picture

Putting it all together, here is how the pieces sit in a clean .NET solution. The domain defines the interfaces. The infrastructure implements them. The application uses them.

Where each piece lives in a layered .NET solution.

The arrows that matter point inward: infrastructure depends on the domain interface, not the other way around. This keeps your core business rules free from database concerns. It is the same idea as the chemist: the counter (your domain) sets the rules, and the back room (infrastructure) follows them.

References and further reading

Quick recap

  • The Repository Pattern is a layer between your business code and your database, like a chemist between you and the shelves.
  • It gives query rules one safe home, so you do not repeat or forget them.
  • The Unit of Work saves many repository changes in one transaction. In EF Core, the DbContext already does this.
  • A generic repository cuts copy-paste but can tempt bad habits like GetAll. A generic base plus specific repositories is often best.
  • It makes testing easy, because you can swap in a fake repository with no database.
  • EF Core is already a repository and unit of work, so add another layer only when it gives you real value: shared rules, DDD aggregates, or easy fakes.
  • Keep the layer thin, never return raw IQueryable, and always ask what each layer is buying you.

Related Patterns