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

💡 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:

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.

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: 2

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.

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

Common Errors (and the debate)

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

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

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