.NET & C# Development · Lesson 5 of 11

Repository & Unit of Work

The Goal

The Repository pattern abstracts data access so your application layer doesn't depend directly on EF Core, SQL, or any specific ORM. The Unit of Work pattern groups multiple repository operations into a single transaction.

Application Layer
    ↓  IOrderRepository  ← interface (no EF reference)
Infrastructure Layer
    ↓  OrderRepository   ← EF Core implementation
    ↓  AppDbContext

Your handlers depend on the interface, not the concrete class. Swap EF Core for Dapper, or add a test double, without touching application code.


Generic Repository

A generic repository provides the standard CRUD operations for any entity:

C#
// Application/Common/Interfaces/IRepository.cs
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default);
    Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default);
    Task AddAsync(T entity, CancellationToken ct = default);
    void Update(T entity);
    void Remove(T entity);
}
C#
// Infrastructure/Persistence/Repositories/Repository.cs
public class Repository<T> : IRepository<T> where T : class
{
    protected readonly AppDbContext _db;
    protected readonly DbSet<T> _set;

    public Repository(AppDbContext db)
    {
        _db = db;
        _set = db.Set<T>();
    }

    public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default)
        => await _set.FindAsync([id], ct);

    public async Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default)
        => await _set.AsNoTracking().ToListAsync(ct);

    public async Task<IReadOnlyList<T>> FindAsync(
        Expression<Func<T, bool>> predicate, CancellationToken ct = default)
        => await _set.AsNoTracking().Where(predicate).ToListAsync(ct);

    public async Task AddAsync(T entity, CancellationToken ct = default)
        => await _set.AddAsync(entity, ct);

    public void Update(T entity)
        => _set.Update(entity);

    public void Remove(T entity)
        => _set.Remove(entity);
}

Specific Repository

A specific repository extends the generic one with domain-specific queries. This is where the real value lives:

C#
// Application/Common/Interfaces/IOrderRepository.cs
public interface IOrderRepository : IRepository<Order>
{
    Task<Order?> GetOrderWithDetailsAsync(int orderId, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetOrdersByCustomerAsync(int customerId, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetPendingOrdersAsync(CancellationToken ct = default);
    Task<decimal> GetTotalRevenueAsync(DateRange range, CancellationToken ct = default);
}
C#
// Infrastructure/Persistence/Repositories/OrderRepository.cs
public class OrderRepository : Repository<Order>, IOrderRepository
{
    public OrderRepository(AppDbContext db) : base(db) { }

    public async Task<Order?> GetOrderWithDetailsAsync(int orderId, CancellationToken ct = default)
        => await _db.Orders
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .Include(o => o.Customer)
            .FirstOrDefaultAsync(o => o.Id == orderId, ct);

    public async Task<IReadOnlyList<Order>> GetOrdersByCustomerAsync(
        int customerId, CancellationToken ct = default)
        => await _db.Orders
            .AsNoTracking()
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .ToListAsync(ct);

    public async Task<IReadOnlyList<Order>> GetPendingOrdersAsync(CancellationToken ct = default)
        => await _db.Orders
            .AsNoTracking()
            .Where(o => o.Status == OrderStatus.Pending)
            .Include(o => o.Customer)
            .ToListAsync(ct);

    public async Task<decimal> GetTotalRevenueAsync(DateRange range, CancellationToken ct = default)
        => await _db.Orders
            .Where(o => o.CreatedAt >= range.Start && o.CreatedAt <= range.End
                     && o.Status == OrderStatus.Completed)
            .SumAsync(o => o.TotalAmount, ct);
}

Unit of Work

The Unit of Work wraps multiple repository operations in one transaction. All changes are committed together or rolled back together.

C#
// Application/Common/Interfaces/IUnitOfWork.cs
public interface IUnitOfWork : IDisposable
{
    IOrderRepository Orders { get; }
    ICustomerRepository Customers { get; }
    IProductRepository Products { get; }

    Task<int> SaveChangesAsync(CancellationToken ct = default);
    Task BeginTransactionAsync(CancellationToken ct = default);
    Task CommitTransactionAsync(CancellationToken ct = default);
    Task RollbackTransactionAsync(CancellationToken ct = default);
}
C#
// Infrastructure/Persistence/UnitOfWork.cs
public class UnitOfWork : IUnitOfWork
{
    private readonly AppDbContext _db;
    private IDbContextTransaction? _transaction;

    private IOrderRepository? _orders;
    private ICustomerRepository? _customers;
    private IProductRepository? _products;

    public UnitOfWork(AppDbContext db) => _db = db;

    // Lazy-initialise repositories so they share the same DbContext
    public IOrderRepository Orders
        => _orders ??= new OrderRepository(_db);

    public ICustomerRepository Customers
        => _customers ??= new CustomerRepository(_db);

    public IProductRepository Products
        => _products ??= new ProductRepository(_db);

    public async Task<int> SaveChangesAsync(CancellationToken ct = default)
        => await _db.SaveChangesAsync(ct);

    public async Task BeginTransactionAsync(CancellationToken ct = default)
        => _transaction = await _db.Database.BeginTransactionAsync(ct);

    public async Task CommitTransactionAsync(CancellationToken ct = default)
    {
        try
        {
            await _db.SaveChangesAsync(ct);
            await _transaction!.CommitAsync(ct);
        }
        catch
        {
            await RollbackTransactionAsync(ct);
            throw;
        }
        finally
        {
            await _transaction!.DisposeAsync();
            _transaction = null;
        }
    }

    public async Task RollbackTransactionAsync(CancellationToken ct = default)
    {
        await _transaction!.RollbackAsync(ct);
        await _transaction!.DisposeAsync();
        _transaction = null;
    }

    public void Dispose() => _db.Dispose();
}

Usage in a Command Handler

With CQRS + Repository:

C#
public class TransferOrderCommandHandler : IRequestHandler<TransferOrderCommand, Unit>
{
    private readonly IUnitOfWork _uow;

    public TransferOrderCommandHandler(IUnitOfWork uow) => _uow = uow;

    public async Task<Unit> Handle(TransferOrderCommand request, CancellationToken ct)
    {
        await _uow.BeginTransactionAsync(ct);

        var order = await _uow.Orders.GetByIdAsync(request.OrderId, ct)
            ?? throw new NotFoundException(nameof(Order), request.OrderId);

        var newCustomer = await _uow.Customers.GetByIdAsync(request.NewCustomerId, ct)
            ?? throw new NotFoundException(nameof(Customer), request.NewCustomerId);

        // Business logic
        order.CustomerId = newCustomer.Id;
        order.TransferredAt = DateTime.UtcNow;

        _uow.Orders.Update(order);

        // Both changes in one transaction
        await _uow.CommitTransactionAsync(ct);

        return Unit.Value;
    }
}

DI Registration

C#
// Infrastructure/DependencyInjection.cs
public static class DependencyInjection
{
    public static IServiceCollection AddInfrastructure(
        this IServiceCollection services,
        IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseSqlServer(config.GetConnectionString("DefaultConnection")));

        services.AddScoped<IUnitOfWork, UnitOfWork>();
        services.AddScoped<IOrderRepository, OrderRepository>();
        services.AddScoped<ICustomerRepository, CustomerRepository>();
        services.AddScoped<IProductRepository, ProductRepository>();

        return services;
    }
}

The Debate: Do You Need This?

The argument against the Repository pattern with EF Core:

EF Core's DbSet<T> is already a repository. DbContext is already a Unit of Work. Wrapping them adds abstraction without adding value — and you lose the ability to use EF-specific features like .Include(), complex projections, and .AsNoTracking() through a generic interface.

When the Repository pattern earns its keep:

| Scenario | Repository worth it? | |---|---| | Simple CRUD app | No — use DbContext directly | | Large codebase with domain logic | Yes — keeps Application layer portable | | Need to swap data stores (e.g., EF + Dapper reads) | Yes | | Unit testing without a real DB | Use IAppDbContext interface instead | | Team unfamiliar with EF | No — the abstraction hides features they need |

The lightweight alternative — just wrap DbContext in an interface:

C#
public interface IAppDbContext
{
    DbSet<Order> Orders { get; }
    DbSet<Customer> Customers { get; }
    Task<int> SaveChangesAsync(CancellationToken ct);
}

public class AppDbContext : DbContext, IAppDbContext { ... }

Inject IAppDbContext into handlers. Mock it in tests. Use .Include(), projections, and .AsNoTracking() freely.


Specification Pattern

When your repository's FindAsync(predicate) starts accepting complex lambdas, consider the Specification pattern to name and reuse query logic:

C#
public abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> Criteria { get; }
    public List<Expression<Func<T, object>>> Includes { get; } = [];
    public Expression<Func<T, object>>? OrderBy { get; protected set; }
    public int? Take { get; protected set; }
    public int? Skip { get; protected set; }
}
C#
// Named, reusable query
public class PendingOrdersForCustomerSpec : Specification<Order>
{
    public PendingOrdersForCustomerSpec(int customerId)
    {
        Criteria = o => o.CustomerId == customerId && o.Status == OrderStatus.Pending;
        Includes.Add(o => o.Items);
        OrderBy = o => o.CreatedAt;
        Take = 20;
    }

