Использование спецификации открыло для меня новый мир в создании приложений

Мотивация

Репозитории предоставляют удобное решение для доступа к данным. Однако за многолетний опыт разработки, побывав в нескольких компаниях, сменив кучу проектов я НЕ ВСТРЕЧАЛ паттерн "Спецификация" совместно с паттерном "Репозиторий".

Плюсы:

  • использование абстракций для доступа к данным – правильное решение;

  • спецификация предлагает стандартизованный подход к созданию репозиториев, что облегчает разработку, сопровождение и масштабирование приложений;

  • добавление разных вариаций запросов данных сводится к созданию одной  строки кода;

  • изменение запросов производится на уровне пользовательского кода — нет необходимости менять код репозитория;

  • паттерн спецификация позволяет более гибко работать с фильтрами и операциями And, Or, Not и т. д.

Минусы

Уверен, ребята в комментариях найдут минусы у этого подхода.
Хотя от себя добавлю, что для оптимизированных запросов к БД, код нужно дорабатывать (хотя бы в том плане, чтобы можно было управлять порядком включения зависимых сущностей .Include(_ => _.AnotherEntity))


Реализация спецификации

Схема моей реализации шаблона представлена на Рисунке 1 ниже. Для моих потребностей она оказалась вполне достаточной и покрывающий все кейсы использования. Хочу обратить внимание, что у интерфейса нет методов And() Or() Not(). Благодаря этому нет нарушения Interface Segregation Principle.

Рис 1. UML схема шаблона "Спецификация"
Рис 1. UML схема шаблона "Спецификация"

Всего 4 класса и пара вспомогательных позволяют достичь ОГРОМНОЙ гибкости в формировании запросов.

Для корректного преобразования наших будущих условий в деревья выражений, с которыми работает любой фреймворк доступа к данным, использую Expression<Func>.

Код интерфейса ISpecification<TEntity>
/// <summary>
///     Базовый интерфейс спецификации
/// </summary>
public interface ISpecification<TEntity> : ICloneable
    where TEntity : class
{
    /// <summary>
    ///     Сколько объектов пропустить
    /// </summary>
    int Skip { get; }

    /// <summary>
    ///     Сколько объектов взять
    /// </summary>
    int Take { get; }

    /// <summary>
    ///     Получить необходимые поля для включения
    /// </summary>
    Expression<Func<TEntity, object>>[] GetIncludes();

    /// <summary>
    ///     Удовлетворяет ли объект условиям
    /// </summary>
    Expression<Func<TEntity, bool>>? SatisfiedBy();

    /// <summary>
    ///     Получить модели для сортировки результатов
    /// </summary>
    OrderModel<TEntity>[] GetOrderModels();
}

Метод Expression<Func<TEntity, object>>[] GetIncludes()позволяет возвращать функции включения объектов в запрос.

Метод Expression<Func<TEntity, bool>>? SatisfiedBy() занимается проверкой объекта на соответствие условиям перечисленным в Func<TEntity, bool>.

Метод OrderModel[] GetOrderModels() возвращает DTO, хранящие сортирующие выражения, для сортировки результатов запроса.

Код класса OrderModel<TEntity>
/// <summary>
///     Модель для хранения сортирующего выражения
/// </summary>
public class OrderModel<TEntity>
    where TEntity : class
{
    #region .ctor

    /// <inheritdoc cref="OrderModel{TEntity}" />
    public OrderModel(Expression<Func<TEntity, object>> orderExpression, bool needOrderByDescending)
    {
        OrderExpression = orderExpression;
        NeedOrderByDescending = needOrderByDescending;
    }

    #endregion

    #region Properties

    /// <summary>
    ///     Сортирующее выражение
    /// </summary>
    public Expression<Func<TEntity, object>> OrderExpression { get; }

    /// <summary>
    ///     Нужна ли сортировка по убыванию
    /// </summary>
    public bool NeedOrderByDescending { get; }

    #endregion
}

Абстрактный класс BaseSpecification<TEntity> содержит реализацию свойств Skip и Take , а также перегрузки операторов И (&) и ИЛИ (|). Благодаря чему нет необходимости внедрять методы And() и Or() в базовый интерфейс.

