Repository Pattern & Unit of Work
Reviewed & published by Brayan K
By the end of this lesson you'll be able to hide your data-access details behind a clean repository, write a reusable generic IRepository<T>, coordinate several repositories with a Unit of Work, and swap a real database for a fake one so your business logic is trivially testable.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Explain what the Repository pattern is for and what problem it solves
- Design and implement a generic IRepository<T> interface
- Build an in-memory repository backed by a List<T>
- Coordinate multiple repositories transactionally with a Unit of Work
- Keep the pattern separate from EF Core so storage can change freely
- Swap a fake repository in for fast, database-free unit tests
💡 Real-World Analogy
A repository is like a librarian who hides where the books are stored. You walk up and say "I'd like the book with this title" — you don't need to know whether it's on the third floor, in the basement archive, or out on loan from another branch. The librarian (the repository) knows the storage; you just know what you want. Tomorrow the library could rip out every shelf and move to a robot warehouse, and your request would be exactly the same. A Unit of Work is the front desk that checks out several books at once: either the whole stack gets stamped through together, or — if your library card is declined — none of them leave. That "all or nothing" is a transaction.
Why a Repository at all?
The Repository pattern puts a thin layer between your business logic and your data store. Instead of sprinkling context.Customers.Where(...) all over your services, you call customerRepo.FindAsync(...). The repository's job is to make a collection of objects feel like a simple in-memory list — Add, GetById, GetAll — while hiding whether that "list" is really a database, a web API, or a JSON file.
Two payoffs make it worth the extra type:
- 🔌 Decoupling — your services depend on an interface, so you can change the storage technology without touching them.
- 🧪 Testability — a test can hand the service a fake repository and run in milliseconds with no database.
It is not free, and it is genuinely debated (you'll see why in Common Errors). But understood properly — as a boundary, not a thin wrapper — it's one of the most useful patterns in a layered C# application.
📊 Pattern Reference
| Piece | What it is | Real-world parallel |
|---|---|---|
| IRepository<T> | The contract: Add / GetById / GetAll | The library's request desk |
| InMemoryRepository<T> | A List-backed implementation | A small private bookshelf |
| EfRepository<T> | A DbSet-backed implementation | The full robot warehouse |
| IUnitOfWork | Groups repos + one SaveChanges | The front desk checking out a stack |
| Fake repository | A test double for the interface | A pretend desk for a fire drill |
The key idea running down this table: callers depend on the contract (top row) and never on a specific implementation below it.
1. A Generic IRepository<T>
A generic repository works for any entity type — Book, Customer, Order — without you rewriting Add and GetById each time. The <T> is a type placeholder; the where T : IEntity constraint guarantees every T has an Id, which is the one thing the repository needs to look items up. Read this worked example — notice the List<T> storage is private, so callers go through the interface and never touch the shelf.
using System;
using System.Collections.Generic;
using System.Linq;
// Every entity this repository stores must expose an Id, so the
// repository can look items up by it. This is the ONLY thing the
// generic repository needs to know about T.
public interface IEntity
{
int Id { get; set; }
}
// The contract: what a repository can DO, with no hint of HOW it does it.
// A service depends on THIS interface, never on a concrete class, so you
// can swap the storage (in-memory now, a database later) without changing
// a single line of the calling code.
public interface IRepository<T> where T : IEntity
{
T? GetById(int id);
IEnumerable<T> GetAll();
void Add(T entity);
}
// One concrete implementation, backed by a plain in-memory List<T>.
// Notice the storage detail (the List) is PRIVATE — callers never see it.
public class InMemoryRepository<T> : IRepository<T> where T : IEntity
{
private readonly List<T> _items = new(); // the hidden "shelf"
private int _nextId = 1; // hands out the next Id
public void Add(T entity)
{
entity.Id = _nextId++; // assign an Id, then store it
_items.Add(entity);
}
// Find one by Id — returns null if nothing matches (the '?' says so).
public T? GetById(int id) => _items.FirstOrDefault(e => e.Id == id);
public IEnumerable<T> GetAll() => _items;
}
public class Book : IEntity
{
public int Id { get; set; }
public string Title { get; set; } = "";
}
class Program
{
static void Main()
{
// The caller asks for a Book by criteria — it never touches the List.
IRepository<Book> books = new InMemoryRepository<Book>();
books.Add(new Book { Title = "Clean Code" }); // gets Id 1
books.Add(new Book { Title = "The Pragmatic Programmer" }); // Id 2
var found = books.GetById(2);
Console.WriteLine($"Found: {found?.Title}"); // Found: The Pragmatic Programmer
Console.WriteLine($"Total books: {books.GetAll().Count()}"); // Total books: 2
// Swap storage later (e.g. a DB) and Main stays EXACTLY the same.
}
}
// ✅ Expected output:
// Found: The Pragmatic Programmer
// Total books: 2Your turn. The repository below is missing its two key lines. Fill in the two ___ blanks so Add stores the entity and GetById finds it, then run it.
using System;
using System.Collections.Generic;
using System.Linq;
public interface IEntity { int Id { get; set; } }
public interface IRepository<T> where T : IEntity
{
void Add(T entity);
T? GetById(int id);
}
// 🎯 YOUR TURN — implement the two methods of this in-memory repository.
public class InMemoryRepository<T> : IRepository<T> where T : IEntity
{
private readonly List<T> _items = new();
private int _nextId = 1;
// 1) Add: give the entity the next Id, then store it in the list.
public void Add(T entity)
{
entity.Id = _nextId++; // (done for you)
___; // 👉 add 'entity' to the list: _items.Add(entity)
}
// 2) GetById: return the first item whose Id matches, or null if none.
public T? GetById(int id)
{
return _items.___(e => e.Id == id); // 👉 use FirstOrDefault
}
}
public class Customer : IEntity
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
class Program
{
static void Main()
{
IRepository<Customer> repo = new InMemoryRepository<Customer>();
repo.Add(new Customer { Name = "Alice" }); // gets Id 1
repo.Add(new Customer { Name = "Bob" }); // gets Id 2
var c = repo.GetById(2);
Console.WriteLine($"Id 2 is {c?.Name}");
// ✅ Expected output:
// Id 2 is Bob
}
}2. Using the Repository
Once the implementation exists, using it is the easy part — you call methods on the interface and forget about storage entirely. That's the whole point: the calling code reads like plain list operations. Here the repository is already built for you; you just drive it. Fill in the three ___ blanks to add two products, fetch one by Id, and count them.
using System;
using System.Collections.Generic;
using System.Linq;
public interface IEntity { int Id { get; set; } }
public interface IRepository<T> where T : IEntity
{
void Add(T entity);
T? GetById(int id);
IEnumerable<T> GetAll();
}
public class InMemoryRepository<T> : IRepository<T> where T : IEntity
{
private readonly List<T> _items = new();
private int _nextId = 1;
public void Add(T entity) { entity.Id = _nextId++; _items.Add(entity); }
public T? GetById(int id) => _items.FirstOrDefault(e => e.Id == id);
public IEnumerable<T> GetAll() => _items;
}
public class Product : IEntity
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
class Program
{
static void Main()
{
// 🎯 YOUR TURN — use the repository; the implementation is already done.
IRepository<Product> products = new InMemoryRepository<Product>();
// 1) Add a Product called "Pen" priced 1.50m, then one called "Notebook" at 3.00m.
products.Add(new Product { Name = "Pen", Price = 1.50m });
products.___(new Product { Name = "Notebook", Price = 3.00m }); // 👉 method name: Add
// 2) Fetch the product with Id 2 and print its name.
var p = products.___(2); // 👉 fetch by id: GetById
Console.WriteLine($"Product 2: {p?.Name}");
// 3) Print how many products there are in total.
Console.WriteLine($"Count: {products.GetAll().___()}"); // 👉 LINQ count: Count
// ✅ Expected output:
// Product 2: Notebook
// Count: 2
}
}3. Keeping It Separate from EF Core
The in-memory repository was a teaching prop; a real app stores data in a database. The beautiful part is that the contract doesn't change — only the implementation does. Below, EfRepository<T> implements the same IRepository<T> using EF Core's DbSet<T>. Everything EF-specific — DbContext, ToListAsync, FindAsync — is sealed inside the class. The crucial detail: FindAsync returns IEnumerable<T>, not IQueryable<T>, so the query executes here and EF never leaks to the layers above.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
// The SAME contract as before. The service layer depends on this and
// has no idea whether the data lives in a List or in SQL Server.
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task AddAsync(T entity);
void Remove(T entity);
}
// An EF Core implementation. Everything EF-specific (DbContext, DbSet,
// ToListAsync, FindAsync) is locked INSIDE this class. Upper layers never
// see EF Core at all — that is the boundary the pattern buys you.
public class EfRepository<T> : IRepository<T> where T : class
{
protected readonly DbContext _context;
protected readonly DbSet<T> _dbSet;
public EfRepository(DbContext context)
{
_context = context;
_dbSet = context.Set<T>(); // EF's per-type table handle
}
public async Task<T?> GetByIdAsync(int id) =>
await _dbSet.FindAsync(id); // SELECT ... WHERE Id = id
public async Task<IEnumerable<T>> GetAllAsync() =>
await _dbSet.ToListAsync(); // materialise to a List
// Return IEnumerable<T>, NOT IQueryable<T> — the query runs HERE and the
// caller receives finished data, so EF never leaks past this boundary.
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) =>
await _dbSet.Where(predicate).ToListAsync();
public async Task AddAsync(T entity) => await _dbSet.AddAsync(entity);
public void Remove(T entity) => _dbSet.Remove(entity);
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string City { get; set; } = "";
}
class Program
{
static async Task Main()
{
// In a real app the DbContext is injected; shown inline here.
using var context = new AppDbContext();
IRepository<Customer> customers = new EfRepository<Customer>(context);
await customers.AddAsync(new Customer { Name = "Alice", City = "Leeds" });
await customers.AddAsync(new Customer { Name = "Bob", City = "Leeds" });
await context.SaveChangesAsync(); // one round-trip writes both rows
var leeds = await customers.FindAsync(c => c.City == "Leeds");
Console.WriteLine($"Customers in Leeds: {leeds.Count()}");
// ✅ Expected output:
// Customers in Leeds: 2
}
}Because your service was written against IRepository<T>, you can switch from InMemoryRepository to EfRepository in your dependency-injection setup and the service never notices. That single fact is what justifies the pattern.
4. The Unit of Work
A Unit of Work coordinates several repositories so a group of changes is saved together — all or nothing. It holds one shared context and exposes the repositories as properties; calling SaveChanges() (or Commit()) once writes every pending change in a single transaction. This is what stops a half-finished operation — like reducing stock but failing to record the order — from leaving your data in a broken state. You'll build a small one yourself in the Mini-Challenge.
🔎 Deep Dive: why one shared context matters
For a Unit of Work to be transactional, every repository it owns must share the same underlying context. If Customers and Orders each created their own context, calling save on one wouldn't include the other's changes — they'd be two separate transactions, and a failure could commit one but not the other.
That's why a real EF-backed Unit of Work injects one AppDbContext and passes it to every repository it creates:
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public UnitOfWork(AppDbContext context) => _context = context;
// BOTH repos share the one _context, so SaveChanges covers both.
public IRepository<Customer> Customers => new EfRepository<Customer>(_context);
public IRepository<Order> Orders => new EfRepository<Order>(_context);
public Task<int> SaveChangesAsync() => _context.SaveChangesAsync();
}In DI, register the Unit of Work and the context as Scoped (one per request), not Transient — otherwise each injection gets a separate context and the "all or nothing" guarantee evaporates.
5. Testability — the Real Prize
Because your service depends on the interface IRepository<T>, a unit test can hand it a fake, in-memory implementation instead of a real database. No connection string, no migrations, no waiting — the test runs in milliseconds and is perfectly deterministic. This worked example tests an OrderService with a hand-written fake repository, no mocking library required.
using System;
using System.Collections.Generic;
using System.Linq;
// Because the service depends on IRepository<T> (an interface), a test can
// hand it a FAKE in-memory repository — no database, no network, instant.
public interface IRepository<T>
{
T? GetById(int id);
void Add(T entity);
}
public class Order { public int Id { get; set; } public decimal Total { get; set; } }
// The thing we want to test. It only knows the interface.
public class OrderService
{
private readonly IRepository<Order> _orders;
public OrderService(IRepository<Order> orders) => _orders = orders;
public bool IsBigOrder(int id)
{
var order = _orders.GetById(id);
return order is not null && order.Total > 100m;
}
}
// A tiny hand-written fake — no mocking library needed.
public class FakeOrderRepo : IRepository<Order>
{
private readonly Dictionary<int, Order> _data = new();
public void Add(Order o) => _data[o.Id] = o;
public Order? GetById(int id) => _data.TryGetValue(id, out var o) ? o : null;
}
class Program
{
static void Main()
{
// Arrange: a fake repo with known data — fast and deterministic.
var repo = new FakeOrderRepo();
repo.Add(new Order { Id = 1, Total = 250m });
repo.Add(new Order { Id = 2, Total = 40m });
var service = new OrderService(repo);
// Act + Assert (printed here instead of a test framework).
Console.WriteLine($"Order 1 big? {service.IsBigOrder(1)}"); // True
Console.WriteLine($"Order 2 big? {service.IsBigOrder(2)}"); // False
// ✅ Expected output:
// Order 1 big? True
// Order 2 big? False
}
}Pro Tips
- 💡 Return IEnumerable<T>, never IQueryable<T> from a repository method — returning IQueryable lets EF leak out and lets callers build queries the repository can't control.
- 💡 Add specialised repositories when generic isn't enough: an IOrderRepository : IRepository<Order> can add domain methods like GetRecentOrdersAsync without bloating the generic base.
- 💡 Register storage choices in one place — services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); wires every entity at once.
- 💡 Treat the repository as a boundary, not a wrapper — its purpose is to keep EF Core out of your domain, so don't expose EF types through it.
- 💡 Skip the pattern for tiny apps: for a quick CRUD tool, using DbContext directly is honest and simpler. Reach for repositories when you have real business logic to test.
Common Errors (and the debate)
- Leaky repositories that expose IQueryable: if a method returns IQueryable<T>, callers can tack on .Where(...), .Include(...), or accidentally trigger N+1 queries — and EF Core has leaked straight through your "boundary". Return finished data (IEnumerable<T> or a List<T>) so the query runs inside the repository.
- The "repo-over-EF is redundant" debate: a well-known argument says EF Core's DbContext is already a Unit of Work and DbSet<T> is already a repository, so wrapping them adds a layer that just forwards calls. That criticism is fair for a thin pass-through wrapper. It does not apply when the repository adds a real boundary — hiding EF from the domain and enabling fakes in tests. Add the layer for those reasons, not by reflex.
- Generic-repository limitations: a one-size Repository<T> handles simple CRUD but can't express domain queries like "orders from the last 7 days with their items eager-loaded". Forcing everything through the generic base leads to awkward, over-general methods. The fix is a specialised repository interface (IOrderRepository) that extends the generic one with the queries that entity actually needs.
- "CS0311: there is no implicit reference conversion ... to 'IEntity'": your entity class doesn't implement the constraint interface. Add : IEntity (and the Id property) to the class, or relax the where T : constraint.
- Each repository new-ing its own context: if repositories don't share one context, a Unit of Work's single SaveChanges won't cover them all — pass the same context into every repository the unit owns.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Define the contract | interface IRepository<T> { ... } | Callers depend on this |
| Constrain the type | where T : IEntity | Guarantees an Id |
| In-memory store | private readonly List<T> _items; | Keep it private |
| Find one | _items.FirstOrDefault(e => e.Id == id) | null if not found |
| EF query (no leak) | await _dbSet.Where(p).ToListAsync() | Returns IEnumerable |
| Specialised repo | IOrderRepository : IRepository<Order> | Add domain queries |
| Save all changes | await _uow.SaveChangesAsync(); | One transaction |
| Register (DI) | AddScoped(typeof(IRepository<>), ...) | Scoped, not Transient |
Frequently Asked Questions
Q: Isn't EF Core already a repository and unit of work?
Yes — DbSet<T> behaves like a repository and DbContext.SaveChanges() behaves like a unit of work. So wrapping them is only worth it when the wrapper adds value: hiding EF from your domain layer and letting tests use fakes. If it just forwards calls, skip it.
Q: Should I use a generic repository or a specific one per entity?
Both. Start with a generic Repository<T> for plain CRUD, then add a specialised interface (e.g. IOrderRepository : IRepository<Order>) for the domain-specific queries an entity needs. A generic base alone can't express richer queries cleanly.
Q: Why must repository methods avoid returning IQueryable?
Returning IQueryable lets the calling layer keep building the query, which means EF Core has effectively leaked through your boundary — the very thing the repository exists to prevent. Return IEnumerable<T> so the query executes inside the repository.
Q: How does this make my code more testable?
Your service depends on the interface, so a test supplies a fake in-memory repository instead of a real database. Tests run in milliseconds, need no setup, and never flake on a connection — you're testing your logic, not the database.
Q: Where does the Unit of Work fit in?
It sits one level above repositories, owning the shared context and exposing each repository as a property. You make changes through several repositories, then call its single SaveChanges so everything commits together or rolls back together.
Mini-Challenge: a Unit of Work
No blanks this time — just a brief and an outline. Build a UnitOfWork that owns two in-memory repositories, Authors and Books, and a Commit() method that reports how many of each it saved. Then exercise it in Main by adding one author and two books. Run it and check your output against the expected line in the comments.
using System;
using System.Collections.Generic;
using System.Linq;
public interface IEntity { int Id { get; set; } }
public interface IRepository<T> where T : IEntity
{
void Add(T entity);
T? GetById(int id);
IEnumerable<T> GetAll();
}
public class InMemoryRepository<T> : IRepository<T> where T : IEntity
{
private readonly List<T> _items = new();
private int _nextId = 1;
public void Add(T entity) { entity.Id = _nextId++; _items.Add(entity); }
public T? GetById(int id) => _items.FirstOrDefault(e => e.Id == id);
public IEnumerable<T> GetAll() => _items;
}
public class Author { public int Id { get; set; } public string Name { get; set; } = ""; }
public class Book { public int Id { get; set; } public string Title { get; set; } = ""; }
// 🎯 MINI-CHALLENGE: a Unit of Work coordinating TWO repositories.
// 1. Give UnitOfWork two read-only properties:
// Authors -> IRepository<Author>
// Books -> IRepository<Book>
// each backed by a new InMemoryRepository<...>.
// 2. Add a Commit() method that prints how many of each were saved, e.g.:
// Console.WriteLine($"Committed {Authors.GetAll().Count()} authors, " +
// $"{Books.GetAll().Count()} books");
//
// In Main: add 1 author and 2 books through the unit of work, then Commit().
//
// ✅ Expected output:
// Committed 1 authors, 2 books
public class UnitOfWork
{
// your two repository properties here
// your Commit() method here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ A repository hides where and how data is stored — like a librarian hiding the shelves
- ✅ A generic IRepository<T> gives reusable CRUD; callers depend on the interface, not the implementation
- ✅ An in-memory repo (a List<T>) and an EF Core repo (a DbSet<T>) satisfy the same contract
- ✅ Keep EF Core inside the repository — return IEnumerable<T>, never IQueryable<T>
- ✅ A Unit of Work shares one context across repositories and commits them as one transaction
- ✅ The big payoff is testability: swap a fake repo in and test logic with no database
- ✅ The pattern is debated — add it for the boundary and the tests, not by reflex
Practice quiz
What is the main purpose of the Repository pattern?
- To make queries run faster
- To replace the database entirely
- To hide data-access details behind a clean interface
- To generate SQL automatically
Answer: To hide data-access details behind a clean interface. A repository puts a boundary in front of data access so callers depend on an interface, not the storage.
Why should a service depend on IRepository<T> rather than InMemoryRepository<T>?
- So the storage can be swapped without changing the service
- The interface is faster
- Interfaces use less memory
- Concrete classes can't be injected
Answer: So the storage can be swapped without changing the service. Depending on the interface lets you swap implementations (in-memory, EF Core) without touching the caller.
What does the constraint 'where T : IEntity' guarantee in the generic repository?
- Every T is a database row
- Every T is immutable
- Every T implements IDisposable
- Every T has an Id the repository can look items up by
Answer: Every T has an Id the repository can look items up by. The IEntity constraint guarantees each T exposes an Id, the one thing the repository needs.
Why should a repository method return IEnumerable<T> rather than IQueryable<T>?
- IEnumerable is always faster
- So the query runs inside the repository and EF Core doesn't leak to callers
- IQueryable can't be returned from methods
- It avoids using generics
Answer: So the query runs inside the repository and EF Core doesn't leak to callers. Returning IEnumerable<T> executes the query at the boundary; returning IQueryable lets EF Core leak past it.
What is the role of a Unit of Work?
- To coordinate several repositories so changes commit together as one transaction
- To cache query results
- To validate input
- To generate entity Ids
Answer: To coordinate several repositories so changes commit together as one transaction. A Unit of Work groups repositories over one shared context and commits them all or nothing.
For a Unit of Work to be transactional, what must every repository it owns share?
- The same entity type
- The same connection string only
- The same DbContext
- A separate context each
Answer: The same DbContext. All repositories must share one context so a single SaveChanges covers every pending change.
What is the biggest payoff the Repository pattern gives you?
- Smaller database files
- Testability - you can hand a fake repository to a service
- Automatic migrations
- Faster network calls
Answer: Testability - you can hand a fake repository to a service. Because the service depends on the interface, tests can supply a fake repo and run with no database.
How do you add domain-specific queries when a generic repository isn't enough?
- Add them to DbContext
- Use IQueryable everywhere
- Subclass the entity
- Define a specialised interface like IOrderRepository : IRepository<Order>
Answer: Define a specialised interface like IOrderRepository : IRepository<Order>. A specialised repository extends the generic one with the richer queries an entity actually needs.
In DI, how should a Unit of Work and its DbContext typically be registered?
- Transient
- Scoped (one per request)
- Singleton
- It doesn't matter
Answer: Scoped (one per request). Scoped registration gives one context per request; Transient would give separate contexts and break the all-or-nothing guarantee.
What is the well-known criticism of putting a repository over EF Core?
- EF Core forbids it
- Repositories can't use async
- DbContext is already a Unit of Work and DbSet<T> is already a repository, so a thin wrapper just forwards calls
- It leaks the database connection
Answer: DbContext is already a Unit of Work and DbSet<T> is already a repository, so a thin wrapper just forwards calls. The debate is fair for a thin pass-through; add the layer only when it provides a real boundary and testability.
Continue this course
- Previous: EF Core Relationships, Tracking & Migrations Mastery
- Next: Authentication & Authorization with JWT (Advanced) — Issue, validate, and refresh JWTs in ASP.NET Core APIs
- Quick reference: C# cheat sheet