Unit of Work
The Unit of Work is a design pattern used to track all changes to objects during a business transaction and commit multiple database operations as a single transaction. In MiCake, the unit of work supports both Lazy and Immediate initialization modes to meet different performance and consistency requirements.
What is a Unit of Work
Section titled “What is a Unit of Work”The core responsibilities of a unit of work:
- Track changes: track all changes to objects during a business operation
- Coordinate persistence: commit all changes as a single transaction
- Ensure consistency: guarantee the integrity and consistency of data
- Manage transactions: control the beginning, commit, and rollback of transactions
Basic Usage
Section titled “Basic Usage”Automatic Unit of Work in ASP.NET Core Applications (Recommended)
Section titled “Automatic Unit of Work in ASP.NET Core Applications (Recommended)”When you use the MiCake.AspNetCore module and the MiCakeAspNetUowOptions.EnableAutoUnitOfWork option is enabled (defaults to true), a unit of work is created automatically at the beginning of every HTTP request and committed or rolled back automatically at the end of the request. You don’t need to worry about the unit of work lifecycle.
// Startup.cs or Program.cs - automatic unit of work is enabled by defaultservices.AddMiCakeWithDefault<MyModule, MyDbContext>();
// You can configure it manually to disable the automatic unit of workservices.AddMiCakeWithDefault<MyModule, MyDbContext>(options =>{ options.AspNetConfig = asp => { asp.UnitOfWork.EnableAutoUnitOfWork = false; };});Controller example:
public class OrderController : ControllerBase{ private readonly IRepository<Order, int> _orderRepository;
public OrderController(IRepository<Order, int> orderRepository) { _orderRepository = orderRepository; }
// ✅ The unit of work is created, committed, or rolled back automatically [HttpPost] public async Task<IActionResult> CreateOrder(CreateOrderDto dto) { var order = new Order(dto.CustomerId, dto.Items); await _orderRepository.AddAsync(order);
// The UoW is committed automatically after the action succeeds return Ok(order.Id); }}Manual Unit of Work
Section titled “Manual Unit of Work”For non-Web scenarios or cases requiring precise control:
public class OrderService{ private readonly IUnitOfWorkManager _uowManager; private readonly IRepository<Order, int> _orderRepository;
public OrderService( IUnitOfWorkManager uowManager, IRepository<Order, int> orderRepository) { _uowManager = uowManager; _orderRepository = orderRepository; }
public async Task ProcessOrderAsync(int orderId) { // ✅ Use BeginAsync to create a unit of work (recommended) using var uow = await _uowManager.BeginAsync();
var order = await _orderRepository.FindAsync(orderId); order.Process();
// Commit the transaction await uow.CommitAsync(); }}Transaction Initialization Modes
Section titled “Transaction Initialization Modes”MiCake provides two transaction initialization modes: Lazy and Immediate, to meet different performance and consistency requirements.
Lazy Mode (Default)
Section titled “Lazy Mode (Default)”The transaction is actually started at the first database operation:
using var uow = await _uowManager.BeginAsync();// The transaction has not started yet
var order = await _orderRepository.FindAsync(1);// The transaction starts now
await uow.CommitAsync();// Commit the transactionCharacteristics:
- Best performance: the transaction is only opened when needed
- Suitable for: read-heavy/write-light operations, scenarios that may not need a transaction
- Lazy initialization: the transaction is created at the first database operation
Immediate Mode
Section titled “Immediate Mode”Use the UnitOfWorkOptions.Immediate static factory, or set the initialization mode manually:
// Option 1: use the static factory (recommended)using var uow = await _uowManager.BeginAsync(UnitOfWorkOptions.Immediate);// The transaction starts immediately
var order = await _orderRepository.FindAsync(1);// Use the already-started transaction directly
await uow.CommitAsync();// Option 2: set it manuallyvar options = new UnitOfWorkOptions{ InitializationMode = TransactionInitializationMode.Immediate};
using var uow = await _uowManager.BeginAsync(options);Characteristics:
- Consistency guarantee: ensures the transaction exists from the beginning
- Suitable for: critical business operations, scenarios that need explicit transaction boundaries
- Immediate initialization: the transaction is created at Begin time
How to Choose
Section titled “How to Choose”| Scenario | Recommended Mode | Reason |
|---|---|---|
| Regular CRUD operations | Lazy | Better performance; most operations need a transaction |
| Critical business operations | Immediate | Ensure transaction consistency |
| Operations that may roll back | Immediate | Avoid the uncertainty of lazy initialization |
| High-concurrency read operations | Lazy | Reduce unnecessary transaction overhead |
| Distributed transactions | Immediate | Need explicit boundary control |
Controlling Through Attributes
Section titled “Controlling Through Attributes”// Read-only: write operations fail fast[UnitOfWork(IsReadOnly = true)]public class ReadOnlyController : ControllerBase{ // ...}
// Custom isolation level[UnitOfWork(IsolationLevel = IsolationLevel.Serializable)]public class HighConsistencyController : ControllerBase{ // ...}Nested Transactions
Section titled “Nested Transactions”MiCake supports nested units of work - the inner unit of work automatically joins the outer transaction:
public async Task ComplexOperationAsync(){ // Outer unit of work using var outerUow = await _uowManager.BeginAsync();
var order = await _orderRepository.FindAsync(1); order.Update();
// Inner unit of work (nested automatically) using var innerUow = await _uowManager.BeginAsync();
var product = await _productRepository.FindAsync(1); product.DecreaseStock();
await innerUow.CommitAsync(); // Mark the inner one as complete await outerUow.CommitAsync(); // Commit everything together}Nesting rules:
- The inner transaction automatically joins the outer transaction
- Only the outermost unit of work is responsible for the final commit
- If any level fails, the entire transaction is rolled back
- Multiple levels of nesting are supported (it is recommended to keep it under 3 levels)
Declarative Control Through Attributes
Section titled “Declarative Control Through Attributes”Enabling the Unit of Work
Section titled “Enabling the Unit of Work”[UnitOfWork]public class ProductController : ControllerBase{ // A UoW is created automatically for all Actions}Disabling the Unit of Work
Section titled “Disabling the Unit of Work”[DisableUnitOfWork]public class ReportController : ControllerBase{ // A pure query controller - no transaction needed}Or override it at the Action level:
public class MixedController : ControllerBase{ // UoW is enabled by default
[DisableUnitOfWork] public async Task<IActionResult> GetCachedData() { // This Action does not create a UoW }}Custom Isolation Level
Section titled “Custom Isolation Level”[UnitOfWork(IsolationLevel = IsolationLevel.Serializable)]public async Task<IActionResult> HighConsistencyOperation(){ // Use the highest isolation level}Read-Only Operation Optimization
Section titled “Read-Only Operation Optimization”Read-only Action name inference is off by default; enable it explicitly (explicit metadata such as [UnitOfWork(IsReadOnly = true)] always takes precedence):
// Re-enable it if you depend on the old inference behavior:services.AddMiCakeWithDefault<MyModule, MyDbContext>( miCakeAspNetConfig: options => { options.UnitOfWork.EnableReadOnlyActionNameInference = true; options.UnitOfWork.ReadOnlyActionKeywords = ["Get", "Find", "Query", "Search", "List", "Fetch"]; });When enabled, Actions whose names match the keywords are automatically identified as read-only (skipping the transaction commit).
Advanced Scenarios
Section titled “Advanced Scenarios”Disabling the Automatic Unit of Work
Section titled “Disabling the Automatic Unit of Work”services.AddMiCakeWithDefault<MyModule, MyDbContext>( miCakeAspNetConfig: options => { options.UnitOfWork.EnableAutoUnitOfWork = false; });In this case, you need to manage all units of work manually.
Savepoints
Section titled “Savepoints”Create savepoints in long transactions to support partial rollback:
using var uow = await _uowManager.BeginAsync();
// Execute some operationsawait ProcessStep1();
// Create a savepointvar savepoint = await uow.CreateSavepointAsync("step1");
try{ // Execute an operation that may fail await ProcessStep2();}catch{ // Roll back to the savepoint, keeping the changes from step1 await uow.RollbackToSavepointAsync("step1");}
await uow.CommitAsync();Manual Rollback
Section titled “Manual Rollback”using var uow = await _uowManager.BeginAsync();
try{ await ProcessOrder();
if (someCondition) { // Roll back manually await uow.RollbackAsync(); return; }
await uow.CommitAsync();}catch{ // Roll back automatically on exception throw;}Listening to Unit of Work Events
Section titled “Listening to Unit of Work Events”using var uow = await _uowManager.BeginAsync();
uow.OnCommitting += (sender, args) =>{ _logger.LogInformation("UoW {Id} is committing", args.UnitOfWorkId);};
uow.OnCommitted += (sender, args) =>{ _logger.LogInformation("UoW {Id} committed successfully", args.UnitOfWorkId);};
uow.OnRolledBack += (sender, args) =>{ _logger.LogWarning(args.Exception, "UoW {Id} rolled back", args.UnitOfWorkId);};
await ProcessOrder();await uow.CommitAsync();Configuration Options
Section titled “Configuration Options”ASP.NET Core Configuration
Section titled “ASP.NET Core Configuration”services.AddMiCakeWithDefault<MyModule, MyDbContext>( miCakeAspNetConfig: options => { // Enable/disable the automatic unit of work (default: true) options.UnitOfWork.EnableAutoUnitOfWork = true;
// Read-only Action name inference (default: false, opt-in) options.UnitOfWork.EnableReadOnlyActionNameInference = false;
// Read-only Action keywords options.UnitOfWork.ReadOnlyActionKeywords = ["Find", "Get", "Query", "Search"]; });UnitOfWork Options
Section titled “UnitOfWork Options”// Use the static factories (recommended)using var uow = await _uowManager.BeginAsync(UnitOfWorkOptions.Default); // Lazy defaultusing var uow2 = await _uowManager.BeginAsync(UnitOfWorkOptions.Immediate); // Start the transaction immediatelyusing var uow3 = await _uowManager.BeginAsync(UnitOfWorkOptions.ReadOnly); // Read-only// Or set the options manuallyvar options = new UnitOfWorkOptions{ // Isolation level (default: ReadCommitted) IsolationLevel = IsolationLevel.ReadCommitted,
// Initialization mode (default: Lazy) InitializationMode = TransactionInitializationMode.Lazy,
// Whether it is read-only (default: false) IsReadOnly = false};
using var uow = await _uowManager.BeginAsync(options);Best Practices
Section titled “Best Practices”✅ Recommended Approaches
Section titled “✅ Recommended Approaches”- Prefer BeginAsync()
// ✅ Goodusing var uow = await _uowManager.BeginAsync();- Use a using statement to ensure Dispose
// ✅ Goodusing var uow = await _uowManager.BeginAsync();// ... operationsawait uow.CommitAsync();
// ❌ Badvar uow = await _uowManager.BeginAsync();// ... operationsawait uow.CommitAsync();// Forgot to Dispose!- Commit or roll back explicitly
using var uow = await _uowManager.BeginAsync();
try{ // ... operations await uow.CommitAsync(); // ✅ Commit explicitly}catch{ // A warning is logged on Dispose throw;}- Use attributes sensibly
// ✅ Declare at the Controller level to reduce repetition[UnitOfWork]public class OrderController : ControllerBase { }
// ✅ Override special cases at the Action level[DisableUnitOfWork]public async Task<IActionResult> GetCachedData() { }❌ Anti-Patterns
Section titled “❌ Anti-Patterns”- Do not create multiple UoWs in a loop
// ❌ Badforeach (var order in orders){ using var uow = await _uowManager.BeginAsync(); await ProcessOrder(order); await uow.CommitAsync();}
// ✅ Goodusing var uow = await _uowManager.BeginAsync();foreach (var order in orders){ await ProcessOrder(order);}await uow.CommitAsync();- Do not use a Repository outside of a UoW
// ❌ Bad: operations outside a UoW get no transaction/rollback/lifecycle guaranteesvar order = await _orderRepository.FindAsync(1); // No UoW context
// ✅ Goodusing var uow = await _uowManager.BeginAsync();var order = await _orderRepository.FindAsync(1);// ... operationsawait uow.CommitAsync();- Avoid overly long transactions
// ❌ Badusing var uow = await _uowManager.BeginAsync();await DoLotsOfWork(); // A 10-minute operationawait DoMoreWork();await uow.CommitAsync();
// ✅ Good - split the long operationawait DoLotsOfWork(); // Not in a transaction
using var uow = await _uowManager.BeginAsync();await DoCriticalWork(); // Only the critical part is in the transactionawait uow.CommitAsync();Common Mistakes
Section titled “Common Mistakes”❌ “Unit of Work Not Completed” Warning
Section titled “❌ “Unit of Work Not Completed” Warning”// Mistake: neither committed nor rolled back explicitlyusing var uow = await _uowManager.BeginAsync();await ProcessOrder();// Forgot to call CommitAsync() or RollbackAsync()// A warning is logged on Dispose: "UnitOfWork disposed without being completed"✅ Correct Handling
Section titled “✅ Correct Handling”using var uow = await _uowManager.BeginAsync();
try{ await ProcessOrder(); await uow.CommitAsync(); // ✅ Commit explicitly}catch (Exception ex){ await uow.RollbackAsync(); // ✅ Roll back explicitly throw;}❌ Misusing Nested Transactions
Section titled “❌ Misusing Nested Transactions”// Mistake: BeginAsync does not accept a requiresNew parameterusing var outerUow = await _uowManager.BeginAsync();using var innerUow = await _uowManager.BeginAsync(requiresNew: true); // ❌ Compile errorFor a truly independent transaction, use the isolated callback execution ExecuteRequiresNewAsync (creates a separate DI scope; commits automatically on success, rolls back on failure):
await _uowManager.ExecuteRequiresNewAsync(async (sp, ct) =>{ // Resolve services from the callback's provider (capturing outer scoped services fails the ownership check) var repo = sp.GetRequiredService<IRepository<Order, int>>(); await repo.AddAsync(order);});✅ Correct Nesting
Section titled “✅ Correct Nesting”// Correct: the inner one nests automatically within the outer transactionusing var outerUow = await _uowManager.BeginAsync();using var innerUow = await _uowManager.BeginAsync();// The inner one is nested automatically within the outer transaction; a nested CommitAsync only marks completion, the physical commit happens at the root UoWawait innerUow.CommitAsync();await outerUow.CommitAsync();Summary
Section titled “Summary”The unit of work is the core of transaction management in MiCake. In the framework:
- ✅ Lazy and Immediate modes - meet different performance and consistency requirements
- ✅ Nested transaction support - flexible transaction composition
- ✅ Declarative control - attributes simplify configuration
- ✅ Automatic management - ASP.NET Core integration
By using units of work sensibly, you can:
- Ensure data consistency
- Simplify transaction management
- Improve code maintainability
- Optimize application performance
Next steps:
- Learn about Repositories to understand data access
- Read about Domain Events to understand event handling
- Check out Aggregate Roots to understand aggregate boundaries