Код класса BaseSpecification<TEntity>
/// <summary>
///     Базовая спецификация для коллекций объектов
/// </summary>
public abstract class SpecificationBase<TEntity> : ISpecification<TEntity>
    where TEntity : class
{
    #region Implementation of ISpecification

    /// <inheritdoc />
    public int Skip { get; set; } = 0;

    /// <inheritdoc />
    public int Take { get; set; } = int.MaxValue;

    /// <inheritdoc />
    public abstract Expression<Func<TEntity, bool>>? SatisfiedBy();

    /// <inheritdoc />
    public abstract Expression<Func<TEntity, object>>[] GetIncludes();

    /// <inheritdoc />
    public abstract OrderModel<TEntity>[] GetOrderModels();

    /// <inheritdoc />
    public abstract object Clone();

    #endregion

    /// <summary>
    ///     Перегрузка оператора И
    /// </summary>
    public static SpecificationBase<TEntity> operator &(
        SpecificationBase<TEntity> left,
        SpecificationBase<TEntity> right)
    {
        return new AndSpecification<TEntity>(left, right);
    }

    /// <summary>
    ///     Перегрузка оператора ИЛИ
    /// </summary>
    public static SpecificationBase<TEntity> operator |(
        SpecificationBase<TEntity> left,
        SpecificationBase<TEntity> right)
    {
        return new OrSpecification<TEntity>(left, right);
    }
}

Самой простой в реализации является DirectSpecification<TEntity>. Она позволяет создавать одно условное выражение для выбора данных.

Код класса DirectSpecification<TEntity>
/// <summary>
///     Прямая спецификация
/// </summary>
public class DirectSpecification<TEntity> : SpecificationBase<TEntity>
    where TEntity : class
{
    #region Fields

    private readonly List<Expression<Func<TEntity, object>>> _includes = new();
    private readonly Expression<Func<TEntity, bool>>? _matchingCriteria;
    private OrderModel<TEntity>? _orderModel;

    #endregion

    #region .ctor

    /// <inheritdoc cref="DirectSpecification{TEntity}" />
    public DirectSpecification(Expression<Func<TEntity, bool>> matchingCriteria)
    {
        _matchingCriteria = matchingCriteria;
    }

    /// <inheritdoc cref="DirectSpecification{TEntity}" />
    public DirectSpecification()
    { }

    /// <inheritdoc cref="DirectSpecification{TEntity}" />
    protected DirectSpecification(
        List<Expression<Func<TEntity, object>>> includes,
        Expression<Func<TEntity, bool>>? matchingCriteria,
        OrderModel<TEntity>? orderModel)
    {
        _includes = includes;
        _matchingCriteria = matchingCriteria;
        _orderModel = orderModel;
    }

    #endregion

    #region Implementation of SpecificationBase

    /// <inheritdoc />
    public override object Clone()
    {
        // NOTE: поскольку список не смотрит из объекта явно,
        // то нет необходимости перекопировать его полностью включая внутренние элементы
        // аналогично и с моделью сортировки, считается, что она неизменяемая
        return new DirectSpecification<TEntity>(_includes, _matchingCriteria, _orderModel);
    }

    /// <inheritdoc />
    public override Expression<Func<TEntity, bool>>? SatisfiedBy()
        => _matchingCriteria;

    /// <inheritdoc />
    public override Expression<Func<TEntity, object>>[] GetIncludes()
        => _includes.ToArray();

    /// <inheritdoc />
    public override OrderModel<TEntity>[] GetOrderModels()
    {
        return _orderModel is null ? Array.Empty<OrderModel<TEntity>>() : new[] { _orderModel };
    }

    #endregion

    #region Public methods

    /// <summary>
    ///     Добавить включение
    /// </summary>
    public DirectSpecification<TEntity> AddInclude(Expression<Func<TEntity, object>> includeExpression)
    {
        _includes.Add(includeExpression);

        return this;
    }

    /// <summary>
    ///     Установить модель сортировки
    /// </summary>
    public DirectSpecification<TEntity> SetOrder(OrderModel<TEntity> orderModel)
    {
        _orderModel = orderModel;

        return this;
    }

    #endregion
}

"И" и "ИЛИ" спецификации между собой очень похожи, их код приведен ниже. Их конструкторы принимают в аргументах две другие спецификации ISpecification<TEntity>, которые могут быть как составными (тоже "И" или "ИЛИ"), так и простые спецификации (например две реализации через DirectSpecification<TEntity>), так и комбинации простой и составной спецификации.

Код класса AndSpecification<TEntity>
/// <summary>
///     Спецификация И
/// </summary>
public sealed class AndSpecification<TEntity> : SpecificationBase<TEntity>
   where TEntity : class
{
    #region Fields

    private readonly ISpecification<TEntity> _rightSideSpecification;
    private readonly ISpecification<TEntity> _leftSideSpecification;

    #endregion

    #region .ctor

    /// <inheritdoc />
    public override object Clone()
    {
        var left = (ISpecification<TEntity>)_leftSideSpecification.Clone();
        var right = (ISpecification<TEntity>)_leftSideSpecification.Clone();

        return new AndSpecification<TEntity>(left, right);
    }

    /// <inheritdoc cref="AndSpecification{TEnity}" />
    public AndSpecification(
        ISpecification<TEntity> leftSide,
        ISpecification<TEntity> rightSide)
    {
        Assert.NotNull(leftSide, "Left specification cannot be null");
        Assert.NotNull(rightSide, "Right specification cannot be null");

        _leftSideSpecification = leftSide;
        _rightSideSpecification = rightSide;
    }

    #endregion

    #region Implementation Of SpecificationBase

    /// <inheritdoc />
    public override Expression<Func<TEntity, bool>>? SatisfiedBy()
    {
        var left = _leftSideSpecification.SatisfiedBy();
        var right = _rightSideSpecification.SatisfiedBy();
        if (left is null && right is null)
        {
            return null;
        }

        if (left is not null && right is not null)
        {
            return left.And(right);
        }

#pragma warning disable IDE0046 // Convert to conditional expression
        if (left is not null)
        {
            return left;
        }
#pragma warning restore IDE0046 // Convert to conditional expression

        return right;
    }

    /// <inheritdoc />
    public override Expression<Func<TEntity, object>>[] GetIncludes()
    {
        var leftIncludes = _leftSideSpecification.GetIncludes();
        var rightIncludes = _rightSideSpecification.GetIncludes();

        leftIncludes.AddRange(rightIncludes);

        return leftIncludes;
    }

    /// <inheritdoc />
    public override OrderModel<TEntity>[] GetOrderModels()
    {
        var leftOrderModels = _leftSideSpecification.GetOrderModels();
        leftOrderModels.AddRange(_rightSideSpecification.GetOrderModels());

        return leftOrderModels;
    }

    #endregion
}

Код класса OrSpecification<TEntity>
/// <summary>
///     Спецификация ИЛИ
/// </summary>
public class OrSpecification<TEntity> : SpecificationBase<TEntity>
    where TEntity : class
{
    #region Fields

    private readonly ISpecification<TEntity> _leftSideSpecification;
    private readonly ISpecification<TEntity> _rightSideSpecification;

    #endregion

    #region .ctor

    /// <inheritdoc cref="OrSpecification{TEnity}" />
    public OrSpecification(
        ISpecification<TEntity> left,
        ISpecification<TEntity> right)
    {
        Assert.NotNull(left, "Left specification cannot be null");
        Assert.NotNull(right, "Right specification cannot be null");

        _leftSideSpecification = left;
        _rightSideSpecification = right;
    }

    #endregion

    #region Implemtation of SpecificationBase

    /// <inheritdoc />
    public override object Clone()
    {
        var left = (ISpecification<TEntity>)_leftSideSpecification.Clone();
        var  right = (ISpecification<TEntity>)_leftSideSpecification.Clone();

        return new OrSpecification<TEntity>(left, right);
    }

    /// <inheritdoc />
    public override Expression<Func<TEntity, bool>>? SatisfiedBy()
    {
        var left = _leftSideSpecification.SatisfiedBy();
        var right = _rightSideSpecification.SatisfiedBy();
        if (left is null && right is null)
        {
            return null;
        }

        if (left is not null && right is not null)
        {
            return left.Or(right);
        }

#pragma warning disable IDE0046 // Convert to conditional expression
        if (left is not null)
        {
            return left;
        }

        return right;
    }

    /// <inheritdoc />
    public override Expression<Func<TEntity, object>>[] GetIncludes()
    {
        var leftIncludes = _leftSideSpecification.GetIncludes();
        var rightIncludes = _rightSideSpecification.GetIncludes();

        leftIncludes.AddRange(rightIncludes);

        return leftIncludes;
    }

    /// <inheritdoc />
    public override OrderModel<TEntity>[] GetOrderModels()
    {
        var leftOrderModels = _leftSideSpecification.GetOrderModels();
        leftOrderModels.AddRange(_rightSideSpecification.GetOrderModels());

        return leftOrderModels;
    }

    #endregion
}

