The underrated Specification pattern combined with the Repository pattern

Using the specification opened up a new world for me in creating applications

Motivation

Repositories provide a convenient solution for accessing data. However, over many years of development experience, having visited several companies, changed a bunch of projects, I HAVE NOT ENCOUNTERED the “Specification” pattern together with the “Repository” pattern.

Pros:

  • using abstractions to access data is the right solution;

  • the specification offers a standardized approach to creating repositories, which facilitates the development, maintenance and scaling of applications;

  • adding different variations of data queries is reduced to creating one line of code;

  • requests are changed at the user code level – there is no need to change the repository code;

  • The pattern specification allows you to work more flexibly with filters and operations And, Or, Not, etc.

Minuses

I’m sure the guys in the comments will find disadvantages to this approach.
Although I would like to add that for optimized queries to the database, the code needs to be improved (at least in the sense that you can control the order in which dependent entities are included .Include(_ => _.AnotherEntity))


Implementation of the specification

A diagram of my implementation of the pattern is shown in Figure 1 below. It turned out to be quite sufficient for my needs and covers all use cases. I would like to point out that the interface does not have methods And() Or() Not(). Thanks to this there is no violation Interface Segregation Principle.

Fig 1. UML template diagram "Specification"

Fig 1. UML diagram of the “Specification” template

Only 4 classes and a couple of auxiliary ones allow you to achieve HUGE flexibility in forming queries.

To correctly convert our future conditions into expression trees that any data access framework works with, I use Expression<Func>.

ISpecification interface code
/// <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();
}

Method Expression<Func<TEntity, object>>[] GetIncludes()allows you to return functions to include objects in a query.

Method Expression<Func<TEntity, bool>>? SatisfiedBy() is engaged in checking the object for compliance with the conditions listed in Func<TEntity, bool>.

Method OrderModel[] GetOrderModels() returns DTOs storing sorting expressions to sort query results.

OrderModel class code
/// <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
}

Abstract class BaseSpecification<TEntity> contains the properties implementation Skip And Take as well as overloading the AND operators (&) and OR (|). Due to this, there is no need to implement methods And() And Or() to the base interface.

BaseSpecification class code
/// <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);
    }
}

The easiest to implement is DirectSpecification<TEntity>. It allows you to create a single conditional expression to select data.

DirectSpecification class code
/// <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
}

“AND” and “OR” specifications are very similar to each other, their code is given below. Their constructors take two other specifications as arguments ISpecification<TEntity>which can be like composite (also “AND” or “OR”), and simple specifications (for example two implementations via DirectSpecification<TEntity>), and combinations simple And composite specifications.

AndSpecification class code
/// <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 class code
/// <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
}

Both of them implement the method SatisfiedBy() base class SpecificationBase<TEntity>combining two Expression<Func>obtained from calls to methods of two specifications that were passed to the constructor.


Implementation of repositories

The diagram of my implementation of repositories using the Specification pattern is presented in Figure 2 below.

Fig 2. UML diagram of the pattern "Repository" together with "Specification"

Fig 2. UML diagram of the “Repository” pattern together with the “Specification”

In general, the first 4 methods (Get, GetStrict, List, Any), presented in IRepository<TEntity>are implemented once in the base abstract class StorageBase<TEntity> and will never change again.

IStorage interface code
/// <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);
}

Below are the implementations of the methods (Get, GetStrict, List, Any), they are as simple and understandable as possible, but at the same time as flexible as possible, thanks to the specifications.

    /// <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);

And now I imagine a nail, a support, a connecting link without which nothing could work together.

Attentive readers wondered about a strange class that appeared out of nowhere

Yes) this is class SpecificationEvaluator

This class allows you to generate queries based on the passed specification to a database, RAM storage or other data sources. Implementation for IEnumerableis completely similar, I will not cite it to make it easier to understand.

/// <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
}

Application

To make it easier to understand, I will give a specific example. Let’s imagine that we have a class Man – a person with the properties name, age and gender:

/// <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
}

Let’s assume that on our website we implement a multiple filter for each of the fields of this entity. Various variations of the most common repositories, which I find complex, unsupported.

Of course, when it comes to such a task (multiple filtering), every good developer will think about how I can write less code.

However, at the beginning of system development, there may not be such a task explicitly, and there may be fewer fields, which will not be critical for creating repository code, etc.

I will show the example of RAM data storage for easier reproduction.

/// <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);
}

The option above contains a lot of method Getbut even they do not cover all possible filtering options for three fields, which, taking into account the AND and OR operators, I have already counted 11, but what will happen for more fields, and if you still need methods Any() , List() with the same filtering conditions?

Another approach reduces the number of methods in the repository, but increases the number of lines of code in each of them. It is also not optimal. I provided the implementation of only the method Get With И operator It is also necessary to implement Get With ИЛИ And Get with variations И And ИЛИ. All this will take a lot of code and when adding a new property to the Man class, you will have to change each of these methods or add new ones.

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

Now I’ll show you what the repository will look like using the specifications. As you can see, the “repository” doesn’t care at all what kind of request comes to its input. It only deals with receiving and sending data.

/// <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;
}

This is how we added a new entity and a repository for working with it to our system. Now I’ll show you how you can use this repository. For convenience, I’ll create a static class that creates specifications:

/// <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));
}

Then the application would look like this:

    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);
    }

In my opinion, it looks much nicer and clearer what will be returned to us and what the request will look like.


Conclusion

In conclusion, the specification pattern and repository pattern are powerful tools that can help create more efficient, usable, and reliable software systems. They make the development process easier, improve code quality, and simplify its maintenance. For me, they opened up a whole new world where repositories are created at the snap of a finger and maintaining them is hassle-free. I will be glad to see your tips, suggestions and recommendations in the comments.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *