Привет, Хабр!

Сегодня разберём, как реализовать паттерн 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 разработчик»:

Комментарии (11)


  1. DEugene
    16.10.2024 05:33

    Но ведь если вы используете EF, то фреймворк уже заботливо предоставил вам имплементацию таких паттернов как Repository и UnitOfWork. В виде DbSet и DbContext соответственно. Городить поверх них абстракции делающие тоже самое - раздувать кодовую базу проекта, создавая дополнительные точки отказа и поле для ошибок в имплементации.


    1. igoriok
      16.10.2024 05:33

      Для простых проектов может и не нужно. Но с определённого момента написание юнит тестов под логику завязанную на EF может обернутся сущим адом. Интерфейсы IRepository<T> и IUnitOfWork изолируют вашу бизнес логику от инфраструктуры.


      1. impwx
        16.10.2024 05:33

        С подходом, когда мы оборачиваем EF в абстракцию и мокаем ее, есть две проблемы. Во-первых, появляется огромное количество бойлерплейта, который утомительно поддерживать. Во-вторых, ощутимая доля настоящей логики оказывается замокана, а следовательно не покрыта тестами.

        На моем опыте, самым удобным вариантом оказалось подсунуть EF вместо настоящего провайдера БД провайдер от SQLite InMemory. Они заявляют не 100%, но довольно высокую совместимость с PostgreSQL, как в виде LINQ-запросов, так и в виде голых SQL-запросов. Мелкое оставшееся уже нужно покрывать интеграционными тестами, если это критично.


        1. igoriok
          16.10.2024 05:33

          В UoW и репозиторий часто прячут логику проверки или дополнения данных, инвалидации кеша или часто используемые запросы. Бизнес логика при этом становится проще. Как вы и сами написали, для тестирования всей логики целиком, есть интеграционные тесты.


          1. impwx
            16.10.2024 05:33

            Да в том-то и дело, что не проще. Я могу понять, когда работа с базой ведется через ADO или Dapper - тогда действительно имеет смысл оборачивать ее в DAL и выставлять наружу осмысленные методы, чтобы в бизнес-логике не нужно было конструировать SQL-запросы. Правда, в таком случае со временем в этом DAL появляется миллион методов с тысячей параметров в каждом, и каждая конкретная комбинация используется всего в одном месте, но это вроде как все еще меньшее из зол. Но если мы говорим про LINQ и ORM вида Linq2DB/EF, то это уже абстракция, причем максимально гибкая. Зачем ее оборачивать в еще одну абстракцию?

            Ну а что касается интеграционных тестов - они по своей природе медленные, зачастую на несколько порядков. Поэтому чем больше мест получается протестировать без них, тем лучше.


  1. impwx
    16.10.2024 05:33

    Если оба ваших контекста работают на основе одного и того же DbConnection, то не было смысла разделять их на два (тогда DbSet = IRepository, а DbContext = IUnitOfWork). А если на разных, то вам потребуются распределенные транзакции, которые дотнет поддерживает только на Windows.

    Итого - бойлерплейта написали, какую задачу решили непонятно.


    1. AlexViolin
      16.10.2024 05:33

      Если приложение использует несколько DbContext, то транзакция и вместе с ней UnitOfWork должны быть на более высоком уровне, чем DbContext = IUnitOfWork.


      1. impwx
        16.10.2024 05:33

        Так я про это и говорю - в тех случаях, когда в приложении действительно нужно иметь два отдельных контекста, предлагаемый вариант не решает главную проблему отсутствия единой транзакции.


  1. MaNaXname
    16.10.2024 05:33

    Поздравляю Вы изобрели EF!


  1. 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();
       }
    }

    Не идеально, но в большинстве случаев достаточно.


    1. AlexViolin
      16.10.2024 05:33

      В моём представлении транзакция должна быть одна. Если так сделать невозможно, то строится набор алгоритмов по обновлению и откату обновления данных, которые сейчас любят называть Saga.