Обе они реализуют метод SatisfiedBy() базового класса SpecificationBase<TEntity>, объединяя два Expression<Func>, полученных от вызовов методов двух спецификаций, которые были переданы в конструктор.


Реализация репозиториев

Схема моей реализации репозиториев совместно с использованием паттерна "Спецификация" представлена на Рисунке 2 ниже.

Рис 2. UML схема паттерна "Репозиторий" совместно со "Спецификацией"
Рис 2. UML схема паттерна "Репозиторий" совместно со "Спецификацией"

В целом первые 4 метода (Get, GetStrict, List, Any), представленные в IRepository<TEntity>, реализуются единожды в базовом абстрактном классе StorageBase<TEntity> и больше никогда не изменятся.

Код интерфейса IStorage<TEntity>
/// <summary>
///     Общий интерфейс хранилищ
/// </summary>
public interface IStorage<T>
    where T : class
{
    /// <summary>
    ///     Добавляет новую модель в хранилище
    /// </summary>
    void Add(T model);
    
    /// <summary>
    ///     Удалить
    /// </summary>
    void Remove(T model);

    /// <summary>
    ///     Находит модель по идентификатору
    /// </summary>
    /// <param name="specification"> Спецификация получения данных </param>
    /// <returns> Модель </returns>
    T? Get(ISpecification<T> specification);

    /// <summary>
    ///     Находит модель по идентификатору, бросает ошибку, если не найдено
    /// </summary>
    /// <param name="specification"> Спецификация получения данных </param>
    /// <param name="errorCode"> Код ошибки, если модель не найдена </param>
    /// <returns> Модель </returns>
    /// <exception cref="ErrorException">
    ///     Ошибка с кодом <paramref name="errorCode" />, если модель не найдена
    /// </exception>
    T GetStrict(ISpecification<T> specification, string errorCode);

    /// <summary>
    ///     Определяет соответствуют ли выбранные объекты условиям спецификации
    /// </summary>
    /// <param name="specification"> Спецификация </param>
    bool Any(ISpecification<T> specification);

    /// <summary>
    ///     Получить сущности
    /// </summary>
    /// <param name="specification"> Спецификация </param>
    IEnumerable<T> GetMany(ISpecification<T> specification);
}

Ниже приведены реализации методов (Get, GetStrict, List, Any), они максимально просты и понятны, но при этом максимально "гибкие", благодаря спецификациям.

    /// <inheritdoc />
    public bool Any(ISpecification<T> specification)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification)
              .Any();

    /// <inheritdoc />
    public T? Get(ISpecification<T> specification)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification)
              .FirstOrDefault();

    /// <inheritdoc />
    public T GetStrict(ISpecification<T> specification, string errorCode)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification)
              .FirstOrDefault() ?? throw new ErrorException(errorCode);

    /// <inheritdoc />
    public IEnumerable<T> GetMany(ISpecification<T> specification)
        => SpecificationEvaluator
              .GetQuery(CreateQuery(), specification);

А теперь представляю гвоздь, опору, связующее звено без которого ничего не могло бы работать вместе.

Внимательные читатели задались вопросом про странный класс, появившийся непонятно откуда

Да) это класс SpecificationEvaluator

Этот класс позволяет формировать запросы на основе переданной спецификации к базе данных, хранилищам в RAM или другим источникам данных. Реализация для IEnumerableполностью аналогична, ее не буду приводить, для облегчения восприятия.

/// <summary>
///     Создает запрос к базе данных на основе спецификации
/// </summary>
public class SpecificationEvaluator
{
    #region IQueryable

    /// <summary>
    ///     Получить сформированный запрос
    /// </summary>
    public static IQueryable<TEntity> GetQuery<TEntity>(
        IQueryable<TEntity> inputQuery,
        ISpecification<TEntity> specification)
        where TEntity : class
    {
        var query = inputQuery;

        // включаю в запрос необходимые дополнительные сущности
        query = specification
            .GetIncludes()
            .Aggregate(query, static (current, include) => current.Include(include));

        // отбираю только необходимые объекты
        var whereExp = specification.SatisfiedBy();
        if (whereExp is not null)
        {
            query = query.Where(whereExp)!;
        }

        // получаю модели для сортировки
        var orderModels = specification.GetOrderModels();
        if (!orderModels.Any())
        {
            return query
                .Skip(specification.Skip)
                .Take(specification.Take);
        }

        // сортирую
        var orderedQuery = AddFirstOrderExpression(query, orderModels.First());
        foreach (var orderModel in orderModels.Skip(1))
        {
            orderedQuery = AddAnotherOrderExpression(orderedQuery, orderModel);
        }

        return orderedQuery
            .Skip(specification.Skip)
            .Take(specification.Take);
    }

    /// <summary>
    ///     Добавить сортировку в самый первый раз
    /// </summary>
    private static IOrderedQueryable<TEntity> AddFirstOrderExpression<TEntity>(
        IQueryable<TEntity> query,
        OrderModel<TEntity> orderModel)
        where TEntity : class
    {
        return orderModel.NeedOrderByDescending
            ? query.OrderByDescending(orderModel.OrderExpression)
            : query.OrderBy(orderModel.OrderExpression);
    }

    /// <summary>
    ///     Продолжить добавление сортировок
    /// </summary>
    private static IOrderedQueryable<TEntity> AddAnotherOrderExpression<TEntity>(
        IOrderedQueryable<TEntity> query,
        OrderModel<TEntity> orderModel)
        where TEntity : class
    {
        return orderModel.NeedOrderByDescending
            ? query.ThenByDescending(orderModel.OrderExpression)
            : query.ThenBy(orderModel.OrderExpression);
    }

    #endregion
}

Применение

Для упрощения понимания использования, приведу конкретный пример. Представим, что у нас есть класс Man - человек со свойствами имя, возраст и пол:

/// <summary>
///     Человек
/// </summary>
internal sealed class Man
{
    /// <summary>
    ///     Возраст
    /// </summary>
    public int Age { get; set; }

    /// <summary>
    ///     Имя
    /// </summary>
    public string Name { get; set; }

    /// <summary>
    ///     Пол
    /// </summary>
    public GenderType Gender { get; set; }
}

/// <summary>
///     Определяет пол человека
/// </summary>
internal enum GenderType
{
    /// <summary>
    ///     Мужчина
    /// </summary>
    Male,

    /// <summary>
    ///     Женщина
    /// </summary>
    Female
}

Предположим, на нашем сайте мы реализуем множественный фильтр по каждому из полей этой сущности. Различные варианты самых распространенных репозиториев, которые я считаю сложными, неподдерживаемыми.

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

Однако в начале разработки системы, может не стоять такой задачи явно, и полей может быть меньше, что не будет критичным для создания кода репозитория и т.п.

Буду показывать на примере RAM хранилища данных для более легкого воспроизведения.

/// <summary>
///     Самое неоптимальное хранилище моделей людей
/// </summary>
internal sealed class BadManRepository1 : StorageBase<Man>
{
    private ImmutableArray<Man> _storage = ImmutableArray<Man>.Empty;

    /// <inheritdoc />
    public override void Add(Man model)
    {
        _storage = _storage.Add(model);
    }

    /// <inheritdoc />
    public override void Remove(Man model)
    {
        _storage = _storage.Remove(model);
    }

    /// <inheritdoc />
    public Man Get(string name)
        => CreateQuery().FirstOrDefault(_ => _.Name == name);

    /// <inheritdoc />
    public Man Get(int age)
        => CreateQuery().FirstOrDefault(_ => _.Age == age);

    /// <inheritdoc />
    public Man Get(GenderType gender)
        => CreateQuery().FirstOrDefault(_ => _.Gender == gender);

    /// <inheritdoc />
    public Man Get(string name, int age)
        => CreateQuery().FirstOrDefault(_ => _.Name == name && _.Age == age);

    /// <inheritdoc />
    public Man Get(string name, GenderType gender)
        => CreateQuery().FirstOrDefault(_ => _.Name == name && _.Gender == gender);

    /// <inheritdoc />
    public Man Get(string name, int age, GenderType gender)
        => CreateQuery().FirstOrDefault(_ => _.Name == name && _.Age == age && _.Gender == gender);

    /// <inheritdoc />
    public Man Get(string name, int age)
        => CreateQuery().FirstOrDefault(_ => _.Name == name || _.Age == age);
}

Вариант выше содержит много метода Get, но даже они не покрывают всех возможных вариантов фильтрации для трех полей, которых с учетом операторов И и ИЛИ уже я насчитал 11, а что будет для большего количества полей, а если еще нужны методы Any() , List() с такими же условиями фильтрации?

Другой подход уменьшает количество методов в репозитории, но увеличивает количество строк кода в каждом из них. Он тоже не является оптимальным. Привел реализацию только метода Get с И оператором. Нужно также реализовать Get с ИЛИ и Get с вариациями И и ИЛИ. Все это займет кучу кода и при добавлении нового свойства в класс Man, придется изменять каждый из этих методов или добавлять новые.

/// <summary>
///     Неоптимальное хранилище моделей людей
/// </summary>
internal sealed class BadManRepository2 : StorageBase<Man>
{
    private ImmutableArray<Man> _storage = ImmutableArray<Man>.Empty;

    /// <inheritdoc />
    public Man Get(GetManRequest request)
    {
        var query = CreateQuery();
        if (request.Name is not null)
        {
            query = query.Where(x => x.Name == request.Name);
        }
        if (request.Age is not null)
        {
            query = query.Where(x => x.Age == request.Age);
        }
        if (request.Gender is not null)
        {
            query = query.Where(x => x.Gender == request.Gender);
        }

        return query.FirstOrDefault();
    }
}

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

/// <summary>
///     Хранилище моделей людей
/// </summary>
internal sealed class ManRepository : StorageBase<Man>
{
    private ImmutableArray<Man> _storage = ImmutableArray<Man>.Empty;

    /// <inheritdoc />
    public override void Add(Man model)
    {
        _storage = _storage.Add(model);
    }

    /// <inheritdoc />
    public override void Remove(Man model)
    {
        _storage = _storage.Remove(model);
    }

    /// <inheritdoc />
    protected override IEnumerable<Man> CreateQuery() => _storage;
}

Вот так мы добавили в нашу систему новую сущность и репозиторий для работы с ней. Теперь покажу как можно использовать этот репозиторий. Для удобства создам статический класс, создающий спецификации:

/// <summary>
///     Статический класс для создания спецификации для получения <see cref="Man" />
/// </summary>
internal static class ManSpecification
{
    /// <summary>
    ///     С именем
    /// </summary>
    public static ISpecification<Man> WithName(string name)
        => new DirectSpecification<Man>(_ => _.Name == name);

    /// <summary>
    ///     С возрастом
    /// </summary>
    public static ISpecification<Man> WithAge(int age)
        => new DirectSpecification<Man>(_ => _.Age == age);

    /// <summary>
    ///     С гендером
    /// </summary>
    public static ISpecification<Man> WithGender(GenderType gender)
        => new DirectSpecification<Man>(_ => _.Gender == gender);

    /// <summary>
    ///     Сортировать по возрасту
    /// </summary>
    public static ISpecification<Man> OrderByAge(bool orderByDescending = false)
        => new DirectSpecification<Man>().SetOrder(new(static _ => _.Age, orderByDescending));

    /// <summary>
    ///     Сортировать по имени
    /// </summary>
    public static ISpecification<Man> OrderByName(bool orderByDescending = false)
        => new DirectSpecification<Man>().SetOrder(new(static _ => _.Name, orderByDescending));
}

Тогда применение будет выглядеть следующим образом:

    public static int Main()
    {
        var repository = new ManRepository();

        var spec1 = ManSpecification.WithName("Коля")
            & ManSpecification.WithAge(26);

        var man1 = repository.Get(spec1);

        var spec2 = ManSpecification.WithName("Коля") | ManSpecification.WithAge(26);
        var men2 = repository.Get(spec2);

        var spec3 = (ManSpecification.WithName("Женя") | ManSpecification.WithAge(26))
            & ManSpecification.WithGender(GenderType.Male);
        var men3 = repository.Get(spec2);

        var spec4 = (ManSpecification.WithName("Женя") | ManSpecification.WithAge(26))
            & ManSpecification.WithGender(GenderType.Male)
            & ManSpecification.OrderByAge();
        var orderedMen4 = repository.Get(spec2);
    }

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


