Dev GuideAPI Reference
Dev GuideAPI ReferenceUser GuideGitHubDev CommunityOptimizely AcademySubmit a ticketLog In
Dev Guide

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 group 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 Save calls 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 is unchanged.

What changed is whether multiple Save calls — and the handler chain around them — group 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. The platform 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 the following:

  • Every Save made 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 Save and commit, even if the handler did not call Save itself.
  • 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 Save calls are no longer atomic unless you manage a transaction explicitly. In .NET 4.8, every Save in the chain enlisted in the single wrapping transaction and committed together at the end. In .NET 8.0+, each Save commits immediately in its own implicit transaction.
  • Your mid-chain changes still persist, but no longer atomically. A UnitOfWork flushes all pending changes on the shared per-request context, so a later Save by base code still persists changes your extension made earlier. Because that Save commits 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 Save stays committed.
  • The framework removed the implicit end-of-request save. Because the next Save in 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 later Save runs in the same request.
  • The transaction isolation level is READ COMMITTED rather than READ UNCOMMITTED (see Isolation level and locking).

A single connection cannot run multiple concurrent queries in .NET 8.0+ because Multiple Active Result Sets (MARS) 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 UNCOMMITTED default.
  • 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 difference 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. The following example shows the pattern:

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

This pattern works identically on both frameworks, so it is the safest way to express an atomic unit of work that does not depend on the framework's automatic behavior. See IUnitOfWork for the full method reference.

📘

Note

Pipelines are not transactional. A pipeline that calls Save between pipes does not establish a transaction boundary across those pipes. Wrap the calls in an explicit transaction if you need them to be atomic.

Retries and the execution strategy

By default, the framework does not retry handler execution. The Enable Retry On Handlers system setting (in the Developer settings group) turns retrying on. When enabled, each handler chain runs inside a retrying execution strategy that automatically re-runs it on transient SQL errors such as deadlocks, command timeouts, and dropped connections. The strategy uses up to six attempts with exponential backoff, the Entity Framework Core default.

A retry re-executes the entire handler chain from the beginning. It is a full restart of the operation, not a query-level retry.

❗️

Warning

In .NET 8.0+, enabling this setting can cause duplicated or partially applied work unless your handlers are idempotent. Because the handler chain is no longer wrapped in a single transaction (see Automatic transaction behavior by framework), any Save that already committed before the transient error is not rolled back, and the retry runs those handlers again. In .NET Framework 4.8, this was safe because the whole chain ran inside one transaction that rolled back between attempts.

To make handler work safe under retries in .NET 8.0+, use one of the following approaches:

  • Make the handler logic idempotent, so re-running it produces the same result.
  • Wrap the unit of work in an explicit transaction (BeginTransaction, Save, and CommitTransaction). On a transient error, the framework rolls back the active transaction before the retry, so each attempt starts from a clean state.
📘

Note

Enabling this setting only adds retry resilience. It does not restore the .NET 4.8 behavior of wrapping the whole handler chain in one transaction. Continue to use explicit transactions for atomicity across multiple saves.

Side effects beyond the database

Some handler work reaches outside the database — calling a payment gateway, an enterprise resource planning (ERP) or tax service, sending email, or posting to a third-party API. A database transaction does not cover these effects and cannot roll them back. Two behaviors make this especially important to plan for in .NET 8.0+:

  • Saves are not atomic with each other or with the external call. A database change can commit even though a later external call fails, and an external call can succeed even though a later Save is never reached.
  • When Enable Retry On Handlers is enabled, a transient SQL error restarts the entire handler chain (see Retries and the execution strategy), so any external call already made during that attempt is made again.

You cannot make a database commit and an external system update truly atomic. Design for that limitation instead.

Make external calls idempotent

Prefer APIs that accept an idempotency key, a stable identifier for the operation, such as the order or payment ID. Send the same key on every attempt so a repeated call, whether from a chain retry or a later reprocess, has no additional effect. This safeguard is the most effective, because it neutralizes both the retry restart and any manual replay.

Perform side effects after the database work succeeds

Make the external call after the unit of work commits, not in the middle of the chain, so you never trigger an external effect for database work that later rolls back or fails. Avoid placing non-idempotent external calls inside a handler that runs under the retry policy, where a transient error would repeat them.

Record intent, then act

For critical effects, persist a record of the work to perform as part of the database transaction, commit, then carry out the external call in a separate step that can be retried and de-duplicated against that record. This approach trades immediacy for a durable, reusable boundary between the database and the external system.

Plan for reconciliation

The two systems can still diverge, the commit succeeds but the external call fails, or the reverse. Make failures detectable and recoverable. Log enough to identify the affected records, and provide a way to retry or compensate.