Skip to main content

Design Patterns in C#: Implementing the Repository and Unit of Work for Clean Architecture

In modern C# application development, particularly within enterprise contexts, structuring data access is a critical architectural decision. This article provides a comprehensive, practical guide to implementing the Repository and Unit of Work patterns, specifically tailored for Clean Architecture in .NET applications. We'll move beyond theoretical definitions to explore concrete implementation strategies, discuss common pitfalls I've encountered in real projects, and demonstrate how these patte

图片

Introduction: The Quest for Clean Data Access

Throughout my career architecting and building .NET applications, I've witnessed a recurring challenge: the gradual entanglement of business logic with data access code. What starts as a simple DbContext call in a controller often spirals into duplicated queries, untestable code, and a high cost of change. The Repository and Unit of Work patterns, when implemented with Clean Architecture principles in mind, offer a powerful antidote to this chaos. However, I've also seen these patterns misapplied—transformed into unnecessary abstraction layers that add complexity without value. This article aims to cut through the noise. We'll explore a pragmatic, experience-driven approach to implementing these patterns in C#, focusing on creating a data access layer that is genuinely clean, testable, and aligned with the core tenets of Clean Architecture as popularized by Robert C. Martin. We'll build a solution that serves the application's domain, not one that exists merely for the sake of pattern compliance.

Understanding Clean Architecture: The Foundational Context

Before we write a single line of repository code, we must understand the architectural soil in which it will grow. Clean Architecture, often visualized as concentric circles, inverts the traditional dependency flow. The core principle is that dependencies point inward. The most central circle, the Domain, contains enterprise logic and entities and has zero dependencies on external concerns like databases or UI frameworks.

The Dependency Inversion Principle in Action

This is where the Dependency Inversion Principle (the 'D' in SOLID) becomes our guiding star. High-level modules (our domain/application logic) should not depend on low-level modules (like Entity Framework Core). Both should depend on abstractions. In practice, this means our application core will define interfaces for repositories. The data access layer in the infrastructure circle will then provide the concrete implementations. This abstraction is what grants us independence from a specific ORM. I've successfully migrated projects from Entity Framework 6 to EF Core, and later to Dapper for specific performance-critical paths, with minimal disruption to the business logic, precisely because of this interface-based design.

Layers vs. Circles: A Practical Interpretation

While the theory speaks of circles, in a Visual Studio solution, we deal with projects (layers). A typical structure includes: a Domain project (entities, core interfaces), an Application project (use cases, DTOs, interface contracts), an Infrastructure project (data access, external API implementations), and a Presentation project (API controllers or UI). The key is ensuring the Domain and Application projects have no reference to Infrastructure. The Infrastructure project references the Application/Domain to implement their interfaces. This physical separation enforces the architectural rule.

Demystifying the Repository Pattern: Beyond a Simple Wrapper

The Repository pattern is often misunderstood as a mere wrapper around an ORM's DbSet. In my view, that's a missed opportunity. A true repository acts as a collection of domain objects in memory, abstracting away the mechanics of persistence. Clients work with the repository interface as if they were querying an in-memory list, while the infrastructure handles SQL, caching, or network calls.

The Core Purpose: Abstraction and Mediation

The primary value isn't just in hiding EF Core. It's in creating a single, managed point of contact between the domain and the data map. This mediation allows you to apply centralized policies: logging, caching, access control, or even soft-delete filtering. For instance, in a multi-tenant application, I've implemented a repository that automatically appends a TenantId filter to every query, ensuring data isolation without a single line of tenant-checking code in hundreds of use cases.

What a Repository Is Not

It's crucial to understand the boundaries. A repository should not leak persistence concerns. It should not return IQueryable<T> broadly, as this allows LINQ queries from the application layer to 'leak' into the infrastructure, breaking the abstraction and making the data layer contract unpredictable. The repository should return concrete results (IEnumerable<T>, List<T>, or a single entity) or execute clearly defined commands. It is also not a place for complex, multi-entity business transactions—that's the job of the Unit of Work.

Introducing the Unit of Work Pattern: Coordinating Transactions

If a repository manages individual entity collections, the Unit of Work (UoW) pattern manages the database transaction as a whole. It coordinates the work of multiple repositories, ensuring they all share the same database context (and thus the same connection and transaction). The most critical responsibility of the UoW is to provide a single Commit or SaveChangesAsync method that persists all changes made across all repositories in a single atomic transaction.

The Atomic Operation Guarantee

