Repository Pattern & Unit of Work — Done Right in .NET
The real-world implementation of Repository Pattern and Unit of Work in ASP.NET Core with EF Core — generic and specific repositories, transaction management, and when the pattern adds value vs when it just adds layers.
The Repository and Unit of Work patterns appear in every .NET architecture discussion — and in almost every senior developer interview. The problem is that most implementations online are either overcomplicated (wrapping EF Core when EF Core already does this) or too thin to be useful. This lesson shows the version that actually belongs in production code.
What Problem Each Pattern Solves
Repository Pattern — abstracts the data access layer. Instead of scattering db.Products.Where(...) throughout your application, data access goes through a repository. Business logic doesn't know or care how data is stored.
Unit of Work — groups multiple operations into a single transaction. You make changes across several repositories, and Commit() either saves all of them or none.
The key insight: EF Core's DbContext is already a Unit of Work, and DbSet<T> is already a Repository. If you're exposing IQueryable from your repositories and calling SaveChangesAsync on DbContext, you're already using these patterns through EF Core's abstractions.
So when should you add your own layer? When you need to:
- Swap the data store without changing business logic
- Unit test business logic without a real database
- Enforce consistent patterns across a large team
- Add cross-cutting concerns (auditing, soft delete) in one place
The Generic Repository
Start with a generic base that handles common CRUD:
// Domain layer — interface knows nothing about EF
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(T entity, CancellationToken ct = default);
void Update(T entity);
void Delete(T entity);
}// Infrastructure layer — EF Core implementation
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(new object[] { id }, ct);
public async Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default)
=> await _set.AsNoTracking().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 Delete(T entity)
=> _set.Remove(entity);
}The generic repository covers the common cases. No SaveChangesAsync here — that belongs to the Unit of Work.
Specific Repositories for Domain Queries
The generic repository only gets you so far. Product-specific queries — searching by name, filtering by category, loading with related data — go in a specific repository that extends the generic one:
// Domain layer — specific interface
public interface IProductRepository : IRepository<Product>
{
Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default);
Task<IReadOnlyList<Product>> GetByCategoryAsync(string category, CancellationToken ct = default);
Task<IReadOnlyList<Product>> SearchAsync(string term, decimal? maxPrice, CancellationToken ct = default);
Task<bool> SkuExistsAsync(string sku, CancellationToken ct = default);
}// Infrastructure layer — EF Core implementation
public class ProductRepository : Repository<Product>, IProductRepository
{
public ProductRepository(AppDbContext db) : base(db) { }
public async Task<Product?> GetBySkuAsync(string sku, CancellationToken ct = default)
=> await _set.AsNoTracking()
.FirstOrDefaultAsync(p => p.Sku == sku, ct);
public async Task<IReadOnlyList<Product>> GetByCategoryAsync(
string category, CancellationToken ct = default)
=> await _set.AsNoTracking()
.Where(p => p.Category == category)
.OrderBy(p => p.Name)
.ToListAsync(ct);
public async Task<IReadOnlyList<Product>> SearchAsync(
string term, decimal? maxPrice, CancellationToken ct = default)
{
IQueryable<Product> query = _set.AsNoTracking();
if (!string.IsNullOrWhiteSpace(term))
query = query.Where(p => p.Name.Contains(term) || p.Description.Contains(term));
if (maxPrice.HasValue)
query = query.Where(p => p.Price <= maxPrice.Value);
return await query.OrderBy(p => p.Name).ToListAsync(ct);
}
public async Task<bool> SkuExistsAsync(string sku, CancellationToken ct = default)
=> await _set.AnyAsync(p => p.Sku == sku, ct);
}Domain logic calls IProductRepository. It never imports EF Core. If you swap Postgres for SQL Server, the interface stays identical.
Unit of Work
The Unit of Work wraps the DbContext and exposes repositories + a CommitAsync that saves all changes in a single transaction:
// Domain layer — interface
public interface IUnitOfWork : IAsyncDisposable
{
IProductRepository Products { get; }
IOrderRepository Orders { get; }
ICustomerRepository Customers { get; }
Task<int> CommitAsync(CancellationToken ct = default);
}// Infrastructure layer — implementation
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _db;
private IProductRepository? _products;
private IOrderRepository? _orders;
private ICustomerRepository? _customers;
public UnitOfWork(AppDbContext db) => _db = db;
// Lazy initialisation — repositories created on first access
public IProductRepository Products => _products ??= new ProductRepository(_db);
public IOrderRepository Orders => _orders ??= new OrderRepository(_db);
public ICustomerRepository Customers => _customers ??= new CustomerRepository(_db);
public async Task<int> CommitAsync(CancellationToken ct = default)
=> await _db.SaveChangesAsync(ct);
public async ValueTask DisposeAsync()
=> await _db.DisposeAsync();
}Registration in Program.cs:
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();Scoped lifetime is correct — DbContext is scoped (one per HTTP request), so the Unit of Work must be too.
Using It in Application Code
The Unit of Work is injected into your service or CQRS handler. Business logic uses repositories; CommitAsync saves everything atomically.
public class CreateOrderCommandHandler
{
private readonly IUnitOfWork _uow;
public CreateOrderCommandHandler(IUnitOfWork uow) => _uow = uow;
public async Task<OrderDto> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
// Validate customer exists
var customer = await _uow.Customers.GetByIdAsync(cmd.CustomerId, ct)
?? throw new NotFoundException($"Customer {cmd.CustomerId} not found.");
// Check all products exist and are in stock
foreach (var item in cmd.Items)
{
var product = await _uow.Products.GetByIdAsync(item.ProductId, ct)
?? throw new NotFoundException($"Product {item.ProductId} not found.");
if (product.Stock < item.Quantity)
throw new BusinessRuleException($"Insufficient stock for {product.Name}.");
// Deduct stock
product.Stock -= item.Quantity;
_uow.Products.Update(product);
}
// Create the order
var order = Order.Create(customer, cmd.Items, cmd.DeliveryAddress);
await _uow.Orders.AddAsync(order, ct);
// One transaction: all product updates + order creation, or nothing
await _uow.CommitAsync(ct);
return OrderDto.From(order);
}
}Products are updated and the order is created in a single SaveChanges call. If any step throws before CommitAsync, nothing is persisted.
Transaction Management for Complex Operations
For operations that span multiple commits or need explicit transaction control, use DbContext.Database.BeginTransactionAsync:
public async Task TransferStockAsync(
int fromProductId, int toProductId, int quantity, CancellationToken ct)
{
// Need explicit transaction — two separate operations must both succeed or both fail
await using var transaction = await _db.Database.BeginTransactionAsync(ct);
try
{
var source = await _uow.Products.GetByIdAsync(fromProductId, ct)
?? throw new NotFoundException("Source product not found.");
var target = await _uow.Products.GetByIdAsync(toProductId, ct)
?? throw new NotFoundException("Target product not found.");
if (source.Stock < quantity)
throw new BusinessRuleException("Insufficient stock.");
source.Stock -= quantity;
target.Stock += quantity;
_uow.Products.Update(source);
_uow.Products.Update(target);
await _uow.CommitAsync(ct); // writes to DB within the transaction
await transaction.CommitAsync(ct); // commits the transaction
}
catch
{
await transaction.RollbackAsync(ct);
throw;
}
}For most operations, EF Core's implicit transaction on SaveChanges is sufficient. Explicit transactions are needed when you have multiple SaveChanges calls that must be atomic, or when mixing raw SQL with EF operations.
Soft Delete with the Repository Pattern
One compelling reason to add a repository layer: implement soft delete once, in one place.
// Base entity
public abstract class AuditableEntity
{
public int Id { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
}// Override Delete in the base repository
public override void Delete(T entity)
{
if (entity is AuditableEntity auditable)
{
auditable.IsDeleted = true;
auditable.DeletedAt = DateTime.UtcNow;
_set.Update(auditable); // mark as modified, not removed
}
else
{
_set.Remove(entity);
}
}// Global query filter in AppDbContext — soft-deleted records are invisible to all queries
protected override void OnModelCreating(ModelBuilder builder)
{
foreach (var entityType in builder.Model.GetEntityTypes())
{
if (typeof(AuditableEntity).IsAssignableFrom(entityType.ClrType))
{
builder.Entity(entityType.ClrType)
.HasQueryFilter(e => !((AuditableEntity)e).IsDeleted);
}
}
}Every query on any AuditableEntity automatically excludes soft-deleted records. No WHERE IsDeleted = false scattered throughout the codebase.
When Not to Use This Pattern
The Repository + Unit of Work pattern has a cost: more files, more indirection, more to test. Don't add it because a blog told you to. Add it when:
Use it when:
- You have complex domain logic that needs to be unit-tested without a database
- You work on a large team that needs consistent data access patterns
- You might swap the data store (PostgreSQL to MongoDB, etc.)
- You need cross-cutting concerns (audit, soft delete) applied consistently
Skip it (use EF Core directly) when:
- You're building a CRUD API with minimal business logic
- The team is small and consistency isn't a concern
- You're using CQRS with Dapper or raw SQL for the read side — the repository abstraction adds nothing for simple queries
In a CQRS architecture: use repositories for the write side (commands that modify state); query the DbContext directly for the read side (simple data retrieval for API responses). Read queries don't need the abstraction — they just need the fastest path to the data.
Quick Reference
// Domain layer (no EF Core imports)
IRepository<T> // CRUD base interface
IProductRepository // domain-specific queries
// Infrastructure layer (EF Core implementation)
Repository<T> // generic implementation
ProductRepository // specific queries, extends Repository<T>
UnitOfWork // wraps DbContext, exposes repositories, CommitAsync
// Registration
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
// Usage
await _uow.Products.AddAsync(product, ct);
_uow.Orders.Update(order);
await _uow.CommitAsync(ct); // single SaveChanges — all or nothingThe pattern is worth the overhead when domain complexity demands it. Applied to a simple CRUD endpoint, it's ceremony. Applied to a multi-aggregate transaction with business invariants to enforce, it's the right tool.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.