EF Core 10 Relational Data · Lesson 8

Transaction boundaries and fresh verification

Commit or roll back several real saves without confusing tracker state with storage.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

Read or download the complete SDK project, then follow its setup on your computer. The separate C# companion practice below runs in SharpRune’s .NET 9 console environment.

Choose a commit boundary for a concrete multi-save requirement

A transaction defines which database work is committed together, subject to the provider’s actual capabilities and isolation behavior. EF normally wraps one SaveChanges in a transaction when supported, so an extra explicit transaction should group work that genuinely spans several operations. This lesson saves two separate additions before deliberately rolling back the group, then verifies a different committed group. The fixture keeps every context and transaction sequential on the held-open SQLite connection. It is a controlled relational atomicity exercise, not a distributed transaction or a claim that all concurrent readers in every provider see the same intermediate state.

Verify rollback and commit through fresh contexts

The first external context creates the transient schema, starts a transaction, saves A, saves B and rolls back. The context and transaction scopes then end. A new context counts zero rows. A second transaction saves C and commits; another fresh context reads the single C row. These observations avoid using the first context’s already tracked objects to decide whether rollback changed storage. The code’s Check assertions establish both outcomes on actual SQLite. Merely watching SaveChanges return successfully inside the uncommitted transaction would not establish that its work later survived the group’s rollback.

using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var db = new LabDb(options))
{
    await db.Database.EnsureCreatedAsync(); await using var transaction = await db.Database.BeginTransactionAsync();
    db.Items.Add(new Item { Name = "A" }); await db.SaveChangesAsync(); db.Items.Add(new Item { Name = "B" }); await db.SaveChangesAsync();
    await transaction.RollbackAsync();
}
await using (var verify = new LabDb(options)) { Check(await verify.Items.CountAsync() == 0, "rollback stored count"); Console.WriteLine("after-rollback:0"); }
await using (var db = new LabDb(options)) { await using var transaction = await db.Database.BeginTransactionAsync(); db.Items.Add(new Item { Name = "C" }); await db.SaveChangesAsync(); await transaction.CommitAsync(); }
await using (var verify = new LabDb(options)) { Check((await verify.Items.SingleAsync()).Name == "C", "commit visible"); Console.WriteLine("after-commit:C"); }

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }

Staging is an application model, not an ACID implementation

The .NET9 companion begins with an empty committed list and a staged A,B list. Clearing the staged list leaves the committed count zero. A later stage containing C is explicitly copied to committed, producing C. Those operations illustrate a proposed boundary between tentative and accepted application data. They do not implement SQLite rollback, locking, durability, savepoints or isolation. The class-free list example remains useful precisely because its claim is small. Use it to reason about what an application wants to commit, then let genuine relational tests establish whether the actual database unit obeys that contract.

Rollback does not rewind arbitrary managed objects

After a database rollback, a context can still retain objects whose state was accepted during an earlier SaveChanges. Their values and tracking status are not an automatic reconstruction of the pre-transaction database. Ending that failed unit and reading through a fresh context avoids a common false verification. Likewise, a successful transaction commit is not the same as an application-level distributed operation completing: another service, an email or a file write may lie outside the database boundary. Do not silently include those effects in a rollback claim. Production retry strategies also need coordination with manually managed transactions.

Practice: make a later operation fail inside the group

Add a real unique index in a separate fixture and save one valid row before attempting a conflicting row in the same explicit transaction. Decide where the failure is translated, dispose or roll back the transaction, and verify the stored rows using a new context. Compare that grouped result with two deliberately independent transactions. Keep the expected outcome specified before writing the handling code. This experiment requires the actual provider constraint; throwing a homemade exception before any SQL executes would test control flow but would not establish database atomicity under a real save failure.

  • Hint: use a fresh context for storage observations after a failed unit.
  • Hint: verify which earlier saves belong to the same transaction.

Practice: define partial acceptance rather than obtaining it accidentally

