The Unit of Work Pattern with a DbContext?
In modern software development, applications rarely interact with just a single data source. We often find ourselves juggling between SQL databases, NoSQL solutions, legacy systems, and external APIs. As this complexity grows, maintaining data consistency across these diverse sources becomes increasingly challenging.
How this can be helpful
In modern software development, applications rarely interact with just a single data source. We often find ourselves juggling between SQL databases, NoSQL solutions, legacy systems, and external APIs. As this complexity grows, maintaining data consistency across these diverse sources becomes increasingly challenging.
Enter the Unit of Work pattern - a powerful design pattern that helps coordinate operations across multiple data sources while preserving the integrity of your domain model.
Understanding the Unit of Work Pattern
At its core, the Unit of Work pattern tracks changes to objects within a business transaction and coordinates the writing of those changes. It ensures that either all operations succeed, or none do. Yes EFCore implements this but keep reading.
Martin Fowler, in "Patterns of Enterprise Application Architecture," describes the Unit of Work as a pattern that "maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems."
Meaning: Unit of Work pattern is like a repository or single place for all your data sources.
It is functioning as a "repository for repositories" or a central coordination point for your data access strategy.
The analogy - just as a repository abstracts data access for a single entity type, the Unit of Work abstracts transaction management across multiple repositories and data sources.
This creates a clear separation of concerns: Your repositories handle the how of data access (SQL queries, API calls, etc.) Your Unit of Work handles the when and what happens if of those operations (transaction boundaries, rollback behavior)
This approach makes your code:
More maintainable: Data access logic is isolated in repositories, while transaction logic is isolated in the UoW More testable: You can mock the UoW interface for testing services without hitting real data sources More flexible: You can swap out data access technologies without changing business logic
It's particularly valuable in enterprise applications where you might be dealing with legacy systems alongside modern ones, or in scenarios where different parts of your domain model are best stored in different types of databases.
The transaction coordination aspect is especially important - it ensures that your domain model remains consistent even when the underlying storage is fragmented across different technologies.
Why Entity Framework's DbContext Isn't Always Enough
Entity Framework Core's DbContext already implements the Unit of Work pattern. It tracks entity changes and commits them as a single transaction when SaveChanges() is called.
For applications working exclusively with a single SQL database through EF Core, the built-in Unit of Work functionality is typically sufficient.
However, this built-in capability becomes insufficient when dealing with:
- Multiple DbContext instances
- A mix of ORM and non-ORM data access (like Dapper)
- NoSQL databases (MongoDB, Redis, etc.)
- External APIs or services
- Legacy systems with their own data access methods
Implementing a Custom Unit of Work for Multiple Data Sources
When your application needs to maintain consistency across these diverse sources, a custom Unit of Work implementation becomes necessary. Let's explore how this might look:
Step 1: Define the Interface
public interface IUnitOfWork : IDisposable
{
// EF Core repositories
IUserRepository Users { get; }
IProductRepository Products { get; }
// Dapper repositories
IReportRepository Reports { get; }
// NoSQL repositories
IDocumentRepository Documents { get; }
// External API repositories
IPaymentGatewayRepository Payments { get; }
// Commit all changes as a single transaction
Task<bool> CommitAsync();
}
Step 2: Implement the Unit of Work
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _efContext;
private readonly IDbConnection _dapperConnection;
private readonly IMongoDatabase _mongoDb;
private readonly IPaymentGatewayClient _paymentClient;
private IDbTransaction _dapperTransaction;
// Repository properties
public IUserRepository Users { get; }
public IProductRepository Products { get; }
public IReportRepository Reports { get; }
public IDocumentRepository Documents { get; }
public IPaymentGatewayRepository Payments { get; }
public UnitOfWork(
AppDbContext efContext,
IDbConnection dapperConnection,
IMongoDatabase mongoDb,
IPaymentGatewayClient paymentClient)
{
_efContext = efContext;
_dapperConnection = dapperConnection;
_mongoDb = mongoDb;
_paymentClient = paymentClient;
// Begin Dapper transaction
_dapperTransaction = _dapperConnection.BeginTransaction();
// Initialize repositories
Users = new UserRepository(_efContext);
Products = new ProductRepository(_efContext);
Reports = new ReportRepository(_dapperConnection, _dapperTransaction);
Documents = new DocumentRepository(_mongoDb);
Payments = new PaymentGatewayRepository(_paymentClient);
}
public async Task<bool> CommitAsync()
{
// Start a MongoDB session/transaction if needed
// var mongoSession = await _mongoDb.Client.StartSessionAsync();
// mongoSession.StartTransaction();
// Begin EF transaction
using var efTransaction = await _efContext.Database.BeginTransactionAsync();
try
{
// Flush EF Core changes into its transaction (not yet committed)
await _efContext.SaveChangesAsync();
// Commit MongoDB changes
// await mongoSession.CommitTransactionAsync();
// Confirm payment gateway changes if needed
// await _paymentClient.ConfirmPendingTransactionsAsync();
// Commit EF first, then Dapper. Ordering matters: whichever commits last can
// still fail after the other has succeeded.
await efTransaction.CommitAsync();
_dapperTransaction.Commit();
return true;
}
catch (Exception ex)
{
// Rollback EF transaction
await efTransaction.RollbackAsync();
// Rollback Dapper transaction
try { _dapperTransaction.Rollback(); } catch { /* Ignore */ }
// Rollback MongoDB transaction
// try { await mongoSession.AbortTransactionAsync(); } catch { /* Ignore */ }
// Reverse payment gateway actions if needed
// try { await _paymentClient.ReversePendingTransactionsAsync(); } catch { /* Ignore */ }
// Log the exception
// _logger.LogError(ex, "Error during transaction commit");
return false;
}
}
public void Dispose()
{
_dapperTransaction?.Dispose();
_efContext?.Dispose();
_dapperConnection?.Dispose();
}
}
Step 3: Use the Unit of Work in Your Services
public class OrderService
{
private readonly IUnitOfWork _unitOfWork;
public OrderService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<bool> PlaceOrderAsync(OrderDto orderDto)
{
// Create order in SQL database
var order = new Order { /* Map from orderDto */ };
await _unitOfWork.Users.EnsureUserExistsAsync(orderDto.UserId);
await _unitOfWork.Products.UpdateInventoryAsync(orderDto.Items);
// Generate report using Dapper
await _unitOfWork.Reports.LogOrderCreatedAsync(order);
// Store order details in MongoDB
await _unitOfWork.Documents.StoreOrderDocumentAsync(orderDto.Document);
// Process payment through payment gateway
await _unitOfWork.Payments.ProcessPaymentAsync(orderDto.Payment);
// Commit both stores. This is best-effort, not atomic: the payment call above has
// already happened, and a failure between the two commits leaves them diverged.
return await _unitOfWork.CommitAsync();
}
}
Challenges and Considerations
While the Unit of Work pattern is powerful, implementing it across multiple data sources comes with challenges:
1. True Atomicity Is Hard
Not all data sources support true transactions. External APIs, in particular, may require compensating transactions to "undo" changes if a failure occurs elsewhere.
2. Performance Implications
Coordinating transactions across multiple data sources can impact performance. Consider if you truly need strong consistency or if eventual consistency would suffice for some operations.
3. Error Handling Complexity
As the number of data sources increases, error handling becomes more complex. You need robust error handling and logging to diagnose issues.
4. Transaction Isolation
Different data sources may have different transaction isolation levels, potentially leading to unexpected behavior.
Best Practices
To implement the Unit of Work pattern effectively with multiple data sources:
Start Simple: Begin with the minimum number of data sources needed, and add complexity gradually.
Consider Domain Boundaries: Not everything needs to be in the same Unit of Work. Use domain boundaries to determine transaction scopes.
Use Idempotent Operations: Where possible, design operations to be idempotent (can be applied multiple times without changing the result).
Log Extensively: Detailed logging is crucial for diagnosing issues in a multi-data-source environment.
Implement Retry Logic: For operations that might fail due to transient issues, consider implementing retry logic.
Test Thoroughly: Test failure scenarios to ensure proper rollback behavior across all data sources.
Conclusion
The Unit of Work pattern shines when coordinating operations across several data sources.
While Entity Framework's DbContext provides built-in Unit of Work functionality, applications dealing with diverse data sources benefit from a custom implementation.
By centralizing transaction management, you can maintain data consistency across your entire application while keeping your business logic clean and focused on the domain problem rather than the intricacies of transaction management.
Remember that with great power comes great responsibility - use the pattern where it adds value, but don't overcomplicate simple scenarios where a single data source would suffice.
References
Fowler, M. (2003). Patterns of Enterprise Application Architecture
Book Info: https://martinfowler.com/books/eaa.html Pattern Catalog: https://martinfowler.com/eaaCatalog/unitOfWork.html
Microsoft Documentation: Entity Framework Core
Official Documentation: https://learn.microsoft.com/en-us/ef/core/ Transactions Guide: https://learn.microsoft.com/en-us/ef/core/saving/transactions
MongoDB Documentation: Transactions
Transactions Introduction: https://www.mongodb.com/docs/manual/core/transactions/ Transactions in .NET: https://www.mongodb.com/docs/drivers/csharp/current/usage-examples/transactions/
Dapper GitHub Repository
Main Repository: https://github.com/DapperLib/Dapper Wiki Documentation: https://github.com/DapperLib/Dapper/wiki
Tags
.NET Full Stack Engineer. 21 years in the Navy, then software.