Imagine a banking 'Transfer Funds' use case. You debit one account (update Repository A) and credit another (update Repository B). Both operations must succeed or fail together. A naive implementation with two separate SaveChanges calls risks leaving the system in an inconsistent state. The UoW pattern solves this. Both repository operations are performed against the same shared context, and only one call to _unitOfWork.CommitAsync() finalizes the entire transaction. This atomicity is non-negotiable for data integrity.

Sharing the Context: The Glue That Binds Repositories

In Entity Framework Core, the DbContext is the natural Unit of Work. Our UoW interface will essentially abstract this context, providing access to repositories and a commit method. The concrete UoW implementation in Infrastructure will hold the DbContext instance and ensure each repository it dispenses uses that same instance. This design prevents the common anti-pattern of each repository creating its own isolated context, which makes transaction management impossible.

Defining the Core Contracts: Interfaces in the Domain/Application Layer

We start our implementation at the center, with the abstractions. This ensures the application logic is written against stable interfaces, completely unaware of EF Core.

The Generic Repository Interface

We define a generic interface for common CRUD operations. This promotes consistency and reduces boilerplate. Here's a robust example I commonly use:

public interface IRepository<T> where T : class, IAggregateRoot {
Task<T?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<T>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken cancellationToken = default);
Task AddAsync(T entity, CancellationToken cancellationToken = default);
Task AddRangeAsync(IEnumerable<T> entities, CancellationToken cancellationToken = default);
void Update(T entity);
void Remove(T entity);
void RemoveRange(IEnumerable<T> entities);
}

Note the use of IAggregateRoot. This is a key DDD concept. Not every entity should have a public repository—only aggregate roots. This constraint guides proper aggregate design.

The Unit of Work Interface

The UoW interface provides access to repositories and the commit mechanism. It should also implement IDisposable (and IAsyncDisposable) to manage the lifecycle of the underlying context.

public interface IUnitOfWork : IDisposable, IAsyncDisposable {
IRepository<T> GetRepository<T>() where T : class, IAggregateRoot;
Task<int> CommitAsync(CancellationToken cancellationToken = default);
Task RollbackAsync(CancellationToken cancellationToken = default);
}

Implementing the Infrastructure: The EF Core Concrete Layer

With our contracts defined, we move outward to the Infrastructure layer. Here, we create the concrete classes that bridge our abstractions to Entity Framework Core.

The Generic Repository Implementation

The EfRepository<T> class implements IRepository<T>. It accepts a DbContext in its constructor. Crucially, it does not call SaveChanges—that responsibility belongs to the Unit of Work.

public class EfRepository<T> : IRepository<T> where T : class, IAggregateRoot {
protected readonly DbContext _dbContext;
protected readonly DbSet<T> _dbSet;
public EfRepository(DbContext dbContext) {
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
_dbSet = _dbContext.Set<T>();
}
public virtual async Task<T?> GetByIdAsync(int id, CancellationToken ct) => await _dbSet.FindAsync(new object[] { id }, ct);
public virtual async Task AddAsync(T entity, CancellationToken ct) => await _dbSet.AddAsync(entity, ct);
public virtual void Update(T entity) => _dbSet.Update(entity);
// ... other implementations
}

The Unit of Work Implementation

The UnitOfWork class is the heart of the coordination. It instantiates and holds the DbContext, provides a method to retrieve repositories (ensuring they share this context), and exposes the commit method.

public class UnitOfWork : IUnitOfWork {
private readonly ApplicationDbContext _context;
private readonly Dictionary<Type, object> _repositories = new();
public UnitOfWork(ApplicationDbContext context) { _context = context; }
public IRepository<T> GetRepository<T>() where T : class, IAggregateRoot {
if (_repositories.ContainsKey(typeof(T))) return (IRepository<T>)_repositories[typeof(T)];
var repository = new EfRepository<T>(_context);
_repositories.Add(typeof(T), repository);
return repository;
}
public async Task<int> CommitAsync(CancellationToken ct) => await _context.SaveChangesAsync(ct);
public void Dispose() => _context?.Dispose();
// ... Rollback and AsyncDispose implementation
}

Designing for Dependency Injection: Integrating with the Modern .NET Stack

A clean architecture demands clean composition. We use Dependency Injection (DI) to wire up our abstractions to their concrete implementations seamlessly. In your Program.cs or a dedicated composition root in the Infrastructure layer, you register the services.

Service Registration Scoping

The scoping is vital. The DbContext and UnitOfWork should typically be scoped to the web request (or the execution of a single use case/command). This ensures all operations in that request share the same unit of work and transaction.

