Domain-Driven Design: Repository

9 января 2011 г.

Суть шаблона подробно описана в статьях P of EAA Catalog: Repository и Domain-Driven Design Community: Repository. Шаблон Repository представляет собой фасад для доступа к данным. Repository реализует механизм для хранения, извлечения и поиска объектов в источнике данных.

Сейчас в большинстве случаев шаблон Repository используется для скрытия конкретной ORM (NHibernate, Entity Framework, Linq2Sql, LLBLGen и т.п.) от остальной части приложения. Наравне с этим он может служить для обёртки любого другого источника данных: сервиса, файловой системы, Active Directory и т.п.

В идеале наша система сохраняет и выбирает доменные объекты из Repository так, как будто это коллекция объектов в памяти. Это позволяет нам полностью игнорировать специфику хранения и выборки объектов.

Проблемы при использовании шаблона Repository

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

Проблема: Repository, как фабрика запросов

Объект IQueryable в .NET дает больше возможностей сделать ошибки в проектировании. К примеру, довольно популярным является такая реализация шаблона Repository:

interface IRepository<TEntity>
{
    IQueryable<TEntity> Query();

    // ...
}

class MyOrmAccountRepository : IRepository<Account>
{
    IQueryable<Account> Query()
    {
        // здесь вызов оборачиваемой ORM
    }

    // ...
}

Похоже, что такая реализация является довольно популярной: Хабр: LINQ to SQL: паттерн Repository, GotDotNet: NHibernate: паттерн Repository, Microsoft Blog: Using Repository Pattern with Entity Framework, MSDN: Persistence Patterns.

К чему может привести передача объекта IQueryable из объекта Repository? Рассмотрим ситуацию выборки пользователей:

var accountRepository = new AccountRepository();

List<Account> activeAccounts = accountRepository.Query()
    .Where(x => x.IsDeleted == false)
    .ToList();

Потом ещё где-нибудь в коде:

var accountRepository = new AccountRepository();

var activeAdmins = accountRepository.Query()
    .Where(x => x.Role == AccountRole.Admin)
    .Where(x => x.IsDeleted == false)
    .ToList();

Здесь мы подразумеваем, что в текущих понятиях предметной области активными пользователями являются те, которые не удалены. Причем эти условия x.IsDeleted == false дублируются в разных частях бизнес-логики. Что будет, когда добавится ещё один флаг или состояние у пользователей. Например, флаг IsArchive будет делать пользователей неактивными. Во все выборки пользователей мы должны будем добавить еще одно условие x.IsArchive == false:

var activeAdmins = repository.Query()
    .Where(x => x.Role == AccountRole.Admin)
    .Where(x => x.IsDeleted == false && x.IsArchive == false)
    .ToList();

Теперь понятно, что мы даем пользователям нашего интерфейса IRepository делать запросы какие угодно, где угодно, с дублированием логики. Посмотрим в определение шаблона Repository и станет понятно, что с этой проблемой он был призван бороться: «...adding this layer helps minimize duplicate query logic».

Решение №1

Одним из решений может быть создание специфического метода у объекта Repository, который будет иметь смысловое название. Вот реализация для выборки активных пользователей:

public class AccountRepository
{
    public IEnumerable<Account> GetActiveAccounts()
    {
        return ormDataContext.Query<Account>()
            .Where(x => x.IsDeleted == false && x.IsArchive == false)
            .ToList();
    }
}

В такой реализации выборка активных пользователей вне AccountRepository выглядит так:

var accountRepository = new AccountRepository();

var activeAccounts = accountRepository.GetActiveAccount();

Мы локализовали логику определения активного пользователя только в методе GetActiveAccounts. Теперь при добавлении или удалении флагов у объекта Account изменится только метод в AccountRepository.

Решение №2

Можно использовать шаблон Specification вместе c шаблоном Query Object. Второй шаблон уже реализован в .NET с помощью дерева запросов на LINQ.

Реализация для выборки активных пользователей:

public class ActiveAccountSpecification : ISpecification
{
    public Expression<Func<Account, bool>> IsSatisfiedBy()
    {
        return x => x.IsDeleted == false && x.IsArchive == false;
    }
}

public class AccountRepository
{
    public IEnumerable<Account> Find(ISpecification specification)
    {
        return ormDataContext.Query<Account>()
            .Where(specification.IsSatisfiedBy())
            .ToList();
    }
}


// использование спецификации
var accountRepository = new AccountRepository();

accountRepository.Find(new ActiveAccountSpecification());

Условия объектов Specification можно накладывать друг на друга. Хороший пример есть в Wikipedia: Specification pattern.

Также, эту тему недавно поднимал menozz в гугл-группе DotNetConf: Многокриториальный поиск и паттерн Specification. Там есть несколько примеров кода.

Проблема: Выход специфики DAL за рамки Repository

Эта проблема относится в основном к ORM, которые генерируют код по существующей структуре БД.