    public override Expression<Func<Order, bool>> Criteria { get; }
}
C#
// Apply spec in repository
public async Task<IReadOnlyList<T>> FindAsync(
    Specification<T> spec, CancellationToken ct = default)
{
    var query = ApplySpecification(spec);
    return await query.AsNoTracking().ToListAsync(ct);
}

private IQueryable<T> ApplySpecification(Specification<T> spec)
{
    var query = _set.Where(spec.Criteria);
    query = spec.Includes.Aggregate(query, (q, i) => q.Include(i));
    if (spec.OrderBy is not null) query = query.OrderBy(spec.OrderBy);
    if (spec.Skip.HasValue) query = query.Skip(spec.Skip.Value);
    if (spec.Take.HasValue) query = query.Take(spec.Take.Value);
    return query;
}

Testing with a Repository Interface

C#
public class GetOrderByIdQueryHandlerTests
{
    [Fact]
    public async Task Handle_ExistingOrder_ReturnsMappedDto()
    {
        // Arrange
        var mockRepo = new Mock<IOrderRepository>();
        mockRepo.Setup(r => r.GetOrderWithDetailsAsync(1, default))
                .ReturnsAsync(new Order
                {
                    Id = 1,
                    Reference = "ORD-001",
                    Status = OrderStatus.Pending,
                    Customer = new Customer { Name = "Alice" },
                    Items = []
                });

        var handler = new GetOrderByIdQueryHandler(mockRepo.Object);

        // Act
        var result = await handler.Handle(new GetOrderByIdQuery(1), default);

        // Assert
        result.Reference.Should().Be("ORD-001");
        result.CustomerName.Should().Be("Alice");
    }
}

Key Takeaways

  • Generic Repository provides base CRUD; Specific Repository adds domain-meaningful query methods — never expose raw IQueryable<T> from a repository
  • Unit of Work coordinates multiple repositories in a single transaction
  • For small apps, skip the pattern and use IAppDbContext directly — EF Core is already a repository/UoW
  • The Specification pattern is the evolution of complex query logic — named, testable, reusable
  • Always inject interfaces into handlers so tests can use mock or in-memory implementations