Chapter 14 · Transactions, Savepoints, Execution Strategies, and Cross-Context Coordination

Default SaveChanges Transactions, Explicit Transactions, Isolation Levels, and Scope

Connect SaveChanges atomicity to explicit transaction scope, isolation, lock duration, and second-connection evidence using the ServiceHub SQLite lab.

Advanced145–185 minutestransaction + isolation evidence labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 mandatory baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

Chapter 13 taught how one row detects a stale writer. Transactions answer a different question: which database operations must succeed or fail as one unit, and what can concurrent sessions observe while that unit is in progress? A transaction is a database boundary around work; atomicity means either the committed unit becomes durable as a whole or its uncommitted changes are rolled back. Isolation controls how concurrently executing transactions can observe and interfere with one another.

01

Explain the transaction EF normally creates around one SaveChanges call and its atomicity boundary.

02

Use BeginTransactionAsync for multiple SaveChanges calls that must commit or roll back together.

03

Distinguish EF transaction APIs from database isolation, locks, timeouts, and provider semantics.

04

Observe visibility from a second DbContext/connection rather than infer isolation from API names.

05

Choose transaction scope deliberately so locks and connection occupancy are not held across remote/user work.

06

Keep optimistic Revision conflicts separate from transaction isolation and physical locking behavior.

Reproducible baseline

Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, the existing Guid Revision concurrency token, and the Chapter 12 outbox teaching model when referenced. SQL Server retry/MARS/System.Transactions examples are optional provider-specific paths. EF Core 11 previews are excluded.

1. One SaveChanges is already an atomic unit when the provider supports transactions

For relational providers that support transactions, EF Core wraps all modification commands generated by one SaveChanges call in a transaction. If one command fails, the call is rolled back so earlier commands from that same save do not remain committed. That default is usually sufficient when one unit of work can be expressed as one SaveChanges.

csharp · one tracked unit of work
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);order.ReviseSummary("Replace bearing and inspect coupling");order.AdvanceRevision();db.Set<OutboxMessage>().Add(new OutboxMessage{    Id = Guid.NewGuid(),    OccurredUtc = clock.GetUtcNow().UtcDateTime,    Type = "WorkOrderChanged",    PayloadJson = BuildMinimalOutboxPayload(order)});await db.SaveChangesAsync(ct); // tracked update + outbox insert are one SaveChanges unit

This is the strongest reason not to open an explicit transaction ceremonially. Extra transaction code adds failure paths and can conflict with retrying execution strategies without increasing correctness when a single SaveChanges already defines the intended atomic boundary.

2. Deliberately wrong: assume two SaveChanges calls are one transaction

csharp · two independently committed units
order.ReviseSummary("Parts approved");order.AdvanceRevision();await db.SaveChangesAsync(ct);          // can commit hereawait billingClient.ReserveBudgetAsync(..., ct); // remote call can failorder.ReviseSummary("Scheduling metadata prepared");order.AdvanceRevision();await db.SaveChangesAsync(ct);          // separate database transaction

The first SaveChanges can be durable even if later work fails. Worse, holding a database transaction open while calling a remote service would trade one inconsistency for long lock duration and uncertain latency. The repair is architectural: keep remote I/O outside a database transaction and use durable workflow/outbox semantics, or—when all operations are in the same database—group the necessary SaveChanges calls in one short explicit transaction.

Transaction scope is not workflow scope

A database transaction should generally surround database work whose latency you control. Do not keep it open while waiting for a person, HTTP service, message broker, or long CPU job.

3. Explicit transaction: two saves, one commit decision

csharp · short local transaction
await using var tx = await db.Database.BeginTransactionAsync(ct);try{    var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);    order.ReviseSummary("Bearing replacement authorized");    order.AdvanceRevision();    await db.SaveChangesAsync(ct);    db.Set<OutboxMessage>().Add(new OutboxMessage    {        Id = Guid.NewGuid(),        OccurredUtc = clock.GetUtcNow().UtcDateTime,        Type = "WorkOrderAuthorized",        PayloadJson = BuildMinimalOutboxPayload(order)    });    await db.SaveChangesAsync(ct);    await tx.CommitAsync(ct);}catch{    await tx.RollbackAsync(ct);    throw;}

Both SaveChanges calls now participate in the same database transaction. Notice that the outbox remains local database work; publishing the message still happens after commit in a separate dispatcher.

4. Isolation is a database contract, not an EF slogan

Concept What it controls SQLite mandatory lab Production-provider question
Atomicity Commit/rollback of the unit Supported; one transaction groups statements What operations and resources actually enlist?
Isolation level Visibility/interference among concurrent transactions Serializable by default; provider has SQLite-specific limits Which anomalies does this engine/version actually prevent?
Locking/MVCC Physical mechanism used by the engine SQLite permits only one pending writer Does the engine block, version, abort, or retry?
Timeout/busy policy How long lock acquisition waits SQLite commands may time out/busy while another writer is pending What provider errors are transient and how are they configured?

