Skip to main content

Command Palette

Search for a command to run...

Implementing an Abstraction for Transactions

Published
3 min read

As an addition to the previous post on integrative persistence tests, I decided to offer a simple implementation of the abstraction over transactions.

You can find the source code here.

Registering Services

It's a good idea to provide the transaction manager in the DI container, so you can inject it into your services as needed. There is an extension method on the service collection for this purpose. An optional parameter lets you specify whether you are in a testing or live context.

// live
services.AddTransactionManager();

// testing
services.AddTransactionManager(forIntegrativeTests: true);

This registers the transaction manager as an open generic, where the type parameter is the type of the DbContext. This setup provides one transaction manager per DbContext.

Since the last registration for a service overrides previous ones, you can call AddTransactionManager in your live code without parameters and then call it with forIntegrativeTests: true in your test setup.

Your test setup for configuring the DI container should always run after the live setup. This allows you to modify existing registrations.

To convert the DbContext registration to a singleton, use the ChangeDbContextLifestyle method:

services.ChangeDbContextLifestyle<MyDbContext>();

Transaction Manager Interface

The interface is similar to what Entity Framework Core provides:

interface ITransactionManager<TDbContext>
    where TDbContext : DbContext
{
    Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken);
}

interface ITransaction : IDisposable, IAsyncDisposable
{
    Task CommitAsync(CancellationToken cancellationToken);

    Task RollbackAsync(CancellationToken cancellationToken);
}

If your transaction is disposed of, a rollback will automatically occur.

Testing TransactionManager Implementation

The TestingTransactionManager creates an outer transaction and maintains a list of save points when opening additional transactions below:

private int _savepointIndex = 0;
private readonly TDbContext _dbContext;
private IDbContextTransaction? _transaction;

async Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken)
{
    if (_transaction == null)
    {
        // create outer transaction
        _transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
    }

    await _transaction.CreateSavepointAsync($"sp_{_savepointIndex}");
    _savepointIndex += 1;
    return new TestingTransactionManagerTransaction<TDbContext>(this);
}

Rolling back a transaction means either undoing the outer transaction or returning to a previous save point, depending on the context.

async Task<ITransaction> BeginTransactionAsync(CancellationToken cancellationToken)
{
    if (_transaction == null)
    {
        // create outer transaction
        _transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken);
    }

    await _transaction.CreateSavepointAsync($"sp_{_savepointIndex}");
    _savepointIndex += 1;
    return new TestingTransactionManagerTransaction<TDbContext>(this);
}

Committing also works differently, depending on whether you are in the outer transaction or an inner transaction:

async ValueTask CommitAsync(CancellationToken cancellationToken)
{
    if (_transaction == null)
    {
        throw new InvalidOperationException("No active transaction.");
    }

    if (_savepointIndex == 0)
    {
        await _transaction.CommitAsync(cancellationToken);
    }
    else
    {
        // await _transaction.ReleaseSavepointAsync(_savepointIndex.ToString(CultureInfo.InvariantCulture), cancellationToken);
        _savepointIndex -= 1;
    }
}

When you are inside a transaction, you simply move your save point back one slot without navigating through save points. In the main transaction, you perform the actual commit.

Conclusion

This code hasn't been thoroughly tested yet, and I will make adjustments if any issues arise. However, it should give you a good idea of how this could be implemented.