When building enterprise applications with C#, one of the most persistent challenges is managing data access in a way that keeps your codebase maintainable, testable, and adaptable to change. The Repository and Unit of Work patterns have become cornerstones of clean architecture, but their implementation is often misunderstood or over-engineered. This guide provides a practical, experience-based walkthrough of these patterns, including when to use them, how to implement them in .NET, and what pitfalls to avoid. All advice reflects widely shared professional practices as of May 2026; verify critical details against current official guidance where applicable.
Why Repository and Unit of Work Matter for Clean Architecture
At its core, clean architecture aims to decouple business logic from infrastructure concerns like databases, external services, and UI frameworks. In a typical C# application, data access code often becomes tightly coupled to Entity Framework (EF) Core or other ORMs, making it difficult to unit test business logic without spinning up a real database. The Repository pattern addresses this by providing an abstraction over data storage, allowing you to swap implementations (e.g., from SQL Server to in-memory) without changing business code. The Unit of Work pattern complements this by managing transactions: when you modify multiple entities, it ensures all changes are committed atomically or rolled back together.
Teams often find that these patterns reduce duplication and centralize query logic. For example, instead of scattering Where and Include calls across controllers, you define reusable repository methods like GetActiveOrdersByCustomer. This also makes it easier to apply cross-cutting concerns like caching or logging. However, the patterns are not free: they introduce additional interfaces and indirection, which can be overkill for simple CRUD applications. The key is to apply them where the complexity is justified—typically in applications with complex business rules, multiple data sources, or a need for extensive unit testing.
The Problem They Solve
Without these patterns, you might have service classes that directly call DbContext.SaveChanges() after modifying entities. This works for small projects but leads to issues as the codebase grows: transactions span multiple operations inconsistently, query logic is duplicated, and mocking the database for tests becomes painful. Repository and Unit of Work encapsulate these concerns, giving you a single point to control persistence behavior.
When to Avoid Them
If your application is a simple CRUD wrapper with minimal business logic, the overhead of these patterns may outweigh benefits. EF Core’s DbSet already acts as a repository, and DbContext itself is a unit of work. Adding extra layers can make the code harder to follow. Reserve these patterns for projects where you need to abstract the data layer for testing, support multiple storage backends, or enforce strict separation of concerns.
Core Concepts: How Repository and Unit of Work Work Together
The Repository pattern defines a collection-like interface for accessing domain objects. A typical interface might include methods like GetById, Add, Update, and Delete. The Unit of Work pattern tracks changes to objects and coordinates the persistence of those changes. In practice, the Unit of Work holds references to multiple repositories and exposes a single SaveChangesAsync method. When a business operation modifies several entities across different repositories, the Unit of Work ensures that either all changes are saved or none are.
In a C# implementation using EF Core, the DbContext naturally serves as the Unit of Work. Your custom repository classes wrap DbSet operations, and the Unit of Work interface exposes SaveChangesAsync and provides access to repositories via properties like IOrderRepository Orders { get; }. This structure allows you to inject a single IUnitOfWork into your service classes, keeping them ignorant of the underlying ORM.
Interface Design Principles
A common mistake is to create generic repository interfaces like IRepository<T> with methods like GetAll, Find, and Add. While convenient, this often leads to leaky abstractions: you end up exposing IQueryable, which ties you to EF Core and defeats the purpose of abstraction. Instead, design repository interfaces around your domain needs. For example, IOrderRepository might have GetOrdersByCustomerId(int customerId, bool includeItems) rather than a generic Find(Expression<Func<T,bool>> predicate). This makes the interface more stable and testable.
Transaction Management
The Unit of Work pattern becomes crucial when you need to perform multiple operations atomically. For instance, when placing an order, you might need to update inventory, create an order record, and charge a payment. If any step fails, all changes should roll back. The Unit of Work coordinates this by calling SaveChangesAsync only once at the end of the operation. In EF Core, you can achieve this by using DbContext.Database.BeginTransaction for distributed transactions, but for most scenarios, SaveChangesAsync itself wraps a single database transaction.
Step-by-Step Implementation in C# with EF Core
Let’s walk through a concrete implementation for an e-commerce system. We’ll define interfaces, implement them with EF Core, and wire everything up with dependency injection.
1. Define Repository Interfaces
Start with domain-specific interfaces. For example:
public interface IOrderRepository
{
Task<Order> GetByIdAsync(int id);
Task<IEnumerable<Order>> GetByCustomerIdAsync(int customerId);
void Add(Order order);
void Update(Order order);
void Delete(Order order);
}
Keep methods focused on business operations. Avoid exposing IQueryable unless you are willing to couple the interface to EF Core.
2. Implement Repositories
Create concrete classes that use the DbContext:
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
public OrderRepository(AppDbContext context) => _context = context;
public async Task<Order> GetByIdAsync(int id) =>
await _context.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id);
public async Task<IEnumerable<Order>> GetByCustomerIdAsync(int customerId) =>
await _context.Orders.Where(o => o.CustomerId == customerId).ToListAsync();
public void Add(Order order) => _context.Orders.Add(order);
public void Update(Order order) => _context.Orders.Update(order);
public void Delete(Order order) => _context.Orders.Remove(order);
}
3. Define and Implement Unit of Work
The Unit of Work interface exposes repositories and a save method:
public interface IUnitOfWork : IDisposable
{
IOrderRepository Orders { get; }
ICustomerRepository Customers { get; }
Task<int> SaveChangesAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IOrderRepository Orders { get; }
public ICustomerRepository Customers { get; }
public UnitOfWork(AppDbContext context)
{
_context = context;
Orders = new OrderRepository(_context);
Customers = new CustomerRepository(_context);
}
public async Task<int> SaveChangesAsync() => await _context.SaveChangesAsync();
public void Dispose() => _context.Dispose();
}
4. Register with Dependency Injection
In your startup configuration, register the DbContext and Unit of Work as scoped services:
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<IUnitOfWork, UnitOfWork>();
Now your services can inject IUnitOfWork and use it to coordinate operations.
Tools, Stack, and Maintenance Realities
Choosing the right tooling for Repository and Unit of Work goes beyond just EF Core. Many teams also consider Dapper, raw ADO.NET, or even document databases like MongoDB. Each has implications for how you implement these patterns.
Comparison of Data Access Approaches
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| EF Core with Repository/UoW | Full abstraction, change tracking, LINQ | Performance overhead, complex mapping | Complex business logic, frequent schema changes |
| Dapper with Repository | Fast, lightweight, full SQL control | No change tracking, manual mapping | High-performance queries, simple CRUD |
| Raw ADO.NET with Repository | Maximum control, no ORM dependency | Boilerplate code, error-prone | Legacy systems, specific performance needs |
| MongoDB with Repository | Schema-less, easy scaling | Different query paradigm, no ACID transactions across collections | Document-oriented data, rapid prototyping |
Maintenance realities: The Repository pattern adds a layer that must be kept in sync with the underlying data model. When you add a new property to an entity, you may need to update repository methods that use it. This can be mitigated by using auto-mapping tools like AutoMapper, but that introduces another dependency. Also, be aware that EF Core’s change tracker can cause unexpected behavior if you mix tracked and untracked entities within the same Unit of Work. Always ensure that entities loaded from one repository instance are attached to the same DbContext.
Performance Considerations
One common criticism of the Repository pattern is that it can lead to the N+1 query problem if you’re not careful. For example, if you have a repository method that returns orders without including items, and then you iterate over orders and call another repository to get items per order, you’ll generate many queries. Mitigate this by designing repository methods that eager-load related data when needed, or by using projection to select only the required fields. Also, consider using compiled queries for frequently executed operations.
Growth Mechanics: Scaling the Pattern for Larger Teams
As your application and team grow, the initial simple implementation of Repository and Unit of Work may need to evolve. One common pattern is to introduce a generic base repository to reduce boilerplate, but with careful constraints to avoid leaking IQueryable. For example, a base repository can provide GetById, Add, Update, and Delete while leaving domain-specific queries to derived interfaces.
Handling Multiple DbContexts
In larger systems, you might have multiple bounded contexts, each with its own DbContext. In that case, each context gets its own Unit of Work. Sharing repositories across contexts is not recommended because transactions cannot span multiple databases without distributed transaction coordination (which is often unavailable or costly). Instead, design your services to work within a single context per operation, or use eventual consistency patterns like messaging.
Testing Strategies
One of the main benefits of these patterns is testability. You can mock IUnitOfWork and its repositories to unit test your business logic without a database. For integration tests, you can use an in-memory database or a test container. However, be cautious: mocking too many interfaces can lead to brittle tests that break on implementation details. Focus on testing behavior rather than repository call counts.
Evolving to CQRS
For very complex domains, some teams move from Repository/UoW to Command Query Responsibility Segregation (CQRS). In CQRS, commands use a separate write model (often with Unit of Work), while queries use a separate read model (often with raw SQL or Dapper). This can improve performance and scalability, but it adds complexity. Consider CQRS only when you have clear performance bottlenecks or when the read and write models diverge significantly.
Risks, Pitfalls, and Mitigations
Even experienced developers fall into traps when implementing these patterns. Here are the most common issues and how to avoid them.
Leaky Abstractions
The biggest risk is that your repository interface exposes EF Core-specific types like IQueryable or includes methods that imply a particular ORM. This defeats the purpose of abstraction. Mitigation: keep repository methods domain-focused; avoid returning IQueryable. If you need dynamic querying, consider using the Specification pattern instead.
Over-Abstraction
Creating a generic IRepository<T> with methods like GetAll, Find, Add, etc., often leads to a “god” interface that every entity must implement, even if some entities are read-only. This also makes it hard to add entity-specific methods. Mitigation: use a generic base class for common operations, but define separate interfaces for each aggregate root. This gives you flexibility to add specialized methods.
Transaction Scope Confusion
When you have multiple repositories within a Unit of Work, all changes are saved atomically. However, if you call SaveChangesAsync multiple times within the same operation, you break the atomicity. Mitigation: ensure that your service methods call SaveChangesAsync only once at the end of the operation. If you need partial saves (e.g., for long-running processes), consider using a different pattern like the Saga pattern.
Performance Overhead
Using a separate repository for each entity can lead to many small database calls. Mitigation: batch operations within a single repository method where possible, and use eager loading to reduce round trips. Profile your application to identify hotspots before optimizing.
Frequently Asked Questions and Decision Checklist
FAQ
Q: Should I always use Repository and Unit of Work?
A: No. For simple CRUD apps, EF Core’s DbContext and DbSet already provide these patterns. Add them only when you need to abstract the data layer for testing, support multiple storage backends, or enforce strict separation.
Q: Can I use Unit of Work without repositories?
A: Yes. You can inject DbContext directly as the Unit of Work and use its DbSet properties. However, this couples your business logic to EF Core. Repositories provide an additional layer of abstraction.
Q: How do I handle transactions that span multiple databases?
A: Distributed transactions with TransactionScope are possible but often not supported in cloud environments. Consider using eventual consistency with a message broker or the Saga pattern.
Q: Is it okay to expose IQueryable from repositories?
A: It’s a trade-off. Exposing IQueryable gives flexibility but couples the interface to EF Core. If you need dynamic querying, consider using the Specification pattern or exposing specific query methods.
Decision Checklist
- Does your application have complex business logic that requires unit testing without a database? → Yes: use Repository/UoW.
- Do you plan to switch databases in the future? → Yes: use Repository/UoW to abstract the ORM.
- Is your application a simple CRUD wrapper with minimal logic? → No: skip the patterns.
- Are you using microservices with bounded contexts? → Use per-context Unit of Work.
- Do you need to support multiple ORMs or data sources? → Yes: use Repository/UoW.
Synthesis and Next Steps
The Repository and Unit of Work patterns are powerful tools for achieving clean architecture in C# applications, but they are not silver bullets. The key is to apply them judiciously, focusing on the abstractions that your domain truly needs. Start with a simple implementation using EF Core, and evolve as your application grows. Remember that the goal is to make your code more maintainable and testable, not to add layers for their own sake.
Concrete Next Steps
- Audit your current data access layer: identify where business logic is coupled to EF Core or other infrastructure.
- Define repository interfaces for each aggregate root in your domain, keeping methods domain-specific.
- Implement a Unit of Work that wraps your DbContext and exposes repositories.
- Register everything with dependency injection as scoped services.
- Write unit tests for your business logic by mocking the Unit of Work and repositories.
- Profile your application to ensure the abstraction does not introduce performance bottlenecks.
- Consider whether you need to evolve to CQRS or other patterns as your domain complexity increases.
This overview reflects widely shared professional practices as of May 2026. Always verify against current official .NET documentation and your specific project requirements.
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!