Привет, Хабр!
Сегодня разберём, как реализовать паттерн Unit of Work в ASP.NET Core. Вместо долгих теоретических рассуждений, посмотрим, зачем он вообще нужен, и как правильно его применить на практике.
Почему вообще нужен Unit of Work?
Ты наверняка сталкивался с ситуацией, когда несколько операций с базой данных нужно обернуть в одну транзакцию. Например, при создании пользователя нужно добавить его в несколько таблиц. А что если что‑то пошло не так? Одна из операций упала, а данные уже частично добавлены? Здесь и помогает Unit of Work. Он следит за тем, чтобы все изменения проходили через одну точку, и либо подтверждаются все сразу, либо откатываются.
Но почему именно Unit of Work, а не просто транзакции через DbContext? Ответ простой — паттерн позволяет работать с несколькими репозиториями одновременно.
Интерфейс IUnitOfWork
Начнём с основы — интерфейса IUnitOfWork, который будет управлять нашими транзакциями.
public interface IUnitOfWork : IDisposable
{
IRepository UserRepository { get; }
IRepository OrderRepository { get; }
void Commit();
void Rollback();
}
Пара заметок:
IDisposable нужен для корректного освобождения ресурсов. Это значит, что когда ты закончишь работу с транзакцией, Dispose автоматически закроет все соединения и освободит память. Не забываем вызывать метод!
Commit и Rollback — это методы, которые отвечают за подтверждение или откат транзакций.
Теперь у нас есть интерфейс, переходим к главному — DbContext.
Основа операции — DbContext
Как я уже говорил, Unit of Work сам по себе мало что может, если у него нет связи с базой данных. Здесь на помощь приходит DbContext, который отвечает за все операции с БД в ASP.NET Core.
public class AppDbContext : DbContext
{
public DbSet Users { get; set; }
public DbSet Orders { get; set; }
public AppDbContext(DbContextOptions options)
: base(options) { }
public void BeginTransaction()
{
Database.BeginTransaction();
}
public void CommitTransaction()
{
Database.CommitTransaction();
}
public void RollbackTransaction()
{
Database.RollbackTransaction();
}
}
Тут видим несколько методов:
BeginTransaction — начинает транзакцию. Это первый шаг, перед тем как выполнять изменения.
CommitTransaction — подтверждает все изменения.
RollbackTransaction — откатывает изменения, если что-то пошло не так.
Эти методы — основа работы паттерна Unit of Work, но ещё важнее то, как их правильно интегрировать в бизнес-логику.
Реализация Unit of Work
Теперь соберём наш Unit of Work в единый механизм.
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IRepository _userRepository;
private IRepository _orderRepository;
public UnitOfWork(AppDbContext context)
{
_context = context;
}
public IRepository UserRepository
{
get { return _userRepository ??= new Repository(_context); }
}
public IRepository OrderRepository
{
get { return _orderRepository ??= new Repository(_context); }
}
public void Commit()
{
_context.SaveChanges();
_context.CommitTransaction();
}
public void Rollback()
{
_context.RollbackTransaction();
}
public void Dispose()
{
_context.Dispose();
}
Обрати внимание:
Ленивая инициализация репозиториев. Это значит, что мы создаём репозитории только тогда, когда они действительно нужны.
Commit вызывает метод SaveChanges, который сохраняет все изменения в базу, а затем подтверждает транзакцию. В случае ошибки — откат.
Когда Unit of Work — это не лучший выбор?
Unit of Work — отличный инструмент для управления транзакциями, но его не всегда стоит использовать. Например, если есть приложение с небольшими и простыми операциями, добавление лишнего уровня абстракции только усложнит код. В таких случаях лучше использовать дефолт транзакции через DbContext.
Помимо этого, если существует слишком много репозиториев и зависимостей, Unit of Work может стать лишь узким местом по производительности. Поэтому всегда оценивай, насколько оправдано его использование.
Репозитории
Unit of Work без репозиториев — как велосипед без колёс. Они управляют конкретными сущностями и отвечают за CRUD-операции. Пример репозитория:
public class Repository : IRepository where T : class
{
private readonly AppDbContext _context;
private readonly DbSet _dbSet;
public Repository(AppDbContext context)
{
_context = context;
_dbSet = context.Set();
}
public void Add(T entity)
{
_dbSet.Add(entity);
}
public void Update(T entity)
{
_dbSet.Update(entity);
}
public void Delete(T entity)
{
_dbSet.Remove(entity);
}
public IEnumerable GetAll()
{
return _dbSet.ToList();
}
public T GetById(int id)
{
return _dbSet.Find(id);
}
}
Этот репозиторий универсален и может работать с любыми сущностями. Все операции — через DbSet.
Как это выглядит на практике
Теперь посмотрим на реальный пример использования Unit of Work в контроллере:
public class UserController : Controller
{
private readonly IUnitOfWork _unitOfWork;
public UserController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
[HttpPost]
public IActionResult CreateUser(UserViewModel model)
{
try
{
_unitOfWork.UserRepository.Add(new User { Name = model.Name });
_unitOfWork.Commit();
return Ok("User created successfully.");
}
catch (Exception ex)
{
_unitOfWork.Rollback();
return BadRequest($"Error: {ex.Message}");
}
}
}
Мы добавляем пользователя через UserRepository и фиксируем транзакцию через Commit. Если что-то пошло не так, транзакция откатывается.
Как это тестировать?
Тестирование транзакций — важная часть работы с Unit of Work. Для этого идеально подходит библиотека Moq:
[Test]
public void CreateUser_ShouldCommitTransaction_WhenUserIsValid()
{
var mockUnitOfWork = new Mock();
var controller = new UserController(mockUnitOfWork.Object);
var result = controller.CreateUser(new UserViewModel { Name = "Test User" });
mockUnitOfWork.Verify(u => u.Commit(), Times.Once);
}
Здесь проверяем, что метод Commit вызывается при успешном добавлении пользователя.
Заключение
Теперь ты знаешь, как реализовать и использовать паттерн Unit of Work в ASP.NET Core. Но помни: не всегда этот паттерн нужен, и его использование должно быть оправдано архитектурой проекта. Если у тебя возникли вопросы или есть чем поделиться — пиши в комментариях.
Пользуясь случаем, напоминаю про открытые уроки, которые скоро пройдут в рамках курса «C# ASP.NET Core разработчик»:
16 октября: .NET Aspire: очередная прорывная технология в мире dotnet или не очень? Запись по ссылке
24 октября: Деплой ASP.NET приложений в Kubernetes. Запись по ссылке
Комментарии (11)
impwx
16.10.2024 05:33Если оба ваших контекста работают на основе одного и того же DbConnection, то не было смысла разделять их на два (тогда DbSet = IRepository, а DbContext = IUnitOfWork). А если на разных, то вам потребуются распределенные транзакции, которые дотнет поддерживает только на Windows.
Итого - бойлерплейта написали, какую задачу решили непонятно.
AlexViolin
16.10.2024 05:33Если приложение использует несколько DbContext, то транзакция и вместе с ней UnitOfWork должны быть на более высоком уровне, чем DbContext = IUnitOfWork.
impwx
16.10.2024 05:33Так я про это и говорю - в тех случаях, когда в приложении действительно нужно иметь два отдельных контекста, предлагаемый вариант не решает главную проблему отсутствия единой транзакции.
igoriok
16.10.2024 05:33Рекомендую добавить ещё абстракцию на транзакции
ITransaction
, Тогда вы сможете использовать несколько Unit of Work в одной логике:void DoBusiness() { using ITransaction coreTransaction = _coreUnitOfWork.BeginTransaction(); using ITransaction auditTransaction = _auditUnitOfWork.BeginTransaction(); try { _coreUnitOfWork.UserRepository.Add(new User()); _coreUnitOfWork.SaveChanges(); _auditUnitOfWork.AuditRepository.Add(new UserAdded()); _auditUnitOfWork.SaveChanges(); _coreTransaction.Commit(); _auditTransaction.Commit(); } catch { _coreTransaction.Rollback(); _auditTransaction.Rollback(); } }
Не идеально, но в большинстве случаев достаточно.
AlexViolin
16.10.2024 05:33В моём представлении транзакция должна быть одна. Если так сделать невозможно, то строится набор алгоритмов по обновлению и откату обновления данных, которые сейчас любят называть Saga.
DEugene
Но ведь если вы используете EF, то фреймворк уже заботливо предоставил вам имплементацию таких паттернов как Repository и UnitOfWork. В виде DbSet и DbContext соответственно. Городить поверх них абстракции делающие тоже самое - раздувать кодовую базу проекта, создавая дополнительные точки отказа и поле для ошибок в имплементации.
igoriok
Для простых проектов может и не нужно. Но с определённого момента написание юнит тестов под логику завязанную на EF может обернутся сущим адом. Интерфейсы
IRepository<T>
иIUnitOfWork
изолируют вашу бизнес логику от инфраструктуры.impwx
С подходом, когда мы оборачиваем EF в абстракцию и мокаем ее, есть две проблемы. Во-первых, появляется огромное количество бойлерплейта, который утомительно поддерживать. Во-вторых, ощутимая доля настоящей логики оказывается замокана, а следовательно не покрыта тестами.
На моем опыте, самым удобным вариантом оказалось подсунуть EF вместо настоящего провайдера БД провайдер от SQLite InMemory. Они заявляют не 100%, но довольно высокую совместимость с PostgreSQL, как в виде LINQ-запросов, так и в виде голых SQL-запросов. Мелкое оставшееся уже нужно покрывать интеграционными тестами, если это критично.
igoriok
В UoW и репозиторий часто прячут логику проверки или дополнения данных, инвалидации кеша или часто используемые запросы. Бизнес логика при этом становится проще. Как вы и сами написали, для тестирования всей логики целиком, есть интеграционные тесты.
impwx
Да в том-то и дело, что не проще. Я могу понять, когда работа с базой ведется через ADO или Dapper - тогда действительно имеет смысл оборачивать ее в DAL и выставлять наружу осмысленные методы, чтобы в бизнес-логике не нужно было конструировать SQL-запросы. Правда, в таком случае со временем в этом DAL появляется миллион методов с тысячей параметров в каждом, и каждая конкретная комбинация используется всего в одном месте, но это вроде как все еще меньшее из зол. Но если мы говорим про LINQ и ORM вида Linq2DB/EF, то это уже абстракция, причем максимально гибкая. Зачем ее оборачивать в еще одну абстракцию?
Ну а что касается интеграционных тестов - они по своей природе медленные, зачастую на несколько порядков. Поэтому чем больше мест получается протестировать без них, тем лучше.