Skip to content

v10 → v11 Migration Guide

This guide helps you upgrade your MiCake application from v10 to v11. v11 establishes the Unit of Work as the single persistence owner and switches to automatic EF Core interceptor installation, with breaking changes across four areas: repository API, UoW API, the ASP.NET Core boundary, and EF Core integration.

Work through the following changes item by item after upgrading:

  1. Removal of IRepository.SaveChangesAsync
  2. Removal of AddAndReturnAsync and the saveNow parameter
  3. Removal of ClearChangeTrackingAsync
  4. UpdateAsync semantics: full replacement
  5. DeleteByIdAsync semantics: tracked delete
  6. requiresNew replaced by callback execution
  7. Removal of PersistenceStrategy / OptimizeForSingleWrite
  8. Removal of Timeout
  9. IDbContextWrapper replaced by IUnitOfWorkResource
  10. New capabilities: FlushAsync, savepoints, event hooks
  11. Slimmed-down UnitOfWorkAttribute
  12. IsUowEnabled replaced by [DisableUnitOfWork]
  13. Read-only action name inference is now opt-in
  14. Context factory interface consolidation
  15. BypassUnitOfWorkCheck renamed
  16. Automatic interceptor installation (UseMiCakeInterceptors removed)
  17. Behavioral changes

To migrate quickly, jump straight to the migration checklist.

Repository methods no longer save or commit by themselves. They only modify the UoW tracking state; persistence is owned entirely by the UoW. Replace every repo.SaveChangesAsync() with CommitAsync() on the ambient UoW:

// Before
await _bookRepository.AddAsync(book);
await _bookRepository.SaveChangesAsync(); // Competing persistence boundary
// After
await _bookRepository.AddAsync(book);
await _unitOfWork.CommitAsync(); // Commit at the UoW boundary

1.2 Removal of AddAndReturnAsync and saveNow

Section titled “1.2 Removal of AddAndReturnAsync and saveNow”

For code that used AddAndReturnAsync, migrate to AddAndGetIdAsync(...), now on the interface — it preserves the original “add + immediately obtain the database-generated key” semantics: after adding the aggregate root it automatically calls FlushAsync to fill the generated key and returns TKey. The flush happens inside the ambient writable UoW transaction and does not commit; data is persisted only when the UoW commits. It requires an ambient writable UoW, otherwise it throws InvalidOperationException:

// Before
var book = await _bookRepository.AddAndReturnAsync(new Book { Title = "x" });
// After (recommended): semantically equivalent, returns the generated key
var id = await _bookRepository.AddAndGetIdAsync(new Book { Title = "x" }); // Now on IRepository
await _unitOfWork.CommitAsync(); // Data is persisted after the commit

In interface-injection scenarios, AddAsync + IUnitOfWork.FlushAsync() is equivalent:

var book = new Book { Title = "x" };
await _bookRepository.AddAsync(book);
await _unitOfWork.FlushAsync(); // Generated key filled into book.Id; transaction not committed
// ...continue, then finally CommitAsync()

The saveNow parameter of AddAsync(..., saveNow: true) is removed as well; handle it the same way.

No longer provided. Clearing change tracking is an EF Core concern; detach entries directly through the DbContext when needed.

UpdateAsync on an untracked instance is now a full detached aggregate replacement: all properties of the instance are written. Configured concurrency tokens are preserved, so stale instances surface conflicts via DbUpdateConcurrencyException instead of being silently overwritten:

// Before: partial update, silently overwrites concurrent changes
await _repo.UpdateAsync(detachedBook);
// After: full replacement; concurrency conflicts throw DbUpdateConcurrencyException at flush/commit
await _repo.UpdateAsync(detachedBook);
await _unitOfWork.CommitAsync();

Note: load-and-modify remains the preferred workflow; concurrency conflicts now throw at FlushAsync / CommitAsync time, not at the UpdateAsync call.

It now loads the aggregate into a stable UoW context first, then performs a tracked delete — soft delete, audit, domain events, and rollback semantics are identical to DeleteAsync:

await _repo.DeleteByIdAsync(id); // Semantics: load → tracked delete → takes effect after UoW commit
await _unitOfWork.CommitAsync();

To bypass the lifecycle and physically delete immediately, use the explicit physical deletion API (see 5.8 Physical/batch operations made explicit).

