RO EN

Applying transactions in the Command handler

Applying transactions in the Command handler
Doru Bulubasa
29 August 2025

🎯 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:

  1. Add Post to the Posts table.

  2. Add ContentText to the ContentTexts table.

  3. 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.