🎯 Objective
To understand how we ensure that critical data operations are consistent, meaning either all execute or none (the atomicity principle).
🔹 Why do we need transactions?
-
In real applications, a Command can modify multiple entities or tables.
-
If some operations succeed and others fail, the database remains in an inconsistent state.
-
The solution: run everything in a transaction, and rollback in case of error.
Example: when creating a new blog post, we might want to:
-
Add Post to the Posts table.
-
Add ContentText to the ContentTexts table.
-
Record an entry in AuditLog.
If any of these fail, all must be rolled back.
🔹 Option 1: Using IDbContextTransaction
In the Command handler, we can work directly with EF Core transactions:
public class CreatePostCommandHandler : IRequestHandler<CreatePostCommand, Guid>
{
private readonly ApplicationDbContext _context;
public CreatePostCommandHandler(ApplicationDbContext context)
{
_context = context;
}
public async Task<Guid> Handle(CreatePostCommand request, CancellationToken cancellationToken)
{
using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
try
{
var post = new Post(request.Title, request.AuthorId);
_context.Posts.Add(post);
var content = new ContentText(post.Id, request.Content);
_context.ContentTexts.Add(content);
await _context.SaveChangesAsync(cancellationToken);
await _context.Database.CommitTransactionAsync(cancellationToken);
return post.Id;
}
catch
{
await _context.Database.RollbackTransactionAsync(cancellationToken);
throw;
}
}
}
🔹 Option 2: Using Unit of Work
If we want a cleaner layer, we can introduce an IUnitOfWork:
public interface IUnitOfWork
{
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitAsync(CancellationToken cancellationToken = default);
Task RollbackAsync(CancellationToken cancellationToken = default);
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}
Implementation (in ApplicationDbContext):
public class ApplicationDbContext : DbContext, IApplicationDbContext, IUnitOfWork
{
private IDbContextTransaction? _currentTransaction;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) { }
public DbSet<Post> Posts => Set<Post>();
public async Task BeginTransactionAsync(CancellationToken cancellationToken = default)
=> _currentTransaction = await Database.BeginTransactionAsync(cancellationToken);
public async Task CommitAsync(CancellationToken cancellationToken = default)
{
await SaveChangesAsync(cancellationToken);
await _currentTransaction?.CommitAsync(cancellationToken)!;
}
public async Task RollbackAsync(CancellationToken cancellationToken = default)
=> await _currentTransaction?.RollbackAsync(cancellationToken)!;
}
Then the handler becomes cleaner:
public class CreatePostCommandHandler : IRequestHandler<CreatePostCommand, Guid>
{
private readonly IUnitOfWork _uow;
public CreatePostCommandHandler(IUnitOfWork uow)
{
_uow = uow;
}
public async Task<Guid> Handle(CreatePostCommand request, CancellationToken cancellationToken)
{
await _uow.BeginTransactionAsync(cancellationToken);
try
{
var post = new Post(request.Title, request.AuthorId);
_uow.Posts.Add(post);
var content = new ContentText(post.Id, request.Content);
_uow.ContentTexts.Add(content);
await _uow.CommitAsync(cancellationToken);
return post.Id;
}
catch
{
await _uow.RollbackAsync(cancellationToken);
throw;
}
}
}
🔹 When to choose each option?
-
IDbContextTransaction → good for simple cases, occasional transactions.
-
UnitOfWork → good for more complex DDD architectures, where you want an explicit contract for transactions and better testability.