C# Ambient Transactions: What They Are and Why They Matter


Introduction
Transactions are the main mechanism for ensuring data consistency in a system. A transaction ensures that all actions within it either succeed or fail, keeping the system in a consistent state.
In .NET
, transactions can be managed manually using BeginTransaction()
from System.Data
.IDbConnection
. Many developers, both experienced and novice, choose this approach.
However, manually managing transactions creates many problems:
Forgotten commits
Unhandled exceptions
Nesting conflicts
Rollback errors
Generating
Leaky repository
ORSeparation of Concerns
. When business logic goes beyond the Business layer and spreads to the Repository layer and vice versa.async/await
issues
It is especially painful when the code base starts to grow and grow dependencies and duplicated parts, violating the most important things in the project: readability, support, testing, in other words, violating the SRP
principle.
In this article, I will talk about typical problems of using BeginTransaction()
and how they can be solved via TransactionScopeFactory
.
Problems
Forgotten commit or rollback
One of the most frequent errors is a forgotten Commit()
or Rollback()
. It can be happen if the code has conditional branching, early return
. Or an exception is thrown.
As result, the transaction hangs, blocks resources, or results in data loss.
In addition, Rollback()
can throw an exception too (e.g. for connection problems), and if it is not handled, the application may crash at the moment when it was supposed to be recovering.
Non-processing of exceptions
When working with transactions, you should always wrap a block of operations in try/catch
. But this is often forgotten, especially in a hurry or when it seems “nothing can do wrong”.
This results in unclosed transactions, connection leaks and data loss.
Conflicts with nesting
Manual transactions do not support nesting. If an internal methods starts BeginTransaction
without knowing that a transaction has already been started above, an error may occur: either an exception or the logic will start working in a new isolated transaction, breaking atomicity.
This is especially critical when reusing libraries.
The principle of trust in previously written code by your teammates is violated.
Leaky Repository / Violation of Separation of Concerns
When you use BeginTransaction
in business logic and commit in repository, or vice versa, you are blurring the responsibility between the layers.
// Service
_repo.DoSomething();
// Repository
using var tx = connection.BeginTransaction();
... // Additional logic related to business or rollback logic.
tx.Commit();
This violates Separation of Concerns
: now the service must know that repository itself commits something, and the repository cannot be reused in other contexts.
This kind of code is hard to test, scale, and maintain.
This is leaky abstraction, and one of the main anti-patterns when working with transactions.
Problems with async/await
and explicit transaction passing
When you use BeginTransaction
in asynchronous code, it becomes necessary to explicitly pass the transaction object IDbTransaction
and the connection IDbConnection
to all methods that participate in the transition.
This make the code less readable, more brittle, and breaks encapsulation.
Each method must accept both the connection and the transaction, even if it does not directly deal with them itself. This leads to strong coupling, makes testing harder, and makes the code more vulnerable to bugs. One forgotten tx
in the parameters and the logic goes beyond the transaction.
Solution: How to solve these problems and what does TransactionScopeFactory have to do with it?
TransactionScopeFactory
is a wrapper that helps to create a TransactionScope
with the desired settings, while taking into account the current ambient
context and working correctly with async/await
.
As examples we will consider a simplified system of recording money transfers from customer A => B
and logging the fact the transfer was made.
// Service
public async Task<...> Transfer(....)
{
.....
source.Balance -= r.Amount;
destination.Balance += r.Amount;
.....
using var ts = _transactionScopeFactory.Create();
var err = await _db.UpdateBalance([source, destination], token);
if (err != null) return err;
var err = await _db.AddLog(r.ToLog(), token);
if (err != null) return err;
ts.Complete();
}
.....
// Repository
public async Task<string> UpdateBalance(AccountEntity[] entities, CancellationToken token)
{
try
{
foreach (var entity in entities)
{
var result = await UpdateBalance(entity, token);
if (!result.IsSucces)
return result;
}
return (true, "");
}
catch (Exception ex)
{
return ex.Message;
}
}
private async Task<string> UpdateBalance(AccountEntity entity, CancellationToken token)
{
var sql = "UPDATE accounts SET ....";
try
{
... ExecuteAsync ...
}
catch (Exception ex)
{
return ex.Message;
}
}
public async Task<string> AddLog(TransactionLogEntity entity, CancellationToken token)
{
var sql = @"
INSERT INTO ....
VALUES ....";
try
{
... ExecuteAsync ...
}
catch (Exception ex)
{
return ex.Message;
}
}
This approach allows you to get:
Clean and readable code
try/catch
processing is at the repository level. All business logic is in one place: clear and easy to understand.Repository methods are concise and simple and ready to reused
Support for
async/await
Secure transaction completion
Respect for external
ambient
— context if it already exists.
How to implement TransactionScopeFactory
If you want to apply this approach to you project, below is a basic example of a TransactionScopeFactory
implementation.
A more detailed implementation with integration into business logic, repositories and unit-of-work
can be viewed in my repository on GitHub: EveIsSim/AmbientTransaction
- Create interface:
public interface ITransactionScopeFactory
{
TransactionScope Create();
}
Add implementation: where:
TransactionScopeOption.Required
— If there is already an active transaction in the current context — join it. If not — create a new one.IsolationLevel.ReadCommitted
— It provides protection against “dirty reads” and is a reasonable compromise between security and perfomance.TransactionScopeAsyncFlowOption.Enabled
— If you specify this flag when creating a TransactionScope,Transaction.Current
will be saved via await.
public class TransactionScopeFactory : ITransactionScopeFactory
{
public TransactionScope Create()
{
return new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions
{
IsolationLevel = IsolationLevel.ReadCommitted
},
TransactionScopeAsyncFlowOption.Enabled);
}
}
- Register with DI and use throughout the project.
Conclusion
If you are still manually managing transactions via BeginTransaction
, you have probably already experienced the pain of supporting such code. An abstraction like TransactionScopeFactory
helps you write cleaner, more stable and reusable code. This is especially important when a project grows and dozens of methods and services start touching transactions.
Take advantage of the convenience of the C# language. (=
Thank you for your attention.
EveIsSim (everything is simple)
Frequently asked question: Is await using for TransactionScope necessary?
The short answer is no, you do not need it.
Although TransationScope
supports async/await
via TransactionScopeAsyncFlowOption.Enabled
, it does not itself implement the IAsyncDisposable
interface, and thus does not require await using
.
Why does the confusion arise?
Many people assume that supporting async
= the need for DisposeAsync()
, but TransactionScope
uses regular Dispose()
even in an asynchronous context.
await using
is only required when the object implements IAsyncDisposable
. This is not the case.
When then is await using
appropriate?
If you wrap TransactionScope
in its own wrapper that implements IAsyncDisposable
— then await using
makes sense.
So, if you use TransactionScope
directly — you can and should get by with just using
.
Subscribe to my newsletter
Read articles from Sergei Petrov directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