The requiresNew boolean parameter is replaced by isolated callback execution. ExecuteRequiresNewAsync creates a separate DI scope; the callback must resolve repositories/DbContexts from the passed-in IServiceProvider; commit, rollback, and disposal are handled automatically:

// Before
var uow = await _unitOfWorkManager.BeginAsync(requiresNew: true);
var repo = _bookRepository; // Outer-scope service — wrong
await repo.AddAsync(book);
await uow.CommitAsync();
// After
await _unitOfWorkManager.ExecuteRequiresNewAsync(async (sp, ct) =>
{
var repo = sp.GetRequiredService<IRepository<Book, int>>(); // Resolve from the callback provider
await repo.AddAsync(book);
// Commits automatically on success, rolls back on failure, scope disposed automatically
});

Note: capturing outer scoped services fails the ownership check; calling without an outer UoW throws InvalidOperationException. For scenarios without an ambient UoW such as background jobs, use IStandaloneUnitOfWorkExecutor.

PersistenceStrategy / OptimizeForSingleWrite are removed. Every writable UoW uses an explicit transaction:

// Before
await _uowManager.BeginAsync(new UnitOfWorkOptions { PersistenceStrategy = PersistenceStrategy.OptimizeForSingleWrite });
// After
await _uowManager.BeginAsync(); // Default is fine: Lazy activates an explicit transaction
// To start the transaction early: UnitOfWorkOptions.Immediate

Reason: OptimizeForSingleWrite allowed EF implicit transactions to commit before post-save lifecycle processing finished, which could produce the contradictory result “data persisted but error returned”.

UnitOfWorkOptions.Timeout is removed — it had no runtime effect anyway. Configure timeouts via EF/provider command, lock, and transaction timeout settings.

Replaced by the provider-agnostic IUnitOfWorkResource. This only affects custom persistence provider integrations; regular applications need no migration.

IUnitOfWork now provides:

  • FlushAsync() — activates and flushes all resources in registration order; returns the number of affected rows; does not commit
  • Savepoints — CreateSavepointAsync / RollbackToSavepointAsync / ReleaseSavepointAsync
  • MarkAsCompletedAsync() — skip the commit at a read-only boundary
  • Transaction events — OnCommitting / OnCommitted / OnRollingBack / OnRolledBack
  • IAsyncDisposable
// Savepoint example: partial rollback inside a transaction
await _uow.CreateSavepointAsync("step1");
// ...execute a batch of operations
await _uow.RollbackToSavepointAsync("step1"); // Undo only the changes after this point

InitializationMode and CreateOptions() are removed. The attribute now has only two options:

[UnitOfWork(IsReadOnly = true)] // Read-only: write operations fail fast
[UnitOfWork(IsolationLevel = IsolationLevel.Serializable)]
// Before
[UnitOfWork(IsUowEnabled = false)]
// After
[DisableUnitOfWork]

3.3 Read-only action name inference is now opt-in

Section titled “3.3 Read-only action name inference is now opt-in”

Previously GET actions were inferred as read-only by name; now this is off by default, and explicit metadata always takes precedence:

// Startup.cs — re-enable if you depend on the old inference behavior:
services.Configure<MiCakeAspNetOptions>(o => o.EnableReadOnlyActionNameInference = true);

4.1 Context factory interface consolidation

Section titled “4.1 Context factory interface consolidation”

The non-generic IEFCoreContextFactory, IEFCoreAnchoredContextFactory, and the parameterless GetDbContextWrapper() are consolidated into a single public contract. Custom factory implementations only need to implement two methods:

public class MyFactory<TDbContext> : IEFCoreContextFactory<TDbContext>
where TDbContext : DbContext
{
public TDbContext GetDbContext() { /* ... */ }
public EFCoreDbContextWrapper GetOrCreateWrapperFor(DbContext context) { /* ... */ }
}

The framework invokes your implementation automatically through an adapter — no need to understand the internal view.

MiCakeEFCoreOptions.BypassUnitOfWorkCheck is renamed to AllowDbContextAccessWithoutUoW; the default remains false:

// Before
options.BypassUnitOfWorkCheck = true;
// After
options.AllowDbContextAccessWithoutUoW = true;

Note: this option now only relaxes context resolution (returns a standalone wrapper without UoW integration, for read-only filters/middleware). Writes without a UoW are allowed overall under the Permissive strategy — this option is no longer needed and should not be relied on to allow writes.

