Repository Pattern
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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
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.
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.
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.
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.
Your 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.
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.
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.
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.
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:
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.
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.
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.
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.
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.
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.