Cofoundry includes ITransactionScopeManager
for managing transactions across multiple statements. This is actually a light wrapper around System.Transaction.TransactionScope
and supports the same functionality, but includes some extra features to help with coordinating transactions when executing nested commands.
Here's an example:
using Cofoundry.Core.Data;
using Cofoundry.Domain.CQS;
public class AddProductHandler
{
private readonly MyDbContext _myDbContext;
private readonly ITransactionScopeManager _transactionScopeManager;
private readonly ICommandExecutor _commandExecutor;
public AddProductHandler(
MyDbContext myDbContext,
ITransactionScopeManager transactionScopeManager,
ICommandExecutor commandExecutor
)
{
_myDbContext = myDbContext;
_transactionScopeManager = transactionScopeManager;
_commandExecutor = commandExecutor;
}
public async Task ExecuteAsync(AddProductCommand command)
{
// …add product to myDbContext (omitted)
// Create a new scope to manage the transaction using the connection associated with _myDbContext
using (var scope = _transactionScopeManager.Create(_myDbContext))
{
// Save the EF context inside the transaction
await _myDbContext.SaveChangesAsync();
// Execute a nested command
await _commandExecutor.ExecuteAsync(new AllocateProductOwnerCommand()
{
// …set some properties on the command (omitted)
});
// Complete the scope
await scope.CompleteAsync();
}
}
}
In this example our command uses a transaction to ensure that local _myDbContext.SaveChangesAsync()
operation and any data access executed inside AllocateProductOwnerCommand
fails or succeeds as a single operation. AllocateProductOwnerCommand
itself may also use ITransactionScopeManager
which will utilize the ambient transaction initiated in the outer command.
Accessing via Repositories
Instead of using ITransactionScopeManager
directly, it's often simpler to access this functionality from our repository classes such as IDomainRepository
. In this case, the database connection associated with the ambient Cofoundry DbContext is used.
public class TransactionExample
{
private readonly IAdvancedContentRepository _contentRepository;
public TransactionExample(
IAdvancedContentRepository contentRepository
)
{
_contentRepository = contentRepository;
}
public async Task RunExample()
{
using (var scope = _contentRepository.Transactions().CreateScope())
{
await _contentRepository
.PageDirectories()
.AddAsync(new()
{
// …set some properties on the command
});
await _contentRepository
.Pages()
.AddAsync(new()
{
// …set some properties on the command
});
await scope.CompleteAsync();
}
}
}
Why not use System.Transaction.TransactionScope?
Before the re-introduction of System.Transactions in .NET Core 2.0 ITransactionScopeManager
was a wrapper around EF transactions which added support for nested scopes. Now that System.Transactions is available the default ITransactionScopeManager
implementation uses System.Transaction.TransactionScope
to manage nested scopes, so it might seem pointless to use this abstraction, however there are some additional benefits of using the Cofoundry implementation:
- Deferred execution of "completion tasks" in nested transactions
- Better defaults for scope creation
Deferred execution of "completion tasks"
ITransactionScopeManager
adds the ability to defer execution of tasks until after the transaction is complete. Cofoundry uses this internally to ensure that message handlers and cache breaking code does not run until the top-level parent transaction is complete.
Here's an example based on a simplified DeletePageCommandHandler
. In the sample scope.QueueCompletionTask
is used to ensure the message publication and cache clearing code is only run when the parent transaction is complete.
public class DeletePageCommandHandler
{
// …constructor removed for brevity
public async Task ExecuteAsync(DeletePageCommand command, IExecutionContext executionContext)
{
var page = await _dbContext
.Pages
.FilterById(command.PageId)
.SingleOrDefaultAsync();
if (page == null)
{
return;
}
_dbContext.Pages.Remove(page);
using (var scope = _transactionScopeManager.Create(_dbContext))
{
await _dbContext.SaveChangesAsync();
await _pageStoredProcedures.UpdatePublishStatusQueryLookupAsync(command.PageId);
scope.QueueCompletionTask(() => OnTransactionComplete(command));
await scope.CompleteAsync();
}
}
private Task OnTransactionComplete(DeletePageCommand command)
{
_pageCache.Clear(command.PageId);
return _messageAggregator.PublishAsync(new PageDeletedMessage()
{
PageId = command.PageId
});
}
}
It's unlikely that anyone implementing a site using Cofoundry will need to use the task queuing features directly, but if you're running built-in Cofoundry commands from your code in transactions then it's best to manage the scopes using ITransactionScopeManager
to ensure internal Cofoundry code runs as expected.
Better defaults for scope creation
ITransactionManager
uses the following defaults when creating a new TransactionScope
:
TransactionScopeAsyncFlowOption.Enabled
: Since we should always use async for database access, we enable this by defaultIsolationLevel.ReadCommitted
: EF uses a default isolation level ofSerializable
for backwards compatibility, but this is rarely the best choice and doesn't align with the SQL Server default. Instead Cofoundry usesReadCommitted
by default.
Customizing scope creation
Per Transaction
To change the scope creation settings there are several extensions you can use. These extensions are specific to our default Cofoundry implementation and so you will need to reference the namespace Cofoundry.Core.Data.TransactionScopeManager.Default
to use them.
using Cofoundry.Domain;
using Cofoundry.Domain.TransactionManager.Default;
public class TransactionExample
{
private readonly IAdvancedContentRepository _contentRepository;
public TransactionExample(
IAdvancedContentRepository contentRepository
)
{
_contentRepository = contentRepository;
}
public async Task RunExample()
{
using (var scope = _contentRepository
.Transactions()
.CreateScope(TransactionScopeOption.Required, IsolationLevel.Serializable))
{
// …code removed for brevity
await scope.CompleteAsync();
}
using (var scope = _contentRepository
.Transactions()
.CreateScope(CreateScope))
{
// …code removed for brevity
await scope.CompleteAsync();
}
}
private TransactionScope CreateScope()
{
var options = new TransactionOptions()
{
IsolationLevel = IsolationLevel.Serializable,
Timeout = TransactionManager.DefaultTimeout
};
return new TransactionScope(
TransactionScopeOption.Required,
options,
TransactionScopeAsyncFlowOption.Suppress
);
}
}
Customizing the defaults
To change the default settings for scope creation, create your own ITransactionScopeFactory
implementation and override it using the DI System.