A .NET 10 library providing database-agnostic transaction abstractions. It enables multiple independent libraries to participate in a single shared database transaction without tight coupling to specific database implementations.
Transactional solves the problem of coordinating transactions across multiple libraries that need to work with the same database. Instead of each library managing its own transactions, they can participate in a shared transaction context that is controlled at the application level.
# Core abstractions (no external dependencies)
dotnet add package Transactional.Abstractions
# MongoDB implementation (requires MongoDB.Driver 3.x)
dotnet add package Transactional.MongoDB
# PostgreSQL implementation (requires Npgsql 8.x)
dotnet add package Transactional.PostgreSQL// Program.cs - Register services
builder.Services.AddSingleton<IMongoClient>(new MongoClient("mongodb://localhost:27017"));
builder.Services.AddMongoDbTransactionManager();
// Usage
public class MyService
{
private readonly IMongoTransactionManager _transactionManager;
public MyService(IMongoTransactionManager transactionManager)
{
_transactionManager = transactionManager;
}
public async Task DoWorkAsync()
{
await using var context = await _transactionManager.BeginTransactionAsync();
// Pass context to repositories...
await context.CommitAsync();
}
}// Program.cs - Register services
builder.Services.AddPostgresTransactionManager("Host=localhost;Database=myapp;Username=postgres;Password=secret");
// Usage
public class MyService
{
private readonly IPostgresTransactionManager _transactionManager;
public MyService(IPostgresTransactionManager transactionManager)
{
_transactionManager = transactionManager;
}
public async Task DoWorkAsync()
{
await using var context = await _transactionManager.BeginTransactionAsync();
// Pass context to repositories...
await context.CommitAsync();
}
}| Package | Description | Dependencies |
|---|---|---|
Transactional.Abstractions |
Core interface (ITransactionContext) |
None |
Transactional.MongoDB |
MongoDB implementation wrapping IClientSessionHandle |
MongoDB.Driver 3.x |
Transactional.PostgreSQL |
PostgreSQL implementation wrapping NpgsqlTransaction |
Npgsql 8.x |
// Register IMongoClient first, then add transaction manager
builder.Services.AddSingleton<IMongoClient>(
new MongoClient(configuration.GetConnectionString("MongoDB")));
builder.Services.AddMongoDbTransactionManager();// Option 1: With connection string (registers both NpgsqlDataSource and transaction manager)
builder.Services.AddPostgresTransactionManager("Host=localhost;Database=myapp");
// Option 2: With existing NpgsqlDataSource registration
builder.Services.AddSingleton(NpgsqlDataSource.Create("Host=localhost;Database=myapp"));
builder.Services.AddPostgresTransactionManager();
// Option 3: With factory (registers both NpgsqlDataSource and transaction manager)
builder.Services.AddPostgresTransactionManager(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return NpgsqlDataSource.Create(config.GetConnectionString("PostgreSQL")!);
});Repositories should accept the base ITransactionContext interface, then cast to the database-specific type internally. This keeps your repository contracts database-agnostic while still allowing full access to native transaction features.
public class UserRepository
{
private readonly IMongoDatabase _database;
public UserRepository(IMongoDatabase database)
{
_database = database;
}
public async Task CreateUserAsync(User user, ITransactionContext? transactionContext = null)
{
var collection = _database.GetCollection<User>("users");
if (transactionContext != null)
{
if (transactionContext is not IMongoTransactionContext mongoContext)
{
throw new InvalidOperationException(
$"Expected {nameof(IMongoTransactionContext)} but received {transactionContext.GetType().Name}");
}
await collection.InsertOneAsync(mongoContext.Session, user);
}
else
{
await collection.InsertOneAsync(user);
}
}
}
public class OrderRepository
{
private readonly IMongoDatabase _database;
public OrderRepository(IMongoDatabase database)
{
_database = database;
}
public async Task CreateOrderAsync(Order order, ITransactionContext? transactionContext = null)
{
var collection = _database.GetCollection<Order>("orders");
if (transactionContext != null)
{
if (transactionContext is not IMongoTransactionContext mongoContext)
{
throw new InvalidOperationException(
$"Expected {nameof(IMongoTransactionContext)} but received {transactionContext.GetType().Name}");
}
await collection.InsertOneAsync(mongoContext.Session, order);
}
else
{
await collection.InsertOneAsync(order);
}
}
}The service layer controls transaction boundaries and passes the context to repositories:
public class OrderService
{
private readonly IMongoTransactionManager _transactionManager;
private readonly UserRepository _userRepo;
private readonly OrderRepository _orderRepo;
public OrderService(
IMongoTransactionManager transactionManager,
UserRepository userRepo,
OrderRepository orderRepo)
{
_transactionManager = transactionManager;
_userRepo = userRepo;
_orderRepo = orderRepo;
}
public async Task CreateOrderWithUserAsync(User user, Order order)
{
await using var context = await _transactionManager.BeginTransactionAsync();
await _userRepo.CreateUserAsync(user, context);
await _orderRepo.CreateOrderAsync(order, context);
await context.CommitAsync();
// If an exception is thrown before CommitAsync, DisposeAsync will auto-rollback
}
}public class UserRepository
{
private readonly NpgsqlDataSource _dataSource;
public UserRepository(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async Task CreateUserAsync(string name, ITransactionContext? transactionContext = null)
{
if (transactionContext != null)
{
if (transactionContext is not IPostgresTransactionContext pgContext)
{
throw new InvalidOperationException(
$"Expected {nameof(IPostgresTransactionContext)} but received {transactionContext.GetType().Name}");
}
await using var command = pgContext.Transaction.Connection!.CreateCommand();
command.Transaction = pgContext.Transaction;
command.CommandText = "INSERT INTO users (name) VALUES (@name)";
command.Parameters.AddWithValue("name", name);
await command.ExecuteNonQueryAsync();
}
else
{
await using var connection = await _dataSource.OpenConnectionAsync();
await using var command = connection.CreateCommand();
command.CommandText = "INSERT INTO users (name) VALUES (@name)";
command.Parameters.AddWithValue("name", name);
await command.ExecuteNonQueryAsync();
}
}
}-
Always use
await usingfor transactions - This ensures proper disposal and automatic rollback if an exception occurs before commit. -
Pass
ITransactionContextto repositories - Use the base interface in method signatures, then cast internally to keep contracts database-agnostic. -
Commit explicitly - Call
CommitAsync()when your work is complete. Uncommitted transactions are automatically rolled back on dispose. -
Make transaction participation optional - Allow methods to work with or without a transaction context for flexibility.
-
Use DI extension methods - Prefer
AddMongoDbTransactionManager()andAddPostgresTransactionManager()over manual registration.
Check whether a transaction has been committed or rolled back:
await using var context = await _transactionManager.BeginTransactionAsync();
// ... do work ...
if (!context.IsCommitted && !context.IsRolledBack)
{
await context.CommitAsync();
}Register async callbacks that execute after a transaction completes:
await using var context = await _transactionManager.BeginTransactionAsync();
// Register callbacks before commit/rollback
context.OnCommitted(async cancellationToken =>
{
// Runs after successful commit - e.g., publish events, send notifications
await _eventPublisher.PublishAsync(new OrderCreatedEvent(order), cancellationToken);
});
context.OnRolledBack(async cancellationToken =>
{
// Runs after explicit rollback - e.g., cleanup, logging
_logger.LogWarning("Order creation rolled back");
});
await _orderRepo.CreateOrderAsync(order, context);
await context.CommitAsync(); // OnCommitted callbacks fire after this completesNote: Callbacks only fire for explicit CommitAsync() or RollbackAsync() calls. They do not fire when DisposeAsync() performs an implicit rollback.
Wrap a pre-existing database transaction into a context. The wrapped transaction's lifecycle remains your responsibility:
// MongoDB: Wrap a session that already has an active transaction
using var session = await client.StartSessionAsync();
session.StartTransaction();
// ... do some work directly with session ...
// Now wrap it to pass to libraries expecting ITransactionContext
var context = _transactionManager.WrapExistingTransaction(session);
await _someLibrary.DoWorkAsync(context);
await context.CommitAsync();
// session is NOT disposed when context is disposed - you manage it
session.Dispose();// PostgreSQL: Wrap an existing NpgsqlTransaction
await using var connection = await _dataSource.OpenConnectionAsync();
await using var transaction = await connection.BeginTransactionAsync();
var context = _transactionManager.WrapExistingTransaction(transaction);
await _someLibrary.DoWorkAsync(context);
await context.CommitAsync();
// transaction/connection are NOT disposed when context is disposedAll public APIs include XML documentation. Enable documentation generation in your IDE or refer to the generated XML documentation files in the NuGet packages.
Base interface for all transaction contexts (extends IAsyncDisposable):
| Member | Description |
|---|---|
IsCommitted |
true after successful commit |
IsRolledBack |
true after explicit rollback |
CommitAsync(CancellationToken) |
Commits the transaction |
RollbackAsync(CancellationToken) |
Rolls back the transaction |
OnCommitted(Func<CancellationToken, Task>) |
Registers a post-commit callback |
OnRolledBack(Func<CancellationToken, Task>) |
Registers a post-rollback callback |
IMongoTransactionContext - Extends ITransactionContext:
Session- The underlyingIClientSessionHandle
IMongoTransactionManager:
BeginTransactionAsync(TransactionOptions?, CancellationToken)- Starts a new transactionWrapExistingTransaction(IClientSessionHandle)- Wraps a session with an active transaction
IPostgresTransactionContext - Extends ITransactionContext:
Transaction- The underlyingNpgsqlTransaction
IPostgresTransactionManager:
BeginTransactionAsync(IsolationLevel, CancellationToken)- Starts a new transaction (default: ReadCommitted)WrapExistingTransaction(NpgsqlTransaction)- Wraps an existing transaction
This project is licensed under the Apache 2.0 License - see the LICENSE file for details.