Skip to main content

Unit of Work Pattern and Its Role in Managing Transactions

Unit of Work Pattern and Its Role in Managing Transactions
Table of Contents

The bug that makes people care about the Unit of Work pattern is the partial write. An operation inserts a row in one table, fails on the second, and the database lands in a state nobody designed for. I’ve spent enough late evenings reconstructing “how did this record end up half-created” timelines to want transaction boundaries stated explicitly in code, not implied by hope.

Two honest caveats before the pattern gets oversold. A single INSERT doesn’t need a Unit of Work; the pattern earns its keep when one use case touches more than one table or repository. And since the Contact Management Application uses Dapper rather than Entity Framework, there is no DbContext change tracker doing this silently: the transaction handling below is explicit and visible, which I consider a feature.

What’s covered
#

  1. The implementation of the Unit of Work pattern using Dapper.

  2. How Dapper keeps database interaction simple while transactions stay managed.

  3. The role of Unit of Work in making multiple database operations atomic.

  4. Code examples showing how transactions are managed in the Contact Management Application.

This article is part of the Clean Architecture series built around the Contact Management Application (source on GitHub). The series introduction has the full index of articles.

1. What is the Unit of Work Pattern?
#

The Unit of Work pattern treats a group of database operations as a single unit: all of them succeed, or none of them do. That guarantee matters most when multiple repositories are involved and consistency has to hold across entities.

In the Contact Management Application, it manages transactions for database work done through Dapper.

2. Unit of Work Implementation with Dapper
#

Dapper is a micro ORM (Object-Relational Mapper) that provides fast data access with plain SQL. To integrate Dapper with the Unit of Work pattern, we wrap database operations so they run within one transaction.

2.1 Defining the Unit of Work Interface
#

The IUnitOfWork interface defines methods for starting, committing, and rolling back transactions.

using System.Data;

namespace Contact.Application.Interfaces
{
    public interface IUnitOfWork : IDisposable
    {
        IDbTransaction BeginTransaction();
        Task CommitAsync();
        Task RollbackAsync();
    }
}

The interface is deliberately small:

  • BeginTransaction() starts a new database transaction.

  • CommitAsync() commits all operations as a single unit of work.

  • RollbackAsync() rolls back the transaction in case of an error.

2.2 Implementing the Unit of Work with Dapper
#

The UnitOfWork class uses Dapper to manage the database connection and handle transactions.

using Contact.Application.Interfaces;
using Contact.Infrastructure.Persistence.Helper;
using System.Data;

namespace Contact.Infrastructure.Persistence
{
    public class UnitOfWork : IUnitOfWork
    {
        private readonly IDapperHelper _dapperHelper;
        private IDbTransaction _transaction;
        private IDbConnection _connection;

        public UnitOfWork(IDapperHelper dapperHelper)
        {
            _dapperHelper = dapperHelper;
            _connection = _dapperHelper.GetConnection();
        }

        public IDbTransaction BeginTransaction()
        {
            if (_connection.State == ConnectionState.Closed)
                _connection.Open();

            _transaction = _connection.BeginTransaction();
            return _transaction;
        }

        public async Task CommitAsync()
        {
            try
            {
                _transaction?.Commit();
                await Task.CompletedTask;
            }
            finally
            {
                Dispose();
            }
        }

        public async Task RollbackAsync()
        {
            try
            {
                _transaction?.Rollback();
                await Task.CompletedTask;
            }
            finally
            {
                Dispose();
            }
        }

        public void Dispose()
        {
            _transaction?.Dispose();
            if (_connection?.State == ConnectionState.Open)
            {
                _connection.Close();
            }
            _connection?.Dispose();
        }
    }
}

In this implementation:

  • BeginTransaction() opens the connection if it is closed, then starts the transaction.

  • CommitAsync() commits the transaction to persist changes.

  • RollbackAsync() rolls back the transaction in case of failure.

  • Dispose() cleans up both the transaction and the connection, and both Commit and Rollback call it in their finally blocks, so a UnitOfWork instance is single-use by design.

3. Using the Unit of Work in Services
#

Services integrate the Unit of Work so that everything a service method does happens inside one transaction.

3.1 Example: Generic Service Using Unit of Work
#

Here is how the pattern is used in the GenericService:

using AutoMapper;
using Contact.Application.Interfaces;
using Contact.Domain.Entities;
using Contact.Domain.Interfaces;

namespace Contact.Application.Services;

