← All articles

Committing related records atomically

When a parent and child record represent one business action, they should not leave a half-completed result. The transaction boundary covers both inserts.

t.Turgay
Committing related records atomically

A parent record exists, its child is missing, and the screen still reports success. In an application or approval workflow, two individually valid INSERT statements are not the same as a completed business operation. The bulk loading, approval and capacity checks in my service archive provide the context for explaining TransactionScope as a boundary around a complete decision.

Define the operation before choosing its transaction

The example writes a parent into ParentDemo and a related row into ChildDemo. They form one operation: either both remain or neither does. Giving every repository method an unrelated transaction would not establish that boundary. The service orchestrating the calls needs to know which writes must share an outcome.

This integration method uses Microsoft.Data.SqlClient and System.Transactions. Prepare ParentDemo.Id and the referencing ChildDemo.ParentId in an isolated SQL Server database. The fixed identity of one keeps the example small; it is not a production identity allocation strategy.

using System.Transactions;
using Microsoft.Data.SqlClient;
static async Task SaveAsync(string connectionString, CancellationToken token)
{
    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
    await using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync(token);
    await using var command = connection.CreateCommand();
    command.CommandText = "INSERT INTO dbo.ParentDemo(Id) VALUES(1)";
    await command.ExecuteNonQueryAsync(token);
    command.CommandText = "INSERT INTO dbo.ChildDemo(ParentId) VALUES(1)";
    await command.ExecuteNonQueryAsync(token);
    scope.Complete();
}

Async transaction flow is an explicit choice

TransactionScope establishes an ambient transaction context. An await can move execution into an asynchronous continuation. TransactionScopeAsyncFlowOption.Enabled allows the transaction context to flow through those continuations. Declaring the method async is not a substitute for selecting this transaction behavior.

The connection is opened inside the scope and participates according to the provider's enlistment behavior. Both commands are created from that same connection, so the demonstration does not introduce a second resource. Cancellation is passed to connection opening and both command executions; lower-level I/O should know when the surrounding operation has been cancelled.

Complete is a successful vote

Calling Complete indicates that this scope considers its work successful. It is not by itself proof that every part of the transaction has already committed. Completion occurs as the transaction scope is disposed. If the second command fails, execution never reaches Complete and the scope exits without voting to commit. I would report success only after the using scope has also finished successfully.

Nested scopes deserve the same care. The default Required behavior can join an existing transaction, so an inner Complete does not prevent an outer rollback. RequiresNew may introduce an independent transaction. Keeping an inner write after the outer business operation fails should be an intentional requirement, not an accidental consequence of a scope option.

Atomic writes are only part of concurrency design

The sample leaves isolation and timeout at TransactionScope defaults. A real operation should choose them alongside its data races and execution budget. Checking remaining capacity outside the transaction and writing afterwards can let two requests both observe the same free place. The check and update need a consistent boundary, with database constraints protecting rules such as uniqueness.

Additional connections or resource managers can introduce transaction promotion and platform-specific distributed transaction requirements. An HTTP call or sent email does not become reversible merely because a scope surrounds it. A notification that must follow a successful commit needs a separate post-commit or outbox design.

Try to create the partial result deliberately

Check both tables after a successful run. Next make the child write fail through a constraint or controlled exception and verify that no new parent remains. Cancellation, timeout and an already existing Id=1 are separate scenarios. Running this fixed-ID sample twice is not an idempotent retry mechanism; the initial data and destination constraints still matter.

TransactionScope execution model; Asynchronous transaction flow.

✳ You have reached the end of this orbit.
Explore more articles →
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth ✳