Unit of Work: Redundant or Absolutely Essential? Debunking the .NET Architectural Debate
Hello everyone, I’m Hoang, the owner of CleanBase – a .NET package with nearly 20k downloads on NuGet Gallery | hoangvh238
CleanBase helps you write clean, Clean Architecture-compliant code, and the Unit of Work (UoW) and Repository patterns are the indispensable backbone. In this article, we’ll dissect the biggest debate in the .NET community: Is the Unit of Work pattern still necessary when DI already handles DbContext sharing?
1. Introduction: The Clash Between Technique and Architecture
The Shocking Confession
I have this friend, born in ’96, studied Mechanical Engineering at Bach Khoa. He once said to me:
“Hoang, I think Unit of Work is obsolete! With modern EF Core and DI, I just inject a shared DbContext into my Repositories and everything commits automatically!”
I laughed and said, “Well, technically… you’re half-right =)))”
The truth is, Scoped DI in .NET ensures that all Repositories within the same HTTP request share the same DbContext instance. That means if you call _context.SaveChangesAsync() anywhere in that scope, all changes are committed.
So then… why would we still bother adding a UnitOfWork class?
The Core Issue: Violation of the Single Responsibility Principle (SRP)
Relying solely on this technical shortcut to manage transactions is a serious architectural flaw. It violates the Single Responsibility Principle (SRP) by mixing the responsibilities of Data Access and Transaction Management within the same layer.
As Robert C. Martin (Uncle Bob) famously said:
“The first rule of clean code is simplicity. The second rule of clean code is abstraction.”
You are forcing the Service Layer or the Repository to bear the burden of Transaction Management (a persistence concern) instead of focusing solely on Business Logic. This is precisely where UoW steps in.
2. Separation of Concerns: Repository vs. Unit of Work
In a clean architecture, each pattern has a clear, non-overlapping responsibility:
A. Repository: Specializing in Data Access
The Repository’s sole responsibility is to query, add, update, and delete Entities, it marks the changes. A Repository should never decide when those changes are saved to the database.
- Rule: The Repository only calls methods like
_dbSet.AddAsync(entity)or_dbSet.Remove(entity). - The Flaw: If the Repository calls
SaveChanges, it breaks the atomicity of larger Business Transactions that involve multiple entities across different Repos.
B. Unit of Work: The Transaction Gatekeeper
The Unit of Work is the single layer of abstraction responsible for deciding when to Commit or Rollback a sequence of marked changes.
| Layer | Ideal Responsibility (SRP) | Role of UoW |
| Repository | Query/Modify Entities. | Provides unsaved operations. |
| Service Layer | Contains Business Logic. | Delegates responsibility for transaction management. |
| Unit of Work | Manages the lifecycle and Commits the entire transaction. | Ensures Atomicity (all or nothing). |
3. Standard Implementation: Transparency and Testability
To make UoW function as a perfect abstraction, we must use Constructor Injection for all dependencies.
A. UoW and Constructor Injection
The UoW receives its dependencies (DbContext and all Repositories) via its constructor. This ensures transparency of dependencies and atomicity, as all components share the same Scoped DbContext instance.
C#
public class UnitOfWork : IUnitOfWork
{
private readonly DbContext _context;
// UoW receives DbContext and Repositories via Constructor Injection
public UnitOfWork(DbContext context, IUserRepository userRepository, IOrderRepository orderRepository)
{
_context = context;
UserRepository = userRepository;
OrderRepository = orderRepository;
}
// ...
public async Task<int> CompleteAsync()
{
// **SOLE RESPONSIBILITY:** Execute the atomic transaction
return await _context.SaveChangesAsync();
}
}
B. Service Layer: Where Abstraction Shines
The Service Layer depends only on IUnitOfWork (a pure interface), not on the concrete DbContext class.
C#
public class UserService
{
private readonly IUnitOfWork _unitOfWork;
// Depends on IUnitOfWork, NOT DbContext
public UserService(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; }
public async Task CreateUserAndOrderAsync(UserDto userDto, OrderDto orderDto)
{
// Perform operations via UoW (marking changes)
await _unitOfWork.UserRepository.AddAsync(user);
await _unitOfWork.OrderRepository.AddAsync(order);
// Delegate responsibility: Request UoW to finalize the transaction
await _unitOfWork.CompleteAsync();
}
}
The Greatest Benefit:
- Testability: When testing
UserService, you only need to MockIUnitOfWork. The business logic becomes completely decoupled from the data layer. - Extensibility: If you swap the ORM (e.g., from EF Core to Dapper), you only rewrite the UoW implementation, while the Service Layer remains untouched.
4. Conclusion: Choosing Architectural Integrity
The argument that “DI is enough” is a perfect example of prioritizing technical convenience over architectural integrity.
The Unit of Work pattern is essential because:
- It enforces the Single Responsibility Principle (SRP) rigorously.
- It provides the Abstraction necessary to shield the Service Layer from concrete ORM technology.
- It maintains absolute Data Integrity by managing atomic transactions.
Build your application with Architectural Steel, where every layer adheres to its prescribed responsibility. Feel free to explore and use CleanBase to see the Unit of Work pattern implemented effectively.