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.
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.
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.
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
Steps
Controller
Receives the HTTP request
Service
Runs business rules
Repository
Knows how to query
DbContext
Builds the SQL
Database
Returns rows
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.
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.
| Approach | Good points | Bad points |
|---|---|---|
| Specific repository only | Clear, query rules in one place | More classes to write |
| Generic repository only | Less repeated code | Tempts GetAll, leaks bad queries |
| Generic base + specific | Best of both, focused methods | Slightly 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.
| Question | If 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?
Steps
Start
You use EF Core
Repeated rules?
Same query in many files
Need fakes or DDD?
Tests or aggregates
Decide
Add only if it pays off
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 returnsIQueryable<Order>, the caller can attach any query it wants, and your rules leak out. Return finished results likeOrderorList<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.
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
- Implementing the infrastructure persistence layer with EF Core — Microsoft Learn
- Implementing the Repository and Unit of Work Patterns — Microsoft Learn
- Is the repository pattern useful with EF Core? — The Reformed Programmer
- Repository Pattern vs Direct EF Core: The Great Debate — DEV Community
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
DbContextalready 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
The Outbox Pattern in .NET: Never Lose a Message Again
Learn the Outbox Pattern in .NET with simple, real-life examples. Save your data and your messages in one transaction so a broker outage can never lose an event. Includes EF Core code, diagrams, and best practices.
What Is a Modular Monolith? A Beginner-Friendly Guide for .NET
Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Implementing the Unit of Work Pattern in EF Core
Learn the Unit of Work pattern in EF Core with simple C# examples, diagrams, transactions, and when DbContext alone is already enough for you.
Specification Pattern in EF Core: Flexible Data Access Without Repositories
Learn the Specification pattern in EF Core to build reusable, testable, composable queries without piling up repository methods or hiding IQueryable.
CQRS Pattern with MediatR in .NET: A Friendly Guide
Learn the CQRS pattern with MediatR in .NET using simple words, clear diagrams, and real C# code. Beginner friendly, with pitfalls and licensing notes.