Skip to content

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.

The core responsibilities of a unit of work:

  1. Track changes: track all changes to objects during a business operation
  2. Coordinate persistence: commit all changes as a single transaction
  3. Ensure consistency: guarantee the integrity and consistency of data
  4. Manage transactions: control the beginning, commit, and rollback of transactions
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 default
services.AddMiCakeWithDefault<MyModule, MyDbContext>();
// You can configure it manually to disable the automatic unit of work
services.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);
}
}

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();
}
}

MiCake provides two transaction initialization modes: Lazy and Immediate, to meet different performance and consistency requirements.

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 transaction

Characteristics:

  • 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

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 manually
var 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
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
// Read-only: write operations fail fast
[UnitOfWork(IsReadOnly = true)]
public class ReadOnlyController : ControllerBase
{
// ...
}
// Custom isolation level
[UnitOfWork(IsolationLevel = IsolationLevel.Serializable)]
public class HighConsistencyController : ControllerBase
{
// ...
}

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)

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 job
await _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 UnitOfWorkBoundaryException combining both causes is thrown.

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 boundary
await _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 context
var executor = sp.GetRequiredService<IStandaloneUnitOfWorkExecutor>();
await executor.ExecuteAsync(async (isolatedProvider, ct) =>
{
var repo = isolatedProvider.GetRequiredService<IRepository<Order, int>>();
await repo.AddAsync(order, ct);
});

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 inside Task.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.
[UnitOfWork]
public class ProductController : ControllerBase
{
// A UoW is created automatically for all Actions
}
[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
}
}
[UnitOfWork(IsolationLevel = IsolationLevel.Serializable)]
public async Task<IActionResult> HighConsistencyOperation()
{
// Use the highest isolation level
}

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

services.AddMiCakeWithDefault<MyModule, MyDbContext>(
miCakeAspNetConfig: options =>
{
options.UnitOfWork.EnableAutoUnitOfWork = false;
});

In this case, you need to manage all units of work manually.

Create savepoints in long transactions to support partial rollback:

using var uow = await _uowManager.BeginAsync();
// Execute some operations
await ProcessStep1();
// Create a savepoint
var 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();
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;
}
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();
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"];
});
// Use the static factories (recommended)
using var uow = await _uowManager.BeginAsync(UnitOfWorkOptions.Default); // Lazy default
using var uow2 = await _uowManager.BeginAsync(UnitOfWorkOptions.Immediate); // Start the transaction immediately
using var uow3 = await _uowManager.BeginAsync(UnitOfWorkOptions.ReadOnly); // Read-only
// Or set the options manually
var 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);
  1. Prefer BeginAsync()
// ✅ Good
using var uow = await _uowManager.BeginAsync();
  1. Use a using statement to ensure Dispose
// ✅ Good
using var uow = await _uowManager.BeginAsync();
// ... operations
await uow.CommitAsync();
// ❌ Bad
var uow = await _uowManager.BeginAsync();
// ... operations
await uow.CommitAsync();
// Forgot to Dispose!
  1. 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;
}
  1. 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() { }
  1. Do not create multiple UoWs in a loop
// ❌ Bad
foreach (var order in orders)
{
using var uow = await _uowManager.BeginAsync();
await ProcessOrder(order);
await uow.CommitAsync();
}
// ✅ Good
using var uow = await _uowManager.BeginAsync();
foreach (var order in orders)
{
await ProcessOrder(order);
}
await uow.CommitAsync();
  1. Do not use a Repository outside of a UoW
// ❌ Bad: operations outside a UoW get no transaction/rollback/lifecycle guarantees
var order = await _orderRepository.FindAsync(1); // No UoW context
// ✅ Good
using var uow = await _uowManager.BeginAsync();
var order = await _orderRepository.FindAsync(1);
// ... operations
await uow.CommitAsync();
  1. Avoid overly long transactions
// ❌ Bad
using var uow = await _uowManager.BeginAsync();
await DoLotsOfWork(); // A 10-minute operation
await DoMoreWork();
await uow.CommitAsync();
// ✅ Good - split the long operation
await DoLotsOfWork(); // Not in a transaction
using var uow = await _uowManager.BeginAsync();
await DoCriticalWork(); // Only the critical part is in the transaction
await uow.CommitAsync();

❌ “Unit of Work Not Completed” Warning

Section titled “❌ “Unit of Work Not Completed” Warning”
// Mistake: neither committed nor rolled back explicitly
using var uow = await _uowManager.BeginAsync();
await ProcessOrder();
// Forgot to call CommitAsync() or RollbackAsync()
// A warning is logged on Dispose: "UnitOfWork disposed without being completed"
using var uow = await _uowManager.BeginAsync();
try
{
await ProcessOrder();
await uow.CommitAsync(); // ✅ Commit explicitly
}
catch (Exception ex)
{
await uow.RollbackAsync(); // ✅ Roll back explicitly
throw;
}
// Mistake: BeginAsync does not accept a requiresNew parameter
using var outerUow = await _uowManager.BeginAsync();
using var innerUow = await _uowManager.BeginAsync(requiresNew: true); // ❌ Compile error

For 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: the inner one nests automatically within the outer transaction
using 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 UoW
await innerUow.CommitAsync();
await outerUow.CommitAsync();

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 (ExecuteIsolatedAsync plus 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: