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#
The implementation of the Unit of Work pattern using Dapper.
How Dapper keeps database interaction simple while transactions stay managed.
The role of Unit of Work in making multiple database operations atomic.
Code examples showing how transactions are managed in the Contact Management Application.
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
finallyblocks, 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
catchblock 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.
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
IDbTransactioninto 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.
