Паттерн Спецификация (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 позволяют организовать простой доступ к данным
- Спецификации хранят правила отдельно от уровня работы с данными