Extend the portable staged-data example to validate every supplied entry before accepting the complete batch. Add one rejected entry after several valid entries and require that committed data remains unchanged. Then describe a different partial-import contract that deliberately accepts valid entries and reports rejected ones. Both can be legitimate application designs, but they need different results and tests. Do not infer partial success merely from a local list that was updated before an exception escaped. The model should express an independently chosen transaction requirement without borrowing database terms for guarantees it does not implement.

  • Hint: all-or-nothing and partial import are different public contracts.
  • Hint: a local variable changing before failure is not proof that storage committed it.

Review actual commit evidence and its limits

The external transcript establishes after-rollback zero and after-commit C through fresh SQLite queries. The portable transcript establishes only the stated staging-list behavior. Neither validates a production migration, another provider’s isolation level or a transaction spanning external side effects. A useful review identifies which saves share a transaction, who owns disposal, how failures end the unit and how committed state is independently observed. Keep those facts visible when the capstone combines stock updates with a unique reservation reference, where a plausible success message must never substitute for the whole relational unit actually committing.

External net10.0 console; SDK10.0.403; Microsoft.EntityFrameworkCore.Sqlite 10.0.14; native SQLite relational provider. Portable practice is a separate .NET9 BCL-only program.

.NET10 / EF Core10 / real SQLite — Transaction boundaries and fresh verification

Set up the SDK project

  1. Use an external .NET10 console environment. This downloadable project targets net10.0 and pins Microsoft.EntityFrameworkCore.Sqlite 10.0.14; it is not the SharpRune .NET9 browser practice.
  2. Restore this project with: dotnet restore EfLab.csproj --source https://api.nuget.org/v3/index.json. Use an installed compatible .NET10 SDK; validation used the isolated official SDK10.0.403.
  3. Run: dotnet run --project EfLab.csproj --no-restore. The source opens a real SQLite :memory: connection and keeps that connection alive across its sequential context scopes. No EF InMemory provider or fake ORM is used.
  4. Read the embedded Check assertions and the expected output below. EnsureCreated is used only for disposable classroom schema unless this is the dedicated migration lesson. A passing SQLite run is not evidence for another database provider.

Download complete SDK project ↗

Project files

EfLab.csprojDownload file ↗
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.14" />
  </ItemGroup>
</Project>
Program.csDownload file ↗
using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

await using var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<LabDb>().UseSqlite(connection).Options;
await using (var db = new LabDb(options))
{
    await db.Database.EnsureCreatedAsync(); await using var transaction = await db.Database.BeginTransactionAsync();
    db.Items.Add(new Item { Name = "A" }); await db.SaveChangesAsync(); db.Items.Add(new Item { Name = "B" }); await db.SaveChangesAsync();
    await transaction.RollbackAsync();
}
await using (var verify = new LabDb(options)) { Check(await verify.Items.CountAsync() == 0, "rollback stored count"); Console.WriteLine("after-rollback:0"); }
await using (var db = new LabDb(options)) { await using var transaction = await db.Database.BeginTransactionAsync(); db.Items.Add(new Item { Name = "C" }); await db.SaveChangesAsync(); await transaction.CommitAsync(); }
await using (var verify = new LabDb(options)) { Check((await verify.Items.SingleAsync()).Name == "C", "commit visible"); Console.WriteLine("after-commit:C"); }

static void Check(bool condition, string name) { if (!condition) throw new InvalidOperationException("CHECK: " + name); }
Model.csDownload file ↗
using Microsoft.EntityFrameworkCore;
public sealed class LabDb : DbContext
{
    public LabDb(DbContextOptions<LabDb> options) : base(options) { }
    public DbSet<Item> Items => Set<Item>();
}
public sealed class Item
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

Expected result

The external program must complete its Check assertions and print: after-rollback:0 after-commit:C It uses actual SQLite materialization and saving; no external service or production database is contacted.

Use the listed SDK and dependencies for this project. Open C# practice runs the separate portable exercise.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;

var committed = new List<string>(); var staged = new List<string> { "A", "B" };
staged.Clear(); Console.WriteLine("committed:" + committed.Count);
staged.Add("C"); committed.AddRange(staged); Console.WriteLine(string.Join(",", committed));
Open C# practice

C# practice output

committed:0
C
Try this
Rollback changes storage; use a fresh context to verify it rather than trusting stale tracked state.

Further reading