Привет, Хабр!
Сегодня разберём, как реализовать паттерн 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. Запись по ссылке
Комментарии (5)
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Так я про это и говорю - в тех случаях, когда в приложении действительно нужно иметь два отдельных контекста, предлагаемый вариант не решает главную проблему отсутствия единой транзакции.
DEugene
Но ведь если вы используете EF, то фреймворк уже заботливо предоставил вам имплементацию таких паттернов как Repository и UnitOfWork. В виде DbSet и DbContext соответственно. Городить поверх них абстракции делающие тоже самое - раздувать кодовую базу проекта, создавая дополнительные точки отказа и поле для ошибок в имплементации.
igoriok
Для простых проектов может и не нужно. Но с определённого момента написание юнит тестов под логику завязанную на EF может обернутся сущим адом. Интерфейсы
IRepository<T>
иIUnitOfWork
изолируют вашу бизнес логику от инфраструктуры.