All 3 overloads of UseMiCakeInterceptors, IMiCakeInterceptorFactory, and its implementation are removed. Interceptors are mounted automatically by the module’s ConfigureDbContext configurator — just register the DbContext in the container:

// Before
services.AddDbContext<AppDbContext>((sp, opt) =>
{
opt.UseSqlite(connectionString);
opt.UseMiCakeInterceptors(sp); // Removed
});
// After — no interceptor-related calls needed:
services.AddDbContext<AppDbContext>(opt =>
{
opt.UseSqlite(connectionString);
opt.UseMiCake(); // Optional: installs the per-context options extension
});

Requirement: EF Core 9+ (ConfigureDbContext / IDbContextOptionsConfiguration<TContext> mechanism; built into EF Core 10, verified on EF Core 10). MiCakeDbContext subclasses do not need to call UseMiCake()OnConfiguring calls it automatically.

The following changes do not remove APIs, but they change runtime behavior and must be verified after migration.

Direct DbContext writes without a UoW used to be rejected; they are now allowed with native EF Core semantics (implicit transaction, no rollback/lifecycle guarantees). The repository/UoW paths remain guarded:

// Without an ambient UoW:
await dbContext.SaveChangesAsync(); // Allowed, same as native EF (no MiCake guarantees)

For transactional guarantees, open a UoW or use IStandaloneUnitOfWorkExecutor.

5.2 Nested UoW: shared commit / root rollback

Section titled “5.2 Nested UoW: shared commit / root rollback”
  • A nested CommitAsync only marks completion; the physical commit happens at the root UoW
  • A nested RollbackAsync marks the root UoW rollback-only; the root rollback covers all resources

5.3 Multi-resource commit: best-effort + structured results

Section titled “5.3 Multi-resource commit: best-effort + structured results”

Multi-resource commits execute deterministically in registration order; partial failures throw PartialUnitOfWorkCommitException carrying per-resource structured, non-sensitive results (ID, type, status). For cross-resource atomicity, choose an outbox or compensation workflow yourself.

5.4 Rollback/cleanup failures are no longer lost

Section titled “5.4 Rollback/cleanup failures are no longer lost”

Rollback or cleanup failures throw UnitOfWorkBoundaryException carrying the primary exception + rollback failure + cleanup failure for easy diagnosis.

5.5 SaveChanges reentry: bounded state machine

Section titled “5.5 SaveChanges reentry: bounded state machine”

Recursive SaveChangesAsync calls inside lifecycle/domain-event handlers are no longer raw recursion: nested saves are merged into save cycles within the same transaction, and events are deduplicated per instance. When there is no progress or the MaxSaveCycles limit (default 16) is reached, SaveChangesReentryException is thrown and the UoW is marked rollback-only:

// MiCakeEFCoreOptions
options.MaxSaveCycles = 32; // Adjust the upper bound as needed

Paging now requires a total order: without caller-provided ordering, every primary key property is appended ascending; with ordering, missing primary key properties are appended as a final ascending ThenBy; keyless entities are rejected. Call sites relying on the old implicit order should provide explicit ordering.

5.7 Startup validation: DbContext lifetime and execution strategy

Section titled “5.7 Startup validation: DbContext lifetime and execution strategy”
  • DbContext must be registered as scoped or pooled; singleton/transient fails at startup with guidance
  • Execution strategies with RetriesOnFailure = true are rejected for ambient writable UoWs (an application-owned replayable boundary, such as a dedicated retry executor, is required)

5.8 Physical/batch operations made explicit

Section titled “5.8 Physical/batch operations made explicit”

Deletions that bypass the aggregate lifecycle use an explicit API, which requires an ambient writable UoW and stays inside the UoW transaction:

// Before: physical delete directly inside the repository
// After: explicit physical operation executor
var executor = sp.GetRequiredService<IEFCorePhysicalOperationExecutor<AppDbContext>>();
await executor.ExecuteDeleteAsync<Book>(b => b.PublishedYear < 2000);

Using EF ExecuteUpdate / ExecuteDelete / ExecuteSqlRaw directly: guarded and bound to the transaction inside a UoW; allowed with native semantics outside a UoW.

  • SaveChangesAsync()IUnitOfWork.CommitAsync()
  • AddAndReturnAsync(x)AddAndGetIdAsync(x) (recommended, now on IRepository); AddAsync(x) + FlushAsync() is also equivalent
  • Remove the saveNow: parameter from AddAsync
  • Remove ClearChangeTrackingAsync() calls
  • Switch immediate physical-delete scenarios to IEFCorePhysicalOperationExecutor<TDbContext>
  • BeginAsync(requiresNew: true)ExecuteRequiresNewAsync(...), resolving all services from the callback provider
  • Remove PersistenceStrategy / OptimizeForSingleWrite / Timeout usage
  • Switch background jobs/no-ambient-UoW operations to IStandaloneUnitOfWorkExecutor.ExecuteAsync(...)
  • Ensure created UoWs are disposed (async disposal supported; the ASP.NET Core boundary handles it automatically)
  • Remove InitializationMode / CreateOptions() / IsUowEnabled from [UnitOfWork]
  • [UnitOfWork(IsUowEnabled = false)][DisableUnitOfWork]
  • Set EnableReadOnlyActionNameInference = true if you rely on GET read-only inference
  • Remove all UseMiCakeInterceptors(...) calls and custom IMiCakeInterceptorFactory
  • Make sure DbContext is registered as scoped or pooled
  • BypassUnitOfWorkCheckAllowDbContextAccessWithoutUoW (read-only scenarios only)
  • Do not register a custom IUnitOfWorkAmbientAccessor (framework singleton)
  • Keep exactly one UseEFCore<TDbContext>() / AddUowCoreServices call per context type
  • Review direct DbContext writes without a UoW — they are now allowed; wrap them in a UoW when guarantees are needed
  • Check paging call sites relying on implicit order and provide explicit ordering
  • Check UpdateAsync call sites: concurrency conflicts now throw at flush/commit
  • Check handlers that recursively call SaveChangesAsync: a no-progress loop throws SaveChangesReentryException
API Purpose
IUnitOfWork.FlushAsync() Activates the transaction and flushes all resources in registration order; returns affected rows; no commit
IRepository<TAggregateRoot, TKey>.AddAndGetIdAsync(...) Recommended migration target for AddAndReturnAsync: add + FlushAsync to fill the generated key and return TKey (requires an ambient writable UoW)
IUnitOfWorkManager.ExecuteRequiresNewAsync(...) Isolated inner boundary (replaces requiresNew)
IStandaloneUnitOfWorkExecutor Isolated scope execution without an ambient UoW; commits on success, rolls back on failure
IUnitOfWorkResource Provider-agnostic resource contract (replaces IDbContextWrapper)
Savepoint trio Partial rollback inside a transaction; resources registered after creation are rejected
IEFCorePhysicalOperationExecutor<TDbContext> Explicit physical delete (bypasses the aggregate lifecycle)
UnitOfWorkBoundaryException Primary exception + rollback/cleanup failures combined
PartialUnitOfWorkCommitException Structured results of best-effort multi-resource commits
SaveChangesReentryException No-progress / MaxSaveCycles reentry failure
UseMiCake() (options builder) EF Core integration entry point that only installs options
[DisableUnitOfWork] Opt out of the ASP.NET UoW boundary for an action/controller
MiCakeEFCoreOptions.MaxSaveCycles (default 16) Reentry loop upper bound
MiCakeEFCoreOptions.AllowDbContextAccessWithoutUoW Relaxes context resolution for read-only filters/middleware

Q: Do I still need to call anything to install interceptors? No. Just register the DbContext in the container — the module’s ConfigureDbContext configurator mounts interceptors and options automatically. Requires EF Core 9+.

Q: Direct SaveChangesAsync outside a UoW used to fail. What about now? It is allowed with native EF Core semantics (Permissive strategy) — no MiCake transaction/rollback/lifecycle guarantees. Open a UoW or use IStandaloneUnitOfWorkExecutor when guarantees are needed.

Q: Timeout is gone. How do I set a timeout? Use EF/provider command, lock, and transaction timeout settings. Timeout never took effect anyway.

Q: Why was OptimizeForSingleWrite removed? It allowed implicit transactions to commit before post-save lifecycle processing finished, so a post-save failure could return an error after data was already persisted. Every writable UoW now uses an explicit transaction (Lazy before the first write, or Immediate).

Q: Does UpdateAsync now throw DbUpdateConcurrencyException? Detached replacement preserves concurrency tokens, so stale instances surface conflicts instead of being silently overwritten. Load-and-modify is recommended.

Q: Can the requiresNew callback still capture outer services? No — the ownership check rejects contexts/repositories captured from the outer scope. Resolve everything from the callback’s IServiceProvider.