Паттерны: Спецификация

Реализация паттерна спецификация на C# и Entity Framework. Централизация бизнес-логики, улучшение тестируемости и независимость от слоя хранения.
Published on Friday 29 August 2025

Паттерн Спецификация (Specification) объединяет в себе доменный подход к построению приложений и Entity Framework. Этот паттерн создан для управления бизнес-правилами и соединяет наш код с ними. Эта статья показывает пример реализации этого паттерна на базе Entity Framework.

Для начала опишем нашу модель:

public sealed class User
{
    public User()
    {
        Contacts = new List<Contact>();
    }
    public int Id { get; set; }

    public string Login { get; set; }

    public bool IsActive { get; set; }

    public ICollection<Contact> Contacts { get; }
}

public abstract class Contact
{
    public int Id { get; set; }

    public bool IsActive { get; set; }
}

public class Phone : Contact
{
    public string PhoneCode { get; set; }
    public string PhoneNumber { get; set; }
}

public class Email : Contact
{
    public string Value { get; set; }
}

Она достаточно простая, но подходит нам в качестве примера. Теперь добавим немного магии Entity Framework для создания контекста данных:

public sealed class Context : DbContext
{
    public Context(DbContextOptions builderOptions)
        :base(builderOptions)
    {
        
    }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<User>().HasMany(o => o.Contacts).WithOne();
        modelBuilder.Entity<Contact>()
            .HasDiscriminator<int>("ContactType")
            .HasValue<Phone>(1)
            .HasValue<Email>(2);
    }
}

Теперь мы можем использовать SQL Server в качестве хранилища.

Хорошо спроектированные приложения разделяют хранилище и доменную модель, поэтому мы добавим репозиторий для нашего класса User:

public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set<User>().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set<User>().FirstOrDefault(o => o.Id == id);
    }
}

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

Фильтры

Для примера давайте создадим несколько фильтров:

  • Найти всех пользователей с активными телефонами
  • Найти всех пользователей по префиксу логина
  • Найти всех пользователей по телефонному номеру

Путь IQueryable

Самый простой способ - предоставить метод репозитория, который возвращает IQueryable<User> и позволить разработчикам запрашивать любые данные:

public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
    IQueryable<User> Query();
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set<User>().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set<User>().FirstOrDefault(o => o.Id == id);
    }

    public IQueryable<User> Query()
    {
        return _context.Set<User>();
    }
}

С методом Query мы можем реализовать наши фильтры:

public static void Search(IUserRepository repository)
{
    var users1 = repository.Query()
        .Where(o => o.Contacts.Any(c => c is Phone && c.IsActive))
        .ToArray();

    var users2 = repository.Query()
        .Where(o => o.Login.StartsWith("log"))
        .ToArray();

    var users3 = repository.Query()
        .Where(o => o.Contacts.Any(c => c is Phone && (c as Phone).PhoneNumber == "111-22-33"))
        .ToArray();
}

Это быстрое и простое решение, но имеющее серьезные недостатки:

  • Нам нужно повторять код фильтра, если нужно его переиспользовать в другом месте. Можно сделать изменение в одном месте и забыть в другом — вот они, недостатки дублирования кода.
  • Бизнес-логика (наши фильтры) распределены по коду. Мы не можем просто выписать все правила, т.к. они находятся в разных частях приложения.
  • Сложно тестировать. Мы не можем проверить фильтры отдельно от хранилища.

Методы расширения

Можно попробовать устранить недостатки предыдущего решения и собрать все фильтры в одном классе в виде методов расширений:

public static class UserQueryExtensions
{
    public static IEnumerable<User> FindWithActivePhones(this IQueryable<User> query)
    {
        return query.Where(o => o.Contacts.Any(c => c is Phone && c.IsActive))
            .AsEnumerable();
    }

    public static IEnumerable<User> FindWithLoginPrefix(this IQueryable<User> query, string prefix)
    {
        return query.Where(o => o.Login.StartsWith(prefix))
            .AsEnumerable();
    }

    public static IEnumerable<User> FindWithPhoneNumber(this IQueryable<User> query, string phoneNumber)
    {
        return query.Where(o => o.Contacts.Any(c => c is Phone && (c as Phone).PhoneNumber == phoneNumber))
            .AsEnumerable();
    }
}
public static void Search(IUserRepository repository)
{
    var users1 = repository.Query().FindWithActivePhones();

    var users2 = repository.Query().FindWithLoginPrefix("log");

    var users3 = repository.Query().FindWithPhoneNumber("111-22-33");
}

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

[Test]
public void FindWithActivePhonesTest()
{
    var user = new User
    {
        Id = 1,
        Login = "login"
    };
    user.Contacts.Add(new Phone { IsActive = true, PhoneNumber = "123-321" });
    
    var queryable = new[] { user }.AsQueryable();

    var result = queryable.FindWithActivePhones();

    CollectionAssert.AreEquivalent(new[] { 1 }, result.Select(o => o.Id));
}

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

Фильтры в репозитории

Добавим все фильтр-методы в сам репозиторий:

