Back to Blog
| 5 min read | Intermediate

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:

  1. Multiple DbContext instances
  2. A mix of ORM and non-ORM data access (like Dapper)
  3. NoSQL databases (MongoDB, Redis, etc.)
  4. External APIs or services
  5. 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:

  1. Start Simple: Begin with the minimum number of data sources needed, and add complexity gradually.

  2. Consider Domain Boundaries: Not everything needs to be in the same Unit of Work. Use domain boundaries to determine transaction scopes.

  3. Use Idempotent Operations: Where possible, design operations to be idempotent (can be applied multiple times without changing the result).

  4. Log Extensively: Detailed logging is crucial for diagnosing issues in a multi-data-source environment.

  5. Implement Retry Logic: For operations that might fail due to transient issues, consider implementing retry logic.

  6. 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

#Software Engineering #Backend #Advanced
Casey Spaulding
Casey Spaulding

.NET Full Stack Engineer. 21 years in the Navy, then software.

Ask Casey AI

Ask Me Anything

Hi! I'm Casey's AI assistant. Ask me anything about his work, skills, or projects!

10 messages remaining