Microsoft.Data.Sqlite documents SQLite transactions as serializable by default. It also documents that only one transaction can have pending writes at a time. SQL Server, PostgreSQL, MySQL/MariaDB, and Oracle implement concurrency differently; passing an IsolationLevel enum to EF does not erase those engine rules.

5. Observe from a second connection

The lab uses a separate physical connection so the first context's identity map cannot be mistaken for database isolation evidence. On SQLite, the important result after a write may be blocking, not a non-blocking stale read.

csharp · second-connection probe before and after commit
await using var writer = await factory.CreateDbContextAsync(ct);await using var tx = await writer.Database.BeginTransactionAsync(ct);var order = await writer.WorkOrders.SingleAsync(x => x.Id == id, ct);order.ReviseSummary("transaction-visible after commit");order.AdvanceRevision();await writer.SaveChangesAsync(ct);// Use a short timeout for the observation probe. Microsoft.Data.Sqlite// documents that a pending write can block another connection.try{    await using var observer = new SqliteConnection(        "Data Source=servicehub-lab.db;Default Timeout=1");    await observer.OpenAsync(ct);    await using var command = observer.CreateCommand();    command.CommandText = "SELECT summary FROM work_orders WHERE work_order_id = $id";    command.Parameters.AddWithValue("$id", id);    Console.WriteLine(await command.ExecuteScalarAsync(ct));}catch (SqliteException ex){    Console.WriteLine($"Pre-commit observer blocked/failed: {ex.SqliteErrorCode}");}await tx.CommitAsync(ct);await using var after = await factory.CreateDbContextAsync(ct);Console.WriteLine(await after.WorkOrders.AsNoTracking()    .Where(x => x.Id == id)    .Select(x => x.Summary)    .SingleAsync(ct));

Microsoft.Data.Sqlite documents that after a SQLite transaction performs its first write, concurrent access can be blocked until the transaction completes. Therefore the pre-commit probe may time out rather than return an “old” row. That blocked/timeout evidence is itself part of the isolation/locking observation. After commit, a fresh context should read the new durable value.

6. Choosing an isolation level needs an anomaly and workload, not a magic “strong” value

csharp · explicit isolation request
await using var tx = await db.Database.BeginTransactionAsync(    System.Data.IsolationLevel.Serializable,    ct);

Ask which invariant requires protection, what rows/ranges participate, and how much blocking/retry is acceptable. For SQLite the default is already serializable; Snapshot and Chaos are not supported by Microsoft.Data.Sqlite. Other engines may support snapshot-style isolation, predicate/range locking, or MVCC semantics under differently named levels. Verify the actual provider/database documentation and test from separate sessions.

7. Transaction events are observable

csharp · development logging categories
optionsBuilder.LogTo(    Console.WriteLine,    new[]    {        DbLoggerCategory.Database.Transaction.Name,        DbLoggerCategory.Database.Command.Name    },    LogLevel.Information);

Transaction logs can show begin/commit/rollback and command ordering. They do not prove database-side lock duration, blocked sessions, deadlock graphs, or execution plans. Combine EF evidence with engine-specific diagnostics in production.

8. Hands-on lab

  1. Reset servicehub-lab.db and enable command + transaction logging.
  2. Perform one SaveChanges containing a WorkOrder update and outbox insert; capture transaction logs.
  3. Repeat as two SaveChanges without an explicit transaction and note two independent commit boundaries.
  4. Wrap the two saves in BeginTransactionAsync; deliberately throw before commit and verify both database effects roll back.
  5. Run the second-connection probe with a short timeout before commit; record whether it blocks/times out, then verify the committed value afterward.
  6. Print tx.GetDbTransaction().IsolationLevel and record the actual SQLite result.
  7. Attempt only documented SQLite isolation levels; record provider errors instead of forcing unsupported settings.
  8. Document how the production provider differs before reusing the same transaction policy.

Check your understanding

  1. When is EF’s default SaveChanges transaction normally enough?
  2. Are two SaveChanges calls automatically one atomic unit?
  3. Why query from a second context when testing isolation?
  4. What is SQLite’s documented default isolation?
  5. Does Serializable mean “no concurrency problems ever”?
  6. Should a transaction stay open across an HTTP call?
Review the answers

When the intended atomic unit fits in one SaveChanges call and the provider supports transactions.

No. Use one explicit transaction if the database work must commit or roll back together.

A second connection observes database visibility; the first context may simply return already-tracked CLR state.

Serializable for normal transactions on the Microsoft.Data.Sqlite baseline.

No. It is one isolation contract; locking, timeouts, write serialization, business conflicts, and distributed workflow remain separate concerns.

Normally no; use short local transactions and durable workflow patterns for external systems.

9. Production judgment and bridge

Use the smallest transaction that protects the real invariant. Keep provider isolation/locking semantics explicit, measure contention, and verify visibility from other sessions. Lesson 2 zooms into a transaction that remains open across multiple save attempts: EF savepoints and the tracker reconciliation required after partial rollback.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n