Заключение

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

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


  1. IvanG
    02.12.2023 20:52

    Однако за многолетний опыт разработки, побывав в нескольких компаниях, сменив кучу проектов я НЕ ВСТРЕЧАЛ паттерн "Спецификация" совместно с паттерном "Репозиторий".

    Сочувствую вашему профессиональному опыту, думаю что все таки репозиторий с спецификацией часто объединяют (если не ошибаюсь даже в видео Дмитрия Нестерука спецификация через фильтрацию к базе иллюстрирована), вот одмн из опенсорс проектов: https://github.com/NikitaEgorov/nuSpec


    1. IvanG
      02.12.2023 20:52

      Ошибся, у Нестерука без базы, просто на коллекции фильтр https://youtu.be/_Fec7ZsVn44?si=GR_s8AAmh3NlPFYc&t=503


  1. Vitimbo
    02.12.2023 20:52
    +3

    Очень похоже, что кто-то пытается переизобрести Biarity/Sieve: ⚗️ Clean & extensible Sorting, Filtering, and Pagination for ASP.NET Core (github.com)
    Как по мне, спецификации слишком "специфичны", как и сам репозиторий и можно обойтись без них.


    1. NeoNN
      02.12.2023 20:52

      Отличная библиотека, кстати.


    1. IvanG
      02.12.2023 20:52
      +2

      Репозитории бывают дженириковыми, когда по сути один класс и один интерфейс, а в конструктор ты просто с указанием типа принимаешь, очень удобно работать.


      1. Vitimbo
        02.12.2023 20:52
        +1

        Ага, и куча не всегда нужных не универсальных методов, не говоря уже о том, что неплохо было бы вообще не работать с реальными моделями, а хотя-бы с Dto.

        Могут быть сущности, для которых, в принципе, не подразумевается удаление. Например, не стоит удалять пользователя. А этот дженерик такую возможность даст, так ещё и не отключишь.


        1. IvanG
          02.12.2023 20:52

          куча не всегда нужных не универсальных методов

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

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


          1. Vitimbo
            02.12.2023 20:52

            А зачем такая прокладка над dbset-ами?

            То, что юзера нельзя удалять это не костыль, так как на него каскадно ссылается много чего. А если мы ещё и с деньгами дело имеем, то выпиливать контрагента совсем не стоит


            1. AgentFire
              02.12.2023 20:52

              Удаление пользователя легко запретить, наследуя конкретный IUserRepostory не от IGenericRepository<User>, а от ICanRead<User>, ICanUpdate<User>, GenericRepository<User>, но не ICanDelete<User>, и где GenericRepository<> - скрытый от потребителя тип.

              Но согласен с тем, что это лишняя абстракция. DbSet - это уже репозиторий, отвязанный от конкретной БД. Смысл иметь два?


        1. muturgan
          02.12.2023 20:52

          Переопределить метод удаления в реализации репозитория пользователя, не?


          1. Vitimbo
            02.12.2023 20:52
            +1

            Тогда получишь просто гениальный код вида

            await DeleteAsync<User>(userId); //nothing happent

            И это все еще не объясняет, зачем делать обертку из "паттерна" спецификация над "паттерном" репозиторий, которые в свою очередь оборачивают dbset, который уже является репозиторием и ef, как спецификация к нему.


  1. Kergan88
    02.12.2023 20:52
    +4

    А за чем делать свою дополнительную обертку, если в языке уже есть встроенная реализация паттерна в виде Expression? С репозиторием аналогично - любая ормка же предоставляет реализацию паттерна из коробки.


    1. ryanl
      02.12.2023 20:52
      +1

      Согласен, а сам ванильный паттерн выглядит невероятно громоздким на этом фоне.


  1. Gromilo
    02.12.2023 20:52
    +1

    Я использовал подобный подход и по итогу запросы из репозитария расползаются по всей программе и реализованы немного по разному. За этим нужно следить.

    В итоге использую всевозможные построили запросов только внутри репозитария. Это внутреннее дело репы, как оно там всё хранится и как строить запросы. Но у меня своя специфика в виде даппера.

    Чтобы справиться с комбинаторным взрывом использую концепцию фильтров, когда null обозначает отсутствие фильтра. Например: GetEmployees(Sex? sexFilter = null, int? minAgeFilter = null, ...). При вызове явно указываю названия использованных фильтров, чтобы не было GetEmployees(false, 24).

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


  1. buldo
    02.12.2023 20:52
    +2

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

    В конечном счёте, разве IQueryable - это не реализация паттерна спецификация?

    Upd. Немного подумал и понял, что со спецификацией возможно удобнее делать условия OR


  1. MadL1me
    02.12.2023 20:52
    +10

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

    В случае подхода со спецификацией, вместо условного метода репозитория с sql запросом или ORM, мы получаем кучу новых классов, которые надо поддерживать, про которые надо знать, которые являются проектно специфичными (в отличие от sql или ORM'ов, API которых знают все), а значит и увеличивается стоимость поддержки, онбординга новых разработчиков в проект.

    Предложенная вами система с спецификациями это создание новой абстракции поверх существующей (ОRM) поверх существующей (SQL). Но ради каких профитов? Ваши плюсы не являются плюсами перед существующим подходами:

    > использование абстракций для доступа к данным – правильное решение;

    Репозиторий как паттерн - это и есть абстракция для доступа к данным. Она инкапсулирует в себя низкоуровневые запросы к дравйверу бд и возвращает доменную модель.

    > спецификация предлагает стандартизованный подход к созданию репозиториев, что облегчает разработку, сопровождение и масштабирование приложений;

    Как будут выглядеть запросы с join? Кажется, как будто здесь также придется делать отдельные non-generic методы для сложных запросов, в итоге вся унификация теряется.

    > Добавление разных вариаций запросов данных сводится к созданию одной  строки кода;

    В вашем же примере BadManRepository2 это делается точно также - вы меняете Request модель, и затем добавляете 1 строчку изменений в репозиторий (Where).

    > изменение запросов производится на уровне пользовательского кода — нет необходимости менять код репозитория;

    В вашем случае необходимости нет, потому что ответственность репозитория расслаивается по проекту на спецификации. Фактически, меняя спецификации вы напрямую аффектите генерируемые запросы, тем самым tight coupling как оставался так и продолжает оставаться

    Кол-во оверхеда, как со стороны когнитивной нагрузки разработчиков, так и со стороны производительности просто напросто того не стоят.


    1. FanToMaS-api Автор
      02.12.2023 20:52
      +2

      Большое спасибо за подробный комментарий, приму во внимание ваши замечания


  1. michael_v89
    02.12.2023 20:52

    var query = CreateQuery();
    
    return query.FirstOrDefault();
    

    Объект query сам по себе неплохая спецификация.
    Только использовать его лучше так.

    class ManService {
      public void doSomething() {
        var query = CreateQuery();
        query = query.Where(x => x.departmentId == 123);
    
        List<Man> manList = this.manRepository.findByQuery(query);
      }
    }
    

    В этом случае можно менять реализацию manRepositоry как угодно, он не обязательно будет привязан к SQL. Я так понимаю, у вас примерно это и получилось, repository.Get(spec2) это аналог findByQuery.
    Если query в используемой ORM слишком низкоуровневый, то да, можно сделать более абстрактную обертку. Проблема с методами типа WithAge в том, что со временем понадобится не только 'равно', а еще 'больше' и 'меньше', и придется или делать метод на каждую комбинацию, или универсальный механизм задания операторов, и получится тот же самый query builder.

    class BadManRepository2 {
      public Man Get(GetManRequest request) {
      }
    }
    

    GetManRequest это должно быть входное DTO, где поля заполняются пользователем, список полей соответствует полям ввода пользовательского интерфейса. Оно должно использоваться в сервисе, а не в репозитории.
    В приложении не должно быть внутренних Request DTO, они быстро превращаются в God-object со всеми возможными полями и операторами, каждое из которых может быть null. Должны быть или специфичные методы репозитория findBySomething, или query builder.

    class ManService {
      public PaginatedResult<Man> list(GetManRequest request) {
        if (request.Name is not null) {
            query = query.Where(x => x.Name == request.Name);
        }
    
        pageSize = 30;
        query = query.offset((request.Page - 1) * pageSize);
        query = query.limit(pageSize);
    
        List<Man> result = this.manRepository.findByQuery(query);
        int total = this.manRepository.findByQuery(query->count());
        int totalPages = Math.ceil(total / pageSize);
    
        return new PaginatedResult(request->page, totalPages, result);
    }
    


    1. FanToMaS-api Автор
      02.12.2023 20:52

      Понял, приму во внимание, спасибо


  1. tsvettsih
    02.12.2023 20:52

    Как скомбинировать спецификации, у которых разные Skip и Take?


    1. FanToMaS-api Автор
      02.12.2023 20:52

      Хороший вопрос, не думал об этом, по идее, такая ситуация при моей реализации возникнуть может.

      Скорее всего будут использованы skip и take последней спецификации, у которой они проставлены.

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

      Если отвечать на вопрос прямо, при текущей реализации, возможно менять Skip и take извне. Поэтому после объединения двух разных спецификаций, можно поставить необходимые Skip и Take


      1. tsvettsih
        02.12.2023 20:52

        А есть ссылка на гитхаб с рабочим сэмплом?


  1. AlexViolin
    02.12.2023 20:52

    Возник вопрос - почему в иерархии наследования репозиториев появляется класс StorageBase<Entity>, хотя совершенно логично его назвать ARepository<Entity>. В чём причина такого названия для базового класса?