Шаблон Repository в основном используется, как обертка над ORM. Он скрывает специфику построения запросов с помощью ORM, инициализации объектов и т.п. Например, вы получили из AccountRepository объект Account. После этого ставите точку и обращаетесь к сгенерированному полю:

var accountRepository = new AccountRepository();

Account account = accountRepository.GetById(1);

// свойство IsChanged сгенерировано ORM
if (account.IsChanged)
{
// ...

Получается, что с помощью Repository мы скрывали источник данных для нашего приложения. В итоге получилось, что используем специфику доступа к данным вне Repository. Более подробно эта проблема описана в статье LLBLGen vs. NHibernate.

Проблема: Один Repository на всех

Попытка создать один универсальный Repository для всех доменных сущностей всегда оборачивается нарушением принципа единственности ответственности и разрастанием этого Repository. Зачастую такие универсальные решения нарушают принцип открытости/закрытости (пример с Repository).

Решение

Нужно разделить реализацию универсального Repository на более маленькие и специфические.

Проблема: Создание Repository для каждого объекта домена

Отдельный объект Repository нужно делать только для корня агрегации, к которому нам нужен непосредственный доступ. Более подробно в статье Domain-Driven Design: aggregation root.

Проблема: Ссылка на Repository из доменного объекта

Ещё одна популярная ошибка, когда в доменном объекте мы можем составлять запросы к БД. Например:

public class Manager
{
    private IEnumerable<customer> customers;

    public IEnumerable<customer> Customers
    {
        get
        {
               return customers ?? (customers = new CustomerRepository().Query().Where(x => x.Manager == this).ToList());
        }
    }
}

В такое реализации мы наделяем доменный объект Manager дополнительными обязанностями:

  • создание CustomerRepository;
  • составление запроса для получения всех клиентов данного менеджера.

Это нарушает как минимум принцип единственности ответственности и инверсии зависимости. Как результат получившаяся система будет менее стабильна.

Решение

Очевидно, что создание запроса нужно вынести в ManagerRepository.

Проблема: использование шаблона Repository без шаблона Unit Of Work

Пример:

Account account = accoutRepository.GetById(1);

Product product = new Product();
accoutRepository.Save(product);

account.AddProduct(product);

accoutRepository.Save(account);

Что будет, если запрос на первом Save() выполнится, а на втором нет? Удалять из БД созданный объект product? Этот код демонстрирует то, что мы можем привести БД в несогласованное состояние.

Решение

Шаблон Unit Of Work реализован практически всеми ORM. Работа с данными должна проходить внутри единицы работы. Более подробно с примерами кода описано в Совершенный код №2. Пример реализации Unit Of Work с NHibernate.

Проблема: Связывание Repository и Unit of Work

Один из вариантов связывания Repository с Unit of Work заключается в том, что мы передаем во все объекты Repository ссылку на созданную Unit of Work. Пример реализации в статье Совместное использование Repository с Unit Of Work:

using (IUnitOfWork uow = new MyDataContext())
{
    ICustomerRepository cr = new EFCustomerRepository(uof);
    IOrderRepository or = new EFOrderRepository(uof);

    // CRUD действия с объектами

    // Завершаем транзакцию.
    uow.Save();
}

Возникает несколько проблем:

  • Каждый Repository получает возможность управлять общей единицей работы. Например, EFCustomerRepository может в любой момент вызвать uow.Save(). Это может привести к ошибкам в логике и нарушает принцип SRP
  • Как мы будем создавать ICustomerRepository с помощью инжекции зависимости, если ему в конструктор требуется экземпляр Unit of Work?
  • Единица работы может создаваться в контроллере, а Repository в сервисе. Тогда придется передавать ссылку на текущий Unit of Work из контроллера в сервис, из сервиса в Repository

Я бы не рекомендовал напрямую связывать Unit of Work и Repository. Решение проблемы связывания приведено в статье Совершенный код №2. Пример реализации Unit Of Work с NHibernate.

Проблема: Смешивание Repository и Specification

Как я уже говорил, шаблон Repository часто пытаются превратить в нечто универсальное. Вот ещё один пример в статье RepositoryUnit: удобный шаблонный репозиторий.

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

Проблема: Смешивание Repository и Unit of Work

Пример ещё одного смешивания. На этот раз Repository и UoW. Пример в статье Entity Framework 4 POCO, Repository and Specification Pattern.

Лучше будет оставлять контроль за транзакцией клиенту объекта Repository, делать этот контроль внешним по отношению к Repository.

Недостаток шаблона Repository

С ростом приложения нам требуется всё больше разных запросов. Объект Repository может вырасти до десятков публичных функций. Нечёткая ответственность делает такой Repository нарушителем SRP.


Ссылки

P of EAA Catalog: Repository

Domain-Driven Design Community: Repository

The NHibernate FAQ: The Repository Pattern

CodeBetter: The Generic Repository

Моя книга «Антихрупкость в IT»

Как достигать результатов в IT-проектах в условиях неопределённости. Подробнее...