Published on

Почему плохо возвращать IQueryable в репозитории

Authors
  • avatar
    Name
    Николай Маракасов
    Twitter
    @Twitter

Разделение ответственности в паттерне "Репозиторий"

Репозиторий позволяет абстрагировать логику доступа к данным от доменной логики. Рассмотрим пример:

public interface IOrderRepository
{
    IQueryable<Order> Get();
    Task<int> Add(Order order, CancellationToken cancellationToken);
    Task<int> Update(Order order, CancellationToken cancellationToken);
    Task<int> Delete(Order order, CancellationToken cancellationToken);
}

Это стандартный набор операций, который требуется в большинстве приложений. Однако метод Get() вызывает вопросы:
он возвращает IQueryable<Order>, что даёт возможность выполнять запросы непосредственно к источнику данных.
Entity Framework Core предоставляет свою реализацию этого интерфейса, которой мы часто пользуемся при работе с DbContext.

Проблема подхода с IQueryable

Использование IQueryable на уровне репозитория приводит к следующим проблемам:

  • Логика фильтрации и проекции "вытекает" наверх — в слой бизнес-правил или даже в контроллеры. Это ведёт к дублированию кода.
  • Усложнённое тестирование — проверка таких методов требует заглушек для IQueryable, что неудобно.
  • Жёсткая привязка к ORM — при необходимости заменить Entity Framework Core на Dapper или ADO.NET могут возникнуть сложности, так как они не поддерживают IQueryable.

Альтернативный вариант

Первым шагом заменим возвращаемый тип метода Get() на IEnumerable<Order>:

public interface IOrderRepository
{
    IEnumerable<Order> Get();
    Task<int> Add(Order order, CancellationToken cancellationToken);
    Task<int> Update(Order order, CancellationToken cancellationToken);
    Task<int> Delete(Order order, CancellationToken cancellationToken);
}

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

Разделение на репозитории для чтения и записи

Чтобы избежать перегрузки интерфейса, разделим его на два:

  • IOrderRepository — отвечает за операции записи и используется бизнес-логикой.
  • IOrderReadRepository — предназначен для операций чтения, используя проекции (DTO) и IEnumerable.
// Репозиторий для записи 
public interface IOrderRepository
{
    Task<Order> Get(string orderId);
    Task<int> Add(Order order, CancellationToken cancellationToken);
    Task<int> Update(Order order, CancellationToken cancellationToken);
    Task<int> Delete(Order order, CancellationToken cancellationToken);
}

// Репозиторий для чтения
public interface IOrderReadRepository
{
    Task<IEnumerable<OrderDto>> GetByDate(DateOnly date, CancellationToken ct);
    // Другие методы выборки...
}

Преимущества такого подхода:

Чёткое разделение ответственности — репозиторий для записи используется бизнес-логикой, а репозиторий для чтения инкапсулирует логику выборки данных.
Гибкость в реализацииIOrderReadRepository можно оптимизировать с помощью Dapper, ADO.NET или кеширования (Redis).
Упрощённое тестирование — не нужно мокать IQueryable, достаточно тестировать методы чтения отдельно.

Заключение

Паттерн "Репозиторий" помогает отделить детали работы с данными от бизнес-логики. Однако важно, чтобы абстракция не протекала — то есть не зависела от конкретных технологий. Разделение интерфейсов на чтение и запись делает код более чистым, тестируемым и гибким.