// In Infrastructure Service Extensions
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) {
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
services.AddScoped<IUnitOfWork, UnitOfWork>();
// Generic repository registration can be done dynamically or via type-specific registrations.
return services;
}

Consuming the Patterns in Application Services

An application service (or mediator handler) now cleanly depends on IUnitOfWork. It never references DbContext or EfRepository directly.

public class TransferFundsCommandHandler : IRequestHandler<TransferFundsCommand>
{
private readonly IUnitOfWork _unitOfWork;
public TransferFundsCommandHandler(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; }
public async Task Handle(TransferFundsCommand command, CancellationToken ct)
{
var accountRepo = _unitOfWork.GetRepository<Account>();
var fromAccount = await accountRepo.GetByIdAsync(command.FromAccountId, ct);
var toAccount = await accountRepo.GetByIdAsync(command.ToAccountId, ct);
// Domain logic to perform transfer...
fromAccount.Debit(command.Amount);
toAccount.Credit(command.Amount);
accountRepo.Update(fromAccount);
accountRepo.Update(toAccount);
await _unitOfWork.CommitAsync(ct); // Single atomic save
}
}

Advanced Considerations and Real-World Nuances

Textbook implementations often miss the gritty details that arise in production. Let's address some of these.

Handling Complex Queries and Specifications

The generic FindAsync(predicate) can be insufficient for complex queries with includes, ordering, and pagination. One powerful solution is the Specification pattern. You can create a ISpecification<T> interface that defines criteria, includes, and ordering, and then extend your repository with a FindAsync(ISpecification<T> spec) method. This keeps complex query logic encapsulated and testable without leaking IQueryable.

Performance and the IQueryable Debate

Some argue for returning IQueryable<T> from repositories to allow the caller to compose queries. I strongly advise against this in the core application flow, as it breaks the abstraction layer—the caller becomes coupled to EF Core's query capabilities. However, for specific, complex reporting scenarios that are purely read-only and performance-critical, I sometimes create separate, dedicated IQueryRepository interfaces that explicitly return IQueryable. This is a conscious, bounded breach of the pattern for a specific need, not the default.

Testing Strategies: Unleashing the Power of Isolation

The primary technical benefit of these patterns is testability. Your application logic can be unit tested without a database.

Mocking the Unit of Work and Repositories

Using a library like Moq or NSubstitute, you can create mocks of IUnitOfWork and IRepository<T> with minimal effort. Your tests arrange by setting up the mock repository to return predefined test data when specific methods are called. They act by invoking the application service. They assert by verifying that the correct repository methods (Add, Update, etc.) were called with the expected arguments, and that CommitAsync was invoked. This is fast, isolated, and deterministic.

Integration Testing the Infrastructure

While unit tests cover the domain logic, you still need integration tests for the concrete EfRepository and UnitOfWork. These tests run against a real database (often an in-memory SQLite or a locally spun-up test container) to ensure your LINQ queries translate to correct SQL, your mappings work, and your transaction behavior is correct. This two-pronged approach gives comprehensive confidence.

Common Anti-Patterns and Pitfalls to Avoid

Learning from mistakes—my own and others'—is invaluable. Here are traps to sidestep.

The Over-Engineered Generic Repository

Avoid creating a generic repository with dozens of methods. Start with a simple interface (GetById, Add, Update, Remove, Find) and extend it only when a genuine, recurring need arises. YAGNI (You Ain't Gonna Need It) applies strongly here. I've removed bloated generic repositories in favor of leaner ones, resulting in cleaner code.

Leaking Persistence Details Upward

Your domain entities should not have attributes like [Key] or [ForeignKey] from System.ComponentModel.DataAnnotations.Schema. These are persistence concerns. Keep your domain project pure. Use Entity Framework's fluent configuration in the Infrastructure layer to define mappings. This strict separation is a hallmark of a clean architecture.

Conclusion: Building a Maintainable Future

Implementing the Repository and Unit of Work patterns within a Clean Architecture is not about blindly following a recipe. It's a deliberate design choice to prioritize maintainability, testability, and domain focus. The initial setup requires thoughtful effort, but the payoff is immense: business logic that is clear and concentrated, data access that is consistent and controlled, and a codebase that can adapt to changing requirements or even underlying technologies. By defining contracts in the core, implementing them in infrastructure, and wiring them together with dependency injection, you construct a system where the most valuable asset—your business rules—stands independent and proud, ready to be tested and evolved for years to come. Start with the principles, adapt the implementation to your project's specific scale and needs, and always remember: the patterns serve the architecture, not the other way around.

Share this article:

Comments (0)

No comments yet. Be the first to comment!