public class GenericService<TEntity, TResponse, TCreate, TUpdate>
        : IGenericService<TEntity, TResponse, TCreate, TUpdate>
        where TEntity : BaseEntity
        where TResponse : class
        where TCreate : class
        where TUpdate : class
{
    private readonly IGenericRepository<TEntity> _repository;
    private readonly IMapper _mapper;
    private readonly IUnitOfWork _unitOfWork;

    public GenericService(IGenericRepository<TEntity> repository, IMapper mapper, IUnitOfWork unitOfWork)
    {
        _repository = repository;
        _mapper = mapper;
        _unitOfWork = unitOfWork;
    }

    public async Task<TResponse> Add(TCreate createDto)
    {
        using var transaction = _unitOfWork.BeginTransaction();
        try
        {
            var entity = _mapper.Map<TEntity>(createDto);
            var createdEntity = await _repository.Add(entity, transaction);
            await _unitOfWork.CommitAsync();
            return _mapper.Map<TResponse>(createdEntity);
        }
        catch
        {
            await _unitOfWork.RollbackAsync();
            throw;
        }
    }

    public async Task<TResponse> Update(TUpdate updateDto)
    {
        var entity = _mapper.Map<TEntity>(updateDto);
        var updatedEntity = await _repository.Update(entity);
        return _mapper.Map<TResponse>(updatedEntity);
    }

    public async Task<bool> Delete(Guid id)
    {
        return await _repository.Delete(id);
    }

    public async Task<TResponse> FindByID(Guid id)
    {
        var entity = await _repository.FindByID(id);
        return _mapper.Map<TResponse>(entity);
    }

    public async Task<IEnumerable<TResponse>> FindAll()
    {
        var entities = await _repository.FindAll();
        return _mapper.Map<IEnumerable<TResponse>>(entities);
    }
}

In this example:

  • Add() starts a transaction, adds the entity, and commits on success.

  • If an exception is thrown, the catch block rolls the transaction back, so no partial changes reach the database.

  • The transaction object is passed into the repository call, which is what lets several repository operations share one transaction when a use case needs it.

Watch out: in this GenericService only Add opens a transaction. Update and Delete call the repository directly, so they run as single statements outside any transaction. That’s acceptable for one-statement operations, but any multi-step update needs the same BeginTransaction/Commit/Rollback treatment as Add.

4. What the pattern buys you with Dapper
#

The practical value is centralization: transaction logic lives in one class instead of being scattered across repositories and services, and a reviewer can see the atomic boundary of an operation by reading one method. Combined with Dapper’s single managed connection, that keeps both the code and the connection handling predictable. The cost is the ceremony visible above; pay it where a use case spans tables, and skip it where it doesn’t.

Worth remembering
#

  • Unit of Work exists to kill the partial write. If a use case touches two tables, the second failure must undo the first, every time.
  • Passing the IDbTransaction into repository methods is the mechanism that lets multiple repositories join one transaction; without it the pattern is decorative.
  • This implementation disposes itself on commit and rollback, making each UnitOfWork single-use. Know that before you try to reuse one across operations.
  • Don’t wrap single-statement operations in transaction ceremony. The pattern is a tool for multi-step consistency, not a style requirement.

Next in the series: how Dapper and the Repository Pattern handle the data access this article takes for granted. Or go straight to the Contact Management Application, break the second step of a multi-step operation on purpose, and watch the rollback do its job.

Related

Dependency Injection Setup Across Layers

··6 mins
Program.cs is where service registration goes to rot. It starts at five lines, then every feature adds its repositories, validators, and settings bindings until nobody can tell which layer owns what. The Contact Management Application avoids that with one rule: each layer registers its own services through an extension method, and Program.cs only calls those methods. This post walks through that setup layer by layer, using the real code, including two spots where the real code deserves a warning label.

Best Practices for Creating and Using DTOs in the API

··6 mins
Serializing domain entities straight to the client looks like a shortcut until the first schema change. Rename one property on the entity and every consumer of the API breaks with it, and you find out from their bug reports. DTOs are the cheap insurance against that: the entity can change shape freely because the contract the client sees is a separate class you control. This post covers the DTO conventions I use in the Contact Management Application and why each one exists.

Handling Authorization and Role-Based Access Control (RBAC)

··19 mins
Introduction # Static role checks ([Authorize(Roles = "Admin")]) fall apart the first time someone asks you to add a permission without a redeploy. Once roles and permissions have to change at runtime, hard-coded role attributes become a liability. The Contact Management Application takes a different route: a dynamic policy provider that builds authorization policies from the database at request time, covering both the backend API and the Angular frontend, wired into JWT authentication without breaking the separation of concerns Clean Architecture expects.