Skip to main content
    Courses/C#/Repository Pattern

    Lesson 35 • Advanced Track

    Repository Pattern & Unit of Work

    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.

    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

    PieceWhat it isReal-world parallel
    IRepository<T>The contract: Add / GetById / GetAllThe library's request desk
    InMemoryRepository<T>A List-backed implementationA small private bookshelf
    EfRepository<T>A DbSet-backed implementationThe full robot warehouse
    IUnitOfWorkGroups repos + one SaveChangesThe front desk checking out a stack
    Fake repositoryA test double for the interfaceA 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.

    Worked example: a generic in-memory repository

    Read every comment and run it — the caller asks for a Book and never sees the List.

    Try it Yourself »
    C#
    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 l
    ...

    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.

    🎯 Your turn: implement Add and GetById

    Finish the in-memory repository, then check that Id 2 is Bob.

    Try it Yourself »
    C#
    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
    ...

    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.

    🎯 Your turn: add and find through the repo

    Use Add, GetById and a LINQ Count to reach the expected output.

    Try it Yourself »
    C#
    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 
    ...

    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.

    Worked example: an EF Core-backed repository

    The same interface, now over a real DbSet — EF Core stays locked inside the class.

    Try it Yourself »
    C#
    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);
     
    ...

    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.

    Worked example: testing with a fake repository

    A fake repo lets you test business logic with zero database — fast and predictable.

    Try it Yourself »
    C#
    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 I
    ...

    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 placeservices.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

    TaskCodeNotes
    Define the contractinterface IRepository<T> { ... }Callers depend on this
    Constrain the typewhere T : IEntityGuarantees an Id
    In-memory storeprivate 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 repoIOrderRepository : IRepository<Order>Add domain queries
    Save all changesawait _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.

    🎯 Mini-Challenge: coordinate two repositories

    Write the UnitOfWork with two repos and a Commit; output should be: Committed 1 authors, 2 books.

    Try it Yourself »
    C#
    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 
    ...

    🎉 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
    • Next lesson: JWT Authentication — securing your API endpoints with tokens

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service