Transactions and the unit of work
Explains how database transactions relate to the unit of work, and how the automatic transaction behavior differs between .NET Framework 4.8 and .NET 8.0+.
A UnitOfWork represents a single session against the database. You make changes through repositories, then persist them by calling Save (or SaveAsync). How those saves are grouped into database transactions depends on the target framework. That behavior changed between .NET Framework 4.8 and .NET 8.0+.
Important.NET 8.0+ removed the automatic, per-request transaction wrapping that existed in .NET Framework 4.8. If your extension relies on multiple
Savecalls succeeding or failing together, read Atomicity across multiple saves and the .NET 4.8 framework to .NET 8.0+ migration guide.
How a single Save behaves
A single call to Save or SaveAsync is atomic on both frameworks. Entity Framework wraps the batch of changes from that one call in its own implicit database transaction, so the whole batch commits or rolls back together. This behavior has not changed.
What changed is whether multiple Save calls — and the handler chain around them — are grouped into a single transaction automatically.
Automatic transaction behavior by framework
.NET Framework 4.8 (Entity Framework 6)
When an API request ran its handler chain, the platform automatically wrapped the entire chain in a single database transaction. It began a transaction before the first handler, ran every handler, then saved and committed at the end. If any handler threw an exception, the framework rolled back the whole transaction and cleared the session.
The practical effects were:
- Every
Savemade by any handler in the chain shared one transaction, so the request was atomic as a whole. - A handler could modify entities and rely on the framework to perform a final
Saveand commit, even if it did not callSaveitself. - The default transaction isolation level was
READ UNCOMMITTED.
.NET 8.0+ (Entity Framework Core)
.NET 8.0+ removed the automatic per-handler transaction wrapping. By default, the handler chain runs without an enclosing transaction, and the framework no longer performs an automatic final Save or commit. This removal has the following effects:
- Multiple
Savecalls are no longer atomic unless you manage a transaction explicitly. In .NET 4.8, everySavein the chain enlisted in the single wrapping transaction and committed together at the end. In .NET 8.0+, eachSavecommits immediately in its own implicit transaction. - Your mid-chain changes still persist, but no longer atomically. A
UnitOfWorkflushes all pending changes on the shared per-request context, so a laterSaveby base code still persists changes your extension made earlier. Because thatSavecommits immediately, a failure later in the request does not roll your change back, which can leave partial writes. Because extension code usually runs inside a base handler or pipeline, this behavior is the consequence most likely to affect you. - On an unhandled exception, the context discards only the unsaved tracked changes. Anything already committed by a prior
Savestays committed. - The framework removed the implicit end-of-request save. Because the next
Savein the chain (yours or base code's) normally flushes your changes, a lost write occurs only in the narrow case where you mutate entities and no laterSaveruns in the same request. - The transaction isolation level is
READ COMMITTEDrather thanREAD UNCOMMITTED(see Isolation level and locking).
A single connection cannot have multiple concurrent running queries in .NET 8.0+ because Multiple Active Result Sets is disabled. See the .NET 4.8 framework to .NET 8.0+ migration guide for related Entity Framework Core changes.
Isolation level and locking
The default transaction isolation level changed from READ UNCOMMITTED (.NET Framework 4.8) to READ COMMITTED (.NET 8.0+).
Optimizely's hosted environments enable Read Committed Snapshot Isolation (RCSI) at the database level. With RCSI, a statement running under READ COMMITTED reads the latest committed row version from the version store instead of acquiring shared (read) locks. RCSI has the following practical effects:
- Readers do not block writers, and writers do not block readers — comparable non-blocking behavior to the previous
READ UNCOMMITTEDdefault. - Unlike
READ UNCOMMITTED, reads never return uncommitted ("dirty") data. Each statement sees a transactionally consistent snapshot of committed data as of the moment the statement started.
RCSI is a database-level setting. Local or on-premises SQL Server instances that do not enable it fall back to lock-based READ COMMITTED, where reads acquire shared locks and can block — and be blocked by — concurrent writes. Account for this when reproducing concurrency behavior outside the hosted environments.
Atomicity across multiple saves
When you need several Save calls to commit or roll back as a unit in .NET 8.0+, manage the transaction explicitly with BeginTransaction, CommitTransaction, and RollbackTransaction:
this.UnitOfWork.BeginTransaction();
try
{
foreach (var contentKey in contentKeys)
{
this.RecursiveDelete(contentKey, isVariant);
}
this.UnitOfWork.Save();
this.UnitOfWork.CommitTransaction();
}
catch (Exception)
{
this.UnitOfWork.RollbackTransaction();
throw;
}Updated about 17 hours ago
