Unit of Work Pattern and Its Role in Managing Transactions
Implementing the Unit of Work pattern over Dapper in a Clean Architecture .NET API — atomic multi-table operations, rollback, and when it's not worth it.
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.
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.
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.
Comments
Comments are GitHub discussions. Sign in with GitHub to post; reactions need no account.