Transactions
A nextorm context can run its queries inside a database transaction: either one it starts itself, or one owned by EF Core, Dapper or raw ADO.NET that it enlists in. The role is separate from the query surface, so a context without a connection (the in-memory one) does not implement it, and ClickHouse — which speaks HTTP and has no transaction — rejects it. There is no change tracking and no
SaveChanges: writes stay explicit commands.
Prerequisites: Connections · Data modification (INSERT) · Provider overview
Overview
ITransactionManager is the transaction axis of DataContext, symmetric with IConnectionManager. Cast the context to the role to start a transaction or to enlist in an existing one; every command the context runs on the connection is then bound to the active transaction.
public interface ITransactionManager
{
DbTransaction? CurrentTransaction { get; }
DbTransaction BeginTransaction();
DbTransaction BeginTransaction(IsolationLevel isolationLevel);
Task<DbTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default);
Task<DbTransaction> BeginTransactionAsync(IsolationLevel isolationLevel, CancellationToken cancellationToken = default);
void UseTransaction(DbTransaction? transaction);
void CreateSavepoint(string name);
Task CreateSavepointAsync(string name, CancellationToken cancellationToken = default);
void RollbackToSavepoint(string name);
Task RollbackToSavepointAsync(string name, CancellationToken cancellationToken = default);
void ReleaseSavepoint(string name);
Task ReleaseSavepointAsync(string name, CancellationToken cancellationToken = default);
}
Starting a transaction
Cast the context to the role and use the ADO.NET-style BeginTransaction* methods; commit or roll back with the returned DbTransaction:
using var ctx = new PostgresDataContext(connectionString, new DataContextBuilder());
var transactions = (ITransactionManager)ctx;
await using var tx = await transactions.BeginTransactionAsync();
ctx.CreateInsertBuilder<IOrder>().Values(new Order { Id = 42, Total = 10m }).Insert();
ctx.From<IOrder>().Where(x => x.Id == 42).ToList(); // sees the uncommitted row
await tx.CommitAsync();
Enlisting an existing transaction
To run nextorm queries inside a transaction owned by EF Core, Dapper or raw ADO.NET, enlist it with UseTransaction. The context only binds it and never commits, rolls back or disposes it:
using var db = new MyDbContext(options); // EF Core
await using var efTx = await db.Database.BeginTransactionAsync();
db.Orders.Add(new Order { Id = 42, Total = 10m });
await db.SaveChangesAsync(); // EF writes (uncommitted)
using var next = new PostgresDataContext(db.Database.GetDbConnection(), new DataContextBuilder());
((ITransactionManager)next).UseTransaction(efTx.GetDbTransaction());
var row = next.From<IOrder>().Where(x => x.Id == 42).FirstOrDefault(); // sees EF's uncommitted row
await efTx.RollbackAsync(); // nextorm drops the completed transaction
The transaction must belong to the context's connection (the context validates it).
Reusing an existing transaction
A reusable method should not assume it owns the transaction. TryBeginTransaction starts one only when none is active and never throws when one already is:
var transactions = (ITransactionManager)ctx;
var started = transactions.TryBeginTransaction(out var tx); // tx is the active one when started == false
try
{
// ... write with ctx ...
if (started) tx!.Commit();
}
catch
{
if (started) tx!.Rollback();
throw;
}
TryBeginTransaction returns false — without throwing — when a transaction is already active (including one enlisted with UseTransaction), handing the active transaction back through out; and it returns false with null when the provider has no transactions at all (ClickHouse). Only the caller that started it (started == true) commits or rolls it back; an inner caller just runs on the existing transaction. The asynchronous twin TryBeginTransactionAsync reports the same outcome as a (bool Started, DbTransaction? Transaction) tuple.
Ownership and lifetime
- Only one transaction is active at a time: a second
BeginTransaction, or enlisting while one is active, throwsInvalidOperationException. See Nested transactions and savepoints. - A transaction started with
BeginTransaction*is owned by the context: if it is still open when the context is disposed, the context rolls it back.UseTransaction(null)does not detach it — commit, roll back or dispose it instead. - A transaction passed to
UseTransactionis owned by the caller: the context never commits, rolls back or disposes it. Passnullto detach it (only an enlisted transaction can be detached). - A transaction that has already been committed, rolled back or disposed is dropped lazily, so later queries on the context run outside it.
- Like the connection itself, the role is not thread-safe: do not begin, detach or complete a transaction concurrently with query execution on the same context.
Nested transactions and savepoints
The transaction model is flat: a context has one transaction at a time, and the role is not
re-entrant. A second BeginTransaction* while one is active — or enlisting another transaction with
UseTransaction(tx) — throws InvalidOperationException:
await using var tx = await transactions.BeginTransactionAsync();
await transactions.BeginTransactionAsync(); // InvalidOperationException
// A transaction is already in progress on this context. Nested transactions are not supported;
// use the active transaction instead.
There is no ambient or scope-based transaction either: nextorm never uses TransactionScope and never
propagates a transaction across contexts. Transactional work is composed by passing the same context
(or the same DbTransaction) down the call chain, so the inner calls run on the already-open transaction
and never commit or roll it back themselves:
// both calls run in one transaction; only the outermost owner commits
await repository.ReserveAsync(order, cancellationToken);
await payment.ChargeAsync(order, cancellationToken);
UseTransaction(externalTx) is the hook for sharing an externally owned transaction (EF Core, Dapper,
raw ADO.NET) — it binds the context to that transaction, it is not a way to nest.
Savepoints. A savepoint lets an inner step undo only its own work while the outer transaction stays
open. The role exposes the three ADO.NET operations, each with an Async twin:
var transactions = (ITransactionManager)ctx;
await using var tx = await transactions.BeginTransactionAsync();
await transactions.CreateSavepointAsync("before_batch");
try
{
// ... the step that may need a partial rollback ...
await transactions.ReleaseSavepointAsync("before_batch");
}
catch
{
await transactions.RollbackToSavepointAsync("before_batch");
throw; // the outer transaction is still open
}
await tx.CommitAsync();
CreateSavepoint, RollbackToSavepoint and ReleaseSavepoint (and their *Async forms) go straight to
the provider's DbTransaction:
nextorm creates, rolls back to, and releases named savepoints through ITransactionManager; callers still
own name uniqueness and nesting. There is no savepoint registry or stack, the transaction is never committed
or rolled back as a whole, command state is untouched, and the transaction's ownership does not change
either — so a transaction enlisted with UseTransaction gets savepoints too.
Names. The name rule is a portable API policy, not a driver limit: 1–32 ASCII identifier characters —
[A-Za-z_][A-Za-z0-9_]* — checked ordinally (only ASCII ranges are inspected, so the culture cannot affect
the result). A null, empty, leading-digit, whitespace, quoted, punctuated, non-ASCII or 33+ character name
throws ArgumentException before the driver is called; the 32-character cap also keeps SQL Server's
truncation out of reach. Duplicate names are delegated unchanged — there is no uniqueness guard — and
nested savepoints work because every newer savepoint marks its own point.
Guards. The checks run in a fixed order and the first applicable one wins, without touching the
driver: transaction support → savepoint support → an active transaction must exist → a valid name. A
savepoint call with no active transaction therefore throws InvalidOperationException even on a provider
that supports savepoints.
Provider notes.
- SQLite, PostgreSQL, SQL Server, MySQL and MariaDB support savepoints:
ISqlDialect.SupportsSavepointsistruethere, and MariaDB inherits MySQL's value. A third-party dialect defaults tofalse. - On SQL Server
ReleaseSavepointstill calls the driver, and there it is a non-throwing no-op that preserves the data: T-SQL has noRELEASE SAVEPOINTstatement. Rolling back after a release is provider-defined and is not part of the supported contract. - PostgreSQL (Npgsql) applies a savepoint immediately: create-then-rollback works even with no intervening command.
- ClickHouse has no ADO.NET transaction at all, so
BeginTransaction*fails before any savepoint guard and every savepoint call throwsNotSupportedException. - A rollback to a savepoint keeps the rows and
NULLvalues written before it and discards the later work; only the surrounding commit or rollback ends the transaction.
Provider support
ITransactionManager is implemented for SQLite, PostgreSQL, SQL Server and MySQL/MariaDB, and the context binds DbCommand.Transaction on every execution path (buffered, streaming and mutations). Savepoints are available on all five — SQL Server's release is a data-preserving no-op — while ClickHouse rejects them through the transaction guard. ClickHouse speaks HTTP and has no ADO.NET transaction: its dialect reports SupportsTransactions as false and BeginTransaction*/UseTransaction throw NotSupportedException. The in-memory provider does not implement the role.