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)
Isolated Execution and Background Jobs
Section titled “Isolated Execution and Background Jobs”By default BeginAsync returns a shared nested instance when an ambient unit of work exists (commit is delegated to the root). Some scenarios need a truly independent transaction: each write block commits on its own, and one block’s failure must not roll back or poison the others. MiCake offers three isolated entries; choose by whether the call site knows its own host context:
| Intent | Ambient at the call site | Entry |
|---|---|---|
| Own a transaction boundary (host edge, long-running flow) | any | BeginAsync (shared nested when an ambient UoW exists) |
| Isolated committed write block | unknown — service shared by requests and background jobs | ExecuteIsolatedAsync (adapts to either context) |
| Isolated committed write block | always present; misuse must fail fast | ExecuteRequiresNewAsync (strict) |
| Isolated committed write block | never present; misuse must fail fast | IStandaloneUnitOfWorkExecutor (strict) |
The two strict entries are wiring self-check variants of ExecuteIsolatedAsync: when their precondition is violated they throw a diagnosable exception immediately instead of leaving the problem to runtime. Without an ambient UoW, ExecuteRequiresNewAsync’s exception message names all three remediation paths (establish a boundary frame with BeginAsync / switch to ExecuteIsolatedAsync / use IStandaloneUnitOfWorkExecutor).
Context-Agnostic Isolated Execution (ExecuteIsolatedAsync)
Section titled “Context-Agnostic Isolated Execution (ExecuteIsolatedAsync)”ExecuteIsolatedAsync always creates an independent root unit of work in a fully isolated DI scope, regardless of any ambient UoW:
// The same code works in a controller action, a domain service, or a background jobawait _uowManager.ExecuteIsolatedAsync(async (sp, ct) =>{ // Resolve repositories/DbContexts from the callback's provider (capturing outer scoped services fails the ownership check) var repo = sp.GetRequiredService<IRepository<Order, int>>(); await repo.AddAsync(order, ct); // Commits automatically on success; rolls back on failure});Semantics:
- With an ambient UoW: the outer frame is suspended and restored on every exit path (success, failure, cancellation, commit failure, rollback failure);
- Without one: it runs self-contained and restoration is a no-op;
- An inner commit never persists the outer’s uncommitted changes, and an inner rollback never affects the outer’s tracked state;
- The original exception is preserved on failure; if rollback also fails, a
UnitOfWorkBoundaryExceptioncombining both causes is thrown.
Strict Entries
Section titled “Strict Entries”Use the strict entries when you want a wiring self-check that fails fast on misuse:
// Requires an ambient UoW: for isolated inner blocks that are definitely inside a request/known boundaryawait _uowManager.ExecuteRequiresNewAsync(async (sp, ct) =>{ var repo = sp.GetRequiredService<IRepository<Order, int>>(); await repo.AddAsync(order, ct);});
// Requires no ambient UoW: for independent operations with no request contextvar executor = sp.GetRequiredService<IStandaloneUnitOfWorkExecutor>();await executor.ExecuteAsync(async (isolatedProvider, ct) =>{ var repo = isolatedProvider.GetRequiredService<IRepository<Order, int>>(); await repo.AddAsync(order, ct);});Background Jobs and Non-HTTP Hosts
Section titled “Background Jobs and Non-HTTP Hosts”ASP.NET Core requests get a UoW boundary automatically through the filter; Hangfire, console apps, and custom workers have no equivalent integration, so the host edge must create the frame itself. The example below is an async job base class:
Async job base class (no sync-over-async)
public abstract class UnitOfWorkJobBase{ private readonly IUnitOfWorkManager _unitOfWorkManager;
protected UnitOfWorkJobBase(IUnitOfWorkManager unitOfWorkManager) => _unitOfWorkManager = unitOfWorkManager;
public async Task ExecuteJobAsync(Func<CancellationToken, Task> body, CancellationToken cancellationToken) { // The boundary frame is read-only by default: long-running jobs never extend a write transaction await using var uow = await _unitOfWorkManager.BeginAsync(UnitOfWorkOptions.ReadOnly, cancellationToken); await body(cancellationToken); // write blocks inside use ExecuteIsolatedAsync await uow.CommitAsync(cancellationToken); }}Notes
- Ambient frames are backed by
AsyncLocal: they must be created in the synchronous segment of the frame-owning method (the same premise as the ASP.NET filter); do not create frames insideTask.Run, timers, or fire-and-forget continuations. - Opt out: notification-only jobs need no boundary — simply do not wrap the job body.
- Cancellation: a cancelled write block rolls back its own UoW and preserves the
OperationCanceledException; the boundary frame still rolls back and is released. - Retries: every attempt gets a fresh DI scope and DbContext, so retries start from clean state; the boundary rollback is usually empty because write blocks are individually committed and idempotent.
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 isolated callback execution. For entry-agnostic services (shared by requests and background jobs), you can use the context-agnostic ExecuteIsolatedAsync; use the strict ExecuteRequiresNewAsync (requires an ambient UoW) when you want a wiring self-check that fails fast on misuse:
await _uowManager.ExecuteIsolatedAsync(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, ct);});✅ 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
- ✅ Isolated execution - independently committed write blocks (
ExecuteIsolatedAsyncplus the strict entries) for background jobs and other hosts without a request boundary
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
