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

Sergei PetrovSergei Petrov
5 min read

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 OR Separation 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

  1. Create interface:
public interface ITransactionScopeFactory  
{  
    TransactionScope Create();  
}
  1. 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);
    }
}
  1. 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.

0
Subscribe to my newsletter

Read articles from Sergei Petrov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Sergei Petrov
Sergei Petrov