public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
    IEnumerable<User> FindWithActivePhones();
    IEnumerable<User> FindWithLoginPrefix(string prefix);
    IEnumerable<User> FindWithPhoneNumber(string phoneNumber);
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set<User>().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set<User>().FirstOrDefault(o => o.Id == id);
    }

    public IEnumerable<User> FindWithActivePhones()
    {
        return _context.Set<User>().Where(o => o.Contacts.Any(c => c is Phone && c.IsActive))
            .AsEnumerable();
    }

    public IEnumerable<User> FindWithLoginPrefix(string prefix)
    {
        return _context.Set<User>().Where(o => o.Login.StartsWith(prefix))
            .AsEnumerable();
    }

    public IEnumerable<User> FindWithPhoneNumber(string phoneNumber)
    {
        return _context.Set<User>().Where(o => o.Contacts.Any(c => c is Phone && (c as Phone).PhoneNumber == phoneNumber))
            .AsEnumerable();
    }
}
public static void Search(IUserRepository repository)
{
    var users1 = repository.FindWithActivePhones();

    var users2 = repository.FindWithLoginPrefix("log");

    var users3 = repository.FindWithPhoneNumber("111-22-33");
}

Мы избавились от опасного метода Query. Но всё еще есть сложности с тестированием — методы сильно связаны с хранилищем и Entity Framework. Так же, наши фильтры могут использоваться только для доступа к хранилищу. А что, если мы хотим проверить вновь создаваемые сущности этими бизнес-правилами? Сейчас это невозможно.

На самом деле, мы бы могли остановиться на этом решении, его достаточно в 99% случаев.

Но если вы используете Domain-Driven development (DDD), Entity Framework (или другую ORM) и активно используете юнит-тесты, то вам может подойти следующий подход.

Спецификации

Вначале давайте опишем требования, которые для нас важны:

  • Хранить бизнес-правила в одном месте и не дублировать их
  • Фильтры отделены от хранилища (могут быть использованы вне репозитория)
  • Фильтры можно тестировать отдельно

Ключевая сущность паттерна - это спецификация, специальный объект с бизнес-правилом.

public interface ISpecification<T>
{
    Expression<Func<T, bool>> IsSatisfiedBy { get; }
}

Где T - наша бизнес сущность (User как в примерах выше). В репозиторий добавляется метод, который работает со спецификацией:

public interface IUserRepository
{
    void Add(User user);
    User Get(int id);
    IEnumerable<User> Get(ISpecification<User> specification);
}

public sealed class UserRepository : IUserRepository
{
    private readonly Context _context;

    public UserRepository(Context context)
    {
        _context = context;
    }

    public void Add(User user)
    {
        _context.Set<User>().Add(user);
    }

    public User Get(int id)
    {
        return _context.Set<User>().FirstOrDefault(o => o.Id == id);
    }

    public IEnumerable<User> Get(ISpecification<User> specification)
    {
        return _context.Set<User>()
            .Where(specification.IsSatisfiedBy)
            .AsEnumerable();
    }
}

Entity Framework позволяет нам использовать концепцию LINQ to SQL через методы расширения. Нам интересен метод Where с предикатом фильтра:

public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate)

Реализуем наши бизнес-правила (спецификации):

public class ActivePhonesSpecification : ISpecification<User>
{
	public Expression<Func<User, bool>> IsSatisfiedBy =>
		o => o.Contacts.Any(c => c is Phone && c.IsActive);
}

public class LoginPrefixSpecification : ISpecification<User>
{
	public LoginPrefixSpecification(string prefix)
	{
		IsSatisfiedBy = o => o.Login.StartsWith(prefix);
	}
	public Expression<Func<User, bool>> IsSatisfiedBy { get; }
}

public class PhoneNumberSpecification:ISpecification<User>
{
	public PhoneNumberSpecification(string phoneNumber)
	{
		IsSatisfiedBy = o => o.Contacts.Any(c => c is Phone && (c as Phone).PhoneNumber == phoneNumber);
	}
	public Expression<Func<User, bool>> IsSatisfiedBy { get; }
}

Мы описали все правила в одном месте и они отделены от хранилища - работают только с доменной сущностью.

public static void Search(IUserRepository repository)
{
	var users1 = repository.Get(new ActivePhonesSpecification());

	var users2 = repository.Get(new LoginPrefixSpecification("log"));

	var users3 = repository.Get(new PhoneNumberSpecification("111-22-33"));
}

Тестирование:

[Test]
public void FindWithActivePhonesTest()
{
	var user = new User
	{
		Id = 1,
		Login = "login"
	};
	user.Contacts.Add(new Phone { IsActive = true, PhoneNumber = "123-321" });


	var queryable = new[] { user }.AsQueryable();

	var specification = new ActivePhonesSpecification();
	var result = queryable.Where(specification.IsSatisfiedBy);

	CollectionAssert.AreEquivalent(new[] { 1 }, result.Select(o => o.Id));
}

Работа с правилами без хранилища:

[Test]
public void FindWithActivePhonesTest()
{
	var user = new User
	{
		Id = 1,
		Login = "login"
	};
	user.Contacts.Add(new Phone { IsActive = true, PhoneNumber = "123-321" });

	var specification = new ActivePhonesSpecification();

	Assert.IsTrue(specification.IsSatisfiedBy.Compile().Invoke(user));
}

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

Заключение

  • DDD описывает модели, репозитории и правила
  • Entity Framework и LINQ позволяют организовать простой доступ к данным
  • Спецификации хранят правила отдельно от уровня работы с данными

Ссылки