Блеск и нищета модели предметной области
DDD-жаргон
Прежде чем начать соревнование, напомню DDD-жаргон. Модель не монолитна, а разделена на несколько ограниченных контекстов. Вопрос соотношения терминов домен, субдомен и ограниченный контекст я оставляю за скобками, потому что он не важен в рамках доклада. Существование ограниченных контекстов объясняется организационными причинами. Чаще всего невозможно создать единую модель для всего предприятия, потому что такая модель не будет отражать реальную неоднородную структуру компании, разнящейся от отдела к отделу.
Скажем, решили вы заказать изготовление продукции. Пока вы ее не оплатите никто не поднимет пятой точки. Зато после оплаты заявка поступает в отдел производства. Отделу производства в свою очередь не важно оплачена заявка или нет. Для них актуальны сроки выполнения и наличие необходимых материалов на складе. Затем товар отправляется в доставку, которому вообще по барабану что это за товар. Их волнует только расстояние от склада отгрузки до точки доставки.
Таким образом, существует три заявки: на оплату, изготовление и доставку, обладающие разными характеристиками и имеющие смысл только внутри ограниченного контекста, а не всего предприятия. Словарь терминов, понимаемый одинаково в рамках ограниченного контекста называется единым языком. Внутри ограниченного контекста DDD предлагает три основных инструмента моделирования: Value Object, Entity и Aggregate.
Агрегаты — это деревья объектов, обладающие инвариантом для группы, а не для единичного объекта. Доступ к агрегатам осуществляется через «Корень агрегации» — объект, находящийся в корне дерева. Таким образом, корень обеспечивает инвариант всей группы с помощью инкапсуляции.
Сущности и Value Object — это основные строительные блоки приложения, которые могут как входить в агрегаты, так и не входить. Их основное отличие в том, что у сущностей есть уникальный идентификатор, а у объектов-значений — нет.
Дизайн на основе типов
Вернемся к пользователю интернет магазина. Попробуем смоделировать всего один объект в стиле «богатой» модели. Мы пришли к тому, что валидацию инварианта и контекстную валидацию необходимо разделить. Самый простой способ достижения цели — разделить класс, моделирующий пользователя. Напрашивается два основных подтипа:
- профиль пользователя — персональные данные, которые заставим заполнить позже.
- аккаунт пользователя, содержащий контактную информацию, необходимую для его идентификации и оформления заказа.
Подробнее этот подход в докладе Скотта Влашина Domain Modeling Made Functional или нашего с Вагифом Абиловым Жизнь после бизнес-объектов
Вынести IO на границы (Anticorruption Layer)
Следующим шагом перенесем ввод-вывод на границы приложения. Данные, пришедшие извне по определению могут быть в любом состоянии: как в согласованном, так и нет. DDD даже предлагает специальный паттерн для пограничного контроля ограниченных контекстов — Anticorruption Layer, который, впрочем, отличается от обычного фасада лишь более узкой специализацией.
Все данные из-за пределов контекста сначала попадают в фасад, где происходит проверка. Некорректные данные отвергаются, а данные, прошедшие валидацию идут дальше в слой домена. Над ними выполняются операции бизнес-логики. Результаты покидают пределы слоя домена. Новые входные данные даже на базе результатов «чистого» слоя домена все-равно считаются «грязными» и операция повторяется.
Crud у всего есть начало
Таким образом в домен попадают только корректные данные. Сам же слой домена соблюдает все инварианты, поэтому входные и выходные параметры всех операций должны быть всегда согласованными. Первый рубеж, гарантирующий корректность — конструкторы классов. Конструкторы изначально были задуманы для того, чтобы создавать только согласованные объекты, но долгое время ORM и сериализаторы умели работать только с непараметрическими конструкторами. Кроме того, синтаксис конструкторов в C++ подобных языках оказался чересчур многословным. В итоге, мы можем наблюдать противостояние прививочников и антипрививочников тех, кто считает, что конструкторы нужны и полезны и тех, кто считает, что это слишком многословно.
Проблема инициализации параметров хорошо решена в TypeScript с помощью parameter properties. В C#9 нам обещают records. Эта функциональность планировалась еще в C#8, но разработчики языка решили доработать концепцию и, похоже, что в следующей версии языка мы все-таки их дождемся.
В случае богатой модели выбора нет, конструктор должен быть. Контактные данные сделаем обязательным полем, чтобы использовать их в качестве уникального идентификатора, а профиль пользователя — необязательный параметр конструктора.
public class User : EntityBase
{
public Contact Contact { get; protected set; }
public UserName? UserName { get; protected set; }
public User(Contact contact, UserName? userName = null)
{
Contact = contact;
UserName = userName;
}
}
Контактные данные — это либо email, либо телефон. Необязательно заполнять и то и другое, кол-центр устроит любой способ связи, главное, чтобы он был заполнен верно. Для email — наличие собаки и домена в адресе, а для телефона знака »+» и следующих за ним цифр. Более точные правила валидации email и телефона намеренно опущены, потому что они сейчас не важны.
public class Contact
{
const string PhonePattern = @"\+?\d";
[EmailAddress]
public string? Email { get; }
[RegularExpression(PhonePattern)]
public string? Phone { get; }
private Contact(string? email, string? phone)
{
Email = email;
Phone = phone;
}
}
Мы могли бы использовать вот такой конструктор, но в C# нет способа показать, что один из параметров является обязательным. Сигнатура метода будет сообщать о том, что оба параметра не обязательные. Поэтому сделаем конструктор закрытым, а вместо него предоставим два публичным метода с более говорящими названиями. В C# обычно используется префикс Try для операций, которые могут завершиться ошибкой, но не выбрасывают исключений. Можно реализовать конструктор таким образом.
public class Contact
{
public static bool TryParsePhone(string phone, out Contact c)
{
if (PhoneRegex.IsMatch(phone))
{
c = new Contact(null, phone);
return true;
}
c = null;
return false;
}
}
Если не хотите использовать TryPattern, можете использовать атрибуты. Несмотря на то, что атрибуты — это мета-информация, вообще никак не влияющая на исполнение программы, существует уже готовое вполне удобное API, использовав которое в конструкторе мы заставим пройти все проверки.
public class UserName
{
public UserName(string firstName, string lastName, string? middleName)
{
FirstName = firstName;
LastName = lastName;
MiddleName = middleName;
Validator.ValidateObject(this, new ValidationContext(this));
}
[Required, StringLength(255)]
public string FirstName { get; protected set; }
[Required, StringLength(255)]
public string LastName { get; protected set; }
[StringLength(1)]
public string? MiddleName { get; protected set; }
}
Откуда берутся пользователи
На этом можно было бы остановиться, если бы за пару простых действий нельзя было сделать бизнес-правила более отчетливыми в коде программы. Из сигнатуры конструктора не ясно при каких обстоятельствах пользователи появились в системе. Заменим два параметра на один и дадим ему понятное название.
public class User : EntityBase
{
public Contact Contact { get; protected set; }
public UserName? UserName { get; protected set; }
public User (SignUp command)
{
Contact = command.Contact;
UserName = command.UserName;
}
}
Теперь стало ясно, что пользователь может зарегистрироваться самостоятельно (SignUp) или зарегистрироваться по приглашению друга (SignUpByInvite). Механизм приглашений может натолкнуть читающего код на мысль о том что в системе существует реферальная программа. У этого изменения есть еще один неожиданный побочный эффект. Представьте, что в логах есть два разных сообщения об ошибке:
- Что-то пошло не так во время регистрации пользователя
- Что-то пошло не так, когда Маша пыталась зарегистрироваться по приглашению Паши
Второе гораздо информативнее. Конкретные Маша и Паша, конкретный процесс регистрации, а не абстрактная ошибка регистрации в вакууме. Таким образом, сопровождать продукт новым разработчикам, не знакомым со всеми требованиями станет несколько проще.
public class User : EntityBase
{
public User? Invitee { get; protected set;}
public Contact Contact { get; protected set; }
public UserName? UserName { get; protected set; }
public User (InviteUser command)
{
Contact = command.Contact;
UserName = command.UserName;
Invitee = command.Invitee;
}
}
Поверья
Я не зря упомянул раньше ORM и сериализацию. Встречаются программисты, считающие, что и сейчас конструкторы с параметрами не поддерживаются. Медленно, но поддержка добавляется. В случаях, когда ORM не справляется с параметрическим конструктором всегда остается план B. Оставить конструктор без параметров приватным или защищенным (в зависимости от того, будете ли вы использовать прокси) и добавить необходимые публичные конструкторы.
public class User : EntityBase
{
public User? Invitee { get; protected set;} // For ORM
public Contact Contact { get; protected set; } // For ORM
public UserName? UserName { get; protected set; } // For ORM
protected User () { } // For ORM
public User (InviteUser command)
public User (SignUp command)
}
ORM будет пользоваться конструктором без параметров несмотря на модификаторы доступа, а в программном коде придется использовать публичные.
public class User : EntityBase
{
public User? Invitee { get; protected set;} // For ORM
public Contact Contact { get; protected set; } // For ORM
public UserName? UserName { get; protected set; } // For ORM
protected User () { } // For ORM
public User (InviteUser command)
public User (SignUp command)
}
К сожалению модификаторы доступа не защищают от коллег, меняющих доступ конструктора без параметров на public. Это вопрос проведения код-ревью, а не архитектуры системы.Также, конструкторы не поддерживают async/await. Этот вопрос хорошо разобран в статье Марка Симана Asynchronous Injection.
В некоторых случаях вместо публичного конструктора может лучше подойти фабричный метод, реализующий TryPattern. Использовать ли исключения для ошибок бизнес-логики вопрос неоднозначный. Подробнее об этом в статье Об ошибках и исключениях.
Подведем первые итоги. Я считаю, что счет 1:0 в пользу богатой модели. Совершив несколько тривиальных преобразований мы улучшили читаемость кода и сделали бизнес-правила явными повысили надежность и удобство сопровождения программы. Перейдем к более сложным сценариям. Пока мы работали только с сущностями и value object. Как дела обстоят с агрегатами? Как будет обеспечиваться инвариант целой группы объектов?
Агрегаты
Классический пример агрегата — заказ в интернет магазине. Если заказ оплачен и доставлен, поздно добавлять в него новые товары. Поэтому список товаров в заказе не может быть публичным, иначе любой программист сможет воспользоваться этим свойством и добавить товар, несмотря на статус, и будет при этом абсолютно прав, потому что сигнатура класса никак не сообщила ему о зависимости между состоянием заказа и товарами в нем.
public class Order : EntityBase
{
private List _cartItems = new List();
public IReadOnlyList CartItems => _cartItems;
public void Add(Product product)
{
_cartItems.Add(new CartItem(this, product));
}
}
Столь же классическое решение этой проблемы — предоставить во вне readonly-список, а операции записи осуществлять с помощью специализированных методов, содержащих необходимые проверки. И с этого момента начинаются первые проблемы.
Паттерн Агрегат — очень хорошо выглядит на бумаге, но оказывается весьма неуклюжим, когда дело доходит до практики. Агрегаты по своей природе сложнее чем сущности или объекты-значения, потому что представляют собой не один объект, а целое дерево. Поэтому проблемы, которые казались на уровне сущностей незначительными, становятся весьма неприятными по мере роста дерева объектов и возможных комбинаций состояний. Рассмотрим типовой сценарий работы с заказом: проверка наличия на складе, отправка, отмена.
Нужно ли проверять наличие на складе до оплаты? Зависит от требований. Можно ли отправлять заказ до оплаты? Некоторые магазины разрешают оплачивать при получении курьером, правда чаще всего только если доставка осуществляется внутри города. Можно ли отменить заказ, удачно поставленный покупателю. Скорее всего в нашем дизайне не хватает статусов и лучше подойдет не «отменен», а новый статус «разбирательство», открывающий целый новый процесс: то-ли мы разбили товар во время доставки, то-ли на складе что-то перепутали, то-ли покупатель что-то напутал и ему привезли ровно то что он заказывал.
Данные и методы, связанные с соответствующими состояниями заказа имеют смысл, только в рамках одного состояния. Мы же снова объединили все в одном классе и получили пусть и вкусный, но все-таки винегрет. Поэтому счет сравнялся. Напоминаю, анемичная модель никогда не утверждала, что будет соблюдаться инвариант, тем более для группы объектов и не обещала того, что из программного кода будут понятны бизнес-правила, поэтому в рамках анемичной модели претензий к дизайну нет, а к богатой появились вопросы. Где выразительность? Где статический анализ для бизнес-правил? Его нет, по крайней мере в классических объектно-ориентированных языках. Зато такая возможность есть в функциональных ЯП с более сильной системой типов.
В F# все иначе
Например в F# существуют так-называемые алгебраические типы данных: records (да-да, те самые, что завезут в C#9) и discriminated union
Поддержки discriminated union в C# в ближайшее время не планируется. Можно воспринимать их как enum на стероидах. В отличие от классического enum-а в перечислении discriminated union могут входить другие типы, в т.ч. и records. Именно поэтому, такая система типов называется «алгебраической». Record — это тип «и &», а discriminated union 0 это тип «или |». Таким образом все приложение может быть построено за счет комбинирования маленьких типов одним из способом. В отличие, от привычного в ООП control flow, основанного на полиморфизме, в ФП часто используется передача управления с помощью pattern matching. Для каждого подтипа в discriminated union необходимо написать свою ветку выполнения, в которой будут доступны только данные и поведение, имеющее смысл в рамках в данного состояния объекта.
Эта проблема решается и без применения F#, однако в C# решение выглядит менее элегантным. В статье Шаблон проектирования «состояние» двадцать лет спустя имитируется функциональный подход на основе классического ООП-паттерна с применением современных языковых конструкций C#. На момент написания статьи switch expression еще не зарелизили. С ним pattern matching выглядит лучше. Проконтролировать разбор всех наследников можно написав свой анализатор Roslyn.
Несмотря на то что мне пришлось сменить язык программирования, я считаю что можно выдать богатой модели еще одно очко. F# совместим с C# и поддерживает объектно-ориентированную парадигму, поэтому используя только F# или F# в сочетание с C# можно решить проблему разного поведения в разных состояниях. К сожалению, радоваться еще рано. У меня в запасе есть еще несколько сценариев проблематичных сценариев.
Распределенные транзакции
«Классическое фаулеро-эвансвое» DDD настаивает на том, что инфраструктура и домен должны быть разделены. А что делать, если инфраструктура становится частью домена. Как так? Легко. Например системы документооборота. Представьте, что вам нужно загружать и подписывать цифровыми, а затем парсить и работать с данными из сотни тысяч документов. Каждый раз открывать бинарные файлы — не вариант. Поэтому вам потребуется механизм, гарантирующий консистентность данных в бинарных файлах и в реляционной структуре при добавлении новых или редактировании старых документов.
В основе таких гарантий лежит обработка ошибок, как связанных с системой хранения файлов, так и с БД. Если в момент обновления первого или второго происходит ошибка, то выполняется компенсация — удаляется загруженный файл или откатывается транзакция к БД. Да, существуют механизмы распределенных транзакций. Если бы они работали на любой инфраструктуре, с любыми хранилищами данных проблема бы не существовала. К сожалению это не так, и довольно часто приходится писать код, специфичный для конкретного проекта и его инфраструктуры.
Internal
Можно делегировать это разработчикам, но где гарантия того, что каждый из разработчиков окажется достаточно ответственным и не забудет что-то обработать? Мой опыт показывает, что таких гарантий дать нельзя. Счёт становится 2:2.
С другой стороны, мой опыт подсказывает, что единственный способ что-то гарантировать — это отобрать у людей возможность выбора. Если public заменить на internal, положить эти объекты в одну сборку, а публичный API предоставить через специализированные методы сервисов, то куда вы денетесь с подводной лодки?
public abstract class Document : Document
{
try
{
internal Document(UploadedFile file, T meta);
internal void Update(UploadedFile file, T meta);
}
}
catch(FileStorageException)
// ...
catch(SqlException)
// ...
Сервисный слой
Для обновления документа мы:
- передаем в метод Update и сам документ, и загруженный файл;
- пытаемся загрузить файл в fileStorage;
- пытаемся записать данные в базу данных;
- пишем обработчик try-catch: если что-то пошло не так, пробуем откатить файловое хранилище; если файловое хранилище не откатывается, то попробуем залогировать эту ошибку, чтобы потом руками разобраться.
Теперь все будут использовать именно этот метод. Я предпочел бы этот страшный зашумлённый код никогда больше не писать и оставить здесь — чтобы он был только в одном сервисе, и ключевое слово internal нам помогает ровно так и сделать.
Слой сервисов в доменной модели гораздо более тонкий и служит для специфических задач. Он будет полезен не для всей бизнес логики, а, например, для коммуникации с инфраструктурой.
Все вместе
- Entity + Value Objects: свойства объектов корректны по отдельности и вместе.
- Aggregate: группы объектов корректны.
- Pattern Matching: разное поведение для разных состояний.
- Сервисы + internal: инфраструктура и домен синхронизированы.
Главный вопрос DDD, смысла жизни и всего такого?
Что делать, когда операция падает с OutOfMemoryException или выполняется 10 часов? Прежде чем ответить на этот вопрос, я бы хотел процитировать Джо Армстронга, разработчика языка Erlang. Он отзывается о ООП следующим образом: «Вы хотите получить банан, но вы получаете гориллу, которая держит в руках банан и вместе с ней все джунгли» таким образом иронизируя над неявным изменяемым состоянием, которое присуще для ООП. Если вы работаете с объектами по ссылке, тем более с интерфейсными ссылками, и не знаете какие настоящие реализации, вы не знаете сколько будет внешних ссылок. Мы получаем те самые «джунгли».
Действительно, 2:3. Большие агрегаты падают с OutOfMemoryException, сделать ничего нельзя. По крайней мере в объектной парадигме.
cRud
Если только вы не решите, что в стеке чтения вы не очень-то хотите это всё загружать. А в стеке чтения вы довольно часто не хотите ничего загружать. Поэтому в мире ООП ответ на этот вопрос несколько другой, он звучит так: напишите SQL-запрос.
До этого я рассказывал, что необходимо делать правильную модель домена, нельзя ни в коем случае писать никаких SQL-запросов, всё должно быть объектно, и тут я говорю: «Давайте напишем SQL-запрос». Не то, чтобы я переобуваюсь на ходу, просто DDD — это инструмент. Моделирование домена — это инструмент, паттерн. Не бывает швейцарских ножей, которые работают во всех обстоятельствах. Отчёты — это плохое применение для DDD.
Read-stack — это зачастую плохое применение для DDD, потому что нам нечего там контролировать. Нам надо просто читать данные. А вот в стеке записи это вполне подходящая штука. Кроме того, SQRS, в принципе, некий симбионт для HTTP, потому как протокол HTTP явно говорит о том, что методы POST и DELETE должны менять состояние сервера, но не возвращать данные, а метод READ — читать. Соответственно, если ваше приложение в вебе, то почему бы не воспользоваться такой возможностью.
CrUD
Мы обошли проблему тем, что выбросили доменную модель в стеке чтения, что же делать в других случаях? Классический ответ такой: не ссылайтесь из одного агрегата на другой по ссылке, и будет вам счастье. С этим аргументом тоже есть определённые нюансы: кто-то считает, что это текучая абстракция, что это не очень хорошо — тем не менее, это работает. По крайней мере, пока у вас не очень большие агрегаты.
Как только они становятся чуть побольше или совсем большими, то у вас начинает падать не некоторый набор агрегатов, а он один. Если у вас именно такие агрегаты, то, возможно, это неправильные агрегаты и они делают неправильный мёд. Возможно, вы их неправильно нарезали. Но бывают, хотя очень редко, такие случаи, когда это неизбежно. Что тогда делать? Давайте я вас сразу предупрежу, что ответы, которые я сейчас дам, будут скатываться по наклонной от уровня «хуже» к «совсем плохо».
По наклонной («плохие» случаи)
Делаем не SOLIDно
Перейдём на уровень «похуже» и ответим для себя на вопрос: насколько мы вообще ценим SOLID? Если принцип D вам не очень близок, и вы не очень знаете, зачем абстракциям зависеть от абстракции или реализации, и вообще вам всё равно — отлично, просто засовываем сюда IQueryable и не паримся. Да, мы нарушили принцип — ну и что?
Если вы считаете, что при использовании линка вы ничего не нарушаете, попробуйте заменить лямбду x => x.OrderId == Id
на любую другую и скажите — выполняется ли здесь принцип L? Если вы уверены, что принцип L здесь всегда выполняется — продолжайте так думать. Это я к тому, что любая абстракция при определённых условиях начинает течь. Зависит от того, насколько ваше пуританское воспитание позволяет или не позволяет так делать.
public partial class Order : EntityBase
{
public IQueryable ItemsQuery
=> _dbContext
.Set
.Where(x => x.OrderId == Id);
}
Lazy Load
Вариант «ещё похуже» — я двигаюсь к абсолютному злу — включите Lazy Load. Он по многим причинам хорош:
- Tell Don«t Ask aka Закон Деметры;
- Persistence Ignorance;
- Меньше boilerplate;
- Простота использования.
Несмотря на это, у него есть также и множество всяких известных проблем:
- Производительность;
- Непредвиденное IO;
- Возможны проблемы с Reflection или кодом, сгенерированным в runtime;
- Массовые операции.
Проектирование — компромисс
Я уже, кажется, раза два повторил, что не бывает идеальных инструментов: всегда есть компромисс, и проектирование — это компромисс. С моей точки зрения, это настолько важный note point, что я его ещё раз повторю: если у вас никак не получается сделать DDD в read-stack, это не говорит о том, что вы не смогли в DDD, и не говорит о том, что DDD плохой — это говорит о том, что DDD плохой инструмент для этой задачи, поэтому просто возьмите другой инструмент.
Как только вы начинаете возводить всё в абсолют и говорить «нет, мы не будем писать так код, потому что Фаулер Эванс, Вернан — кто угодно — сказал, что «нельзя», вы обязательно будете испытывать только расстройство. Только эти ребята (на слайде персонажи фильма «Звёздные войны») возводят всё в абсолют. Поэтому, несмотря на все оговорки и не самое высокое качество тех решений, которые я предложил, давайте считать, что счёт у нас равный — 3:3.
Workarounds
Вернёмся к агрегатам: независимо от того, анемичную или богатую модель мы используем, большие агрегаты никуда не делись. То есть когда вы тащите половину базы данных, чтобы что-то посчитать, по умолчанию такой подход будет менее эффективным, чем просто выполнить запрос к базе данных. Ответить на рукописный запрос всегда будет эффективней, потому что не надо тащить данные по сети, поднимать их в оперативную память и там считать. База данных это делает внутри своего приложения. Значит ли это, что для определённого класса задач объектная модель никак не подходит? Если бы мы работали с Java, я бы сказал «да, так и есть».
Expressions
В .NET у нас есть технология, которая позволяет преодолеть это ограничение — Expression Trees. Её можно использовать по прямому назначению, то есть писать C# код, а можно использовать как структуру данных — обходить и дальше трансформировать структуру данных тем способом, которым нам нужно.
Specification
Паттерн «Спецификация» раньше работал только с объектами и создавал проблемы с производительностью, потому что нам нужно было сначала вытащить весь набор данных, а потом в оперативной памяти его отфильтровать. В C# этот паттерн обретает новую жизнь: если у нас есть правило о том, что для продажи только определённые товары, у которых цена больше нуля, мы можем объявить это правило как Expression.
public Spec IsForSale { get; } =>
new Spec(x => x.Price > 0);
Оно выглядит как C# код, соответственно, оно может быть частью нашей модели домена, но мы его никогда не выполняем как C# код — мы его используем для трансляции к запросу к базе данных. При этом мы получаем довольно эффективные запросы. Также если ваши объекты, агрегаты или сущности слишком большие, не обязательно их читать целиком, можно, используя C# и проекции, читать только часть этих данных в виде DTO и делать более производительные программы. Причем необязательно даже делать анонимные типы. Если вы используете AutoMapper или Mapster, можно вообще снизить количество императивного кода и заменить его на декларативный.
Default Interfaces Implementation
При этом возникают некоторые интересные лазейки, которые именно в классической ООП-парадигме не были бы возможными. Начнём с того, что с 8-ой версией C# у нас появились дефолтные реализации интерфейсов.
public class Product : EntityBase
{
public decimal Price { get; }
public decimal DiscountPercent { get; }
public decimal DiscountedPrice()
=> Price - Price / 100 * DiscountPercent;
}
Давайте представим, что у товара есть логика расчёта скидки, которую мы поместили в тот же объект, то есть мы попытались сделать богатую модель. Такому проектированию сопутствуют все проблемы, о которых я говорил до этого: если нам потребуется вытащить большое количество товаров, то память неминуемо закончится. Мы бы хотели это оптимизировать: вместо того, чтобы объявлять метод непосредственно в сущности, мы можем перенести его в интерфейс с дефолтной реализацией.
public interface IHasDiscount
{
decimal Price { get; set; }
decimal DiscountPercent { get; set; }
decimal DiscountedPrice()
=> Price - Price / 100 * DiscountPercent;
}
Теперь этот интерфейс можно «прилепить» как к сущности, так и к DTO. Непосредственно код будет находиться ни в том, ни в другом объекте, а в реализации интерфейса.
public class ProductListItem : IHasDiscount
public interface IHasDiscount
{
decimal Price { get; }
decimal DiscountPercent { get; }
public decimal DiscountedPrice()
=> Price - Price / 100 * DiscountPercent;
}
Bulk Extensions
Для массовых операций Expressions дают тоже свободу действий. Если не хочется переносить логику в SQL, можно её оставить в Expressions в C# коде. Тогда можно использовать нечто вроде Bulk Extensions, чтобы использовать ту же самую спецификацию на C#: в данном примере — удалить все товары, которых у нас сейчас нет для продажи или поднять на 100 долларов или рублей стоимость товаров, которые мы всё-таки продаём.
А может F#?
Однако как только я начинаю использовать некоторые такие лазейки, я задумываюсь: на том ли языке программирования я сейчас пишу и тот ли инструмент я использую? Потому что дефолтная реализация интерфейсов — это, фактически, функция, которую мы можем «прилеплять» к любым типам данных, используя интерфейс. Стоит ли переходить на F#, чтобы реализовать модель домена — каждый решает сам, исходя из потребностей проекта. Мы, например, так и не решились переезжать. Тем не менее, поглядывать в сторону каких-то других языков программирования бывает полезно, чтобы позаимствовать оттуда некоторые идеи.
Bounded Context
Независимо от того, какая модель используется — богатая или анемичная, неплохо было бы разделять приложения на приложения поменьше, хотя бы просто потому, что ими проще управлять.
Ложный агрегат
Я говорил о том, что агрегаты имеют свойство разрастаться. Это происходит, когда мы используем документацию Microsoft и следуем ей вслепую. Например, когда мы хотим объявить связанные коллекции, мы обычно связываем их в две стороны. А вот если пользователь может много чего делать в вашей системе, тогда и объект User будет довольно-таки большим. Это антипаттерн «Божественный объект».
И ладно, если бы он только тратил ресурсы памяти — это мы можем оптимизировать с помощью Expressions. Такой подход ещё и порождает большое количество циклических зависимостей, потому что сейчас у заказов тоже может быть набор пользователей, у комментариев может быть набор товаров, и так далее.
Пока все эти объекты живут у вас в одной сборке, вы получаете такие «круговые» зависимости: A зависит от B, B зависит от C, C зависит от A. В итоге получается большой и страшный монолит, который мы никак не можем распилить. Всё дело в том, что этот агрегат ложный, в реальном мире его не существует, потому что я объединил в класс пользователя всё, что только можно.
Как же тогда находить корни агрегации и выбирать их правильным способом? Есть два списка вопросов, ответив на которые вы сможете с очень высокой вероятностью понять, тот ли у вас агрегат.
Первый список вопросов (организационный и никак не связан с технологиями)
- Есть ли аналог в реальном мире? (Задайте этот вопрос вашим аналитикам.)
- Когда вы рассказываете о своём дизайне бизнес-пользователям, считают ли они вас местным сумасшедшим?
Если вас не считают сумасшедшим и говорят «да, мы так и работаем», то вы правильно обнаружили агрегат, проверьте второй список вопросов.
Второй список вопросов (технологический)
- Агрегат не пересекает Bounded Context?
- Есть ли инвариант для этой группы объектов?
- Данные должны изменяться в рамках одной транзакции?
Если ответ на все его вопросы — «да», значит, агрегат правильный. В нашем случае ответ на всё это — «нет». Заказы — это отдельная штука, настройки пользователя — другая, отзывы о товарах — третья. Поэтому распилим этот агрегат на три маленьких, уменьшив количество циклических зависимостей. Кроме того, мы можем перенести их по разным сборкам и делегировать работу с этими классами командам.
public class Client : User
{
public ICollection Orders => _orders;
}
public class Account : User
{
public ICollection Settings => _settings;
}
public class Blogger : User
{
public ICollection Comments => _comments;
}
Как делить Domain Model на Bounded Context
Как же делить Domain Model на Bounded Context? Я уже сказал, что в итоге внутри контекста у нас будут сущности и агрегаты. Сущности редко имеют тенденцию расползаться, потому что они маленькие, а пример «божественного» агрегата я приводил до этого. И если вы видите, что агрегат залез между двумя контекстами, значит, он неправильный. Но это правило работает и в обратную сторону: если вы уверены, что агрегат правильный, и ответ на вопросы выше — «да», то, похоже, вы неправильно нарезали контекст. Поэтому ответ на вопрос «как же нарезать Domain Model на Bounded Context?» — по границам агрегатов.
Монолит на микросервисы
Этот вопрос можно переформулировать более модно: как делить монолит на микросервисы? Я не считаю, что разделение программы на подмодули вообще зависит от того, распределённая у вас система или нет. Распределённая система сопровождается некоторыми дополнительными проблемами, связанными с тем, что данные у вас находятся в разных процессах, и вам нужно постоянно что-то сериализовывать и десериализовывать.
Но, тем не менее, ответ такой: мы растащили Domain Model на разные Bounded Context, сказали, что Bounded Context — это отдельный микросервис и получили из страшного монолита много маленьких красивых микросервисов. Причём, обратите внимание, все проблемы, связанные со страшными монолитами, которые не распиливаются, чаще всего связаны с тем, что у вас есть циклические зависимости, которые сложно растащить. Если в самый начальный момент этих зависимостей нет, значит и разделить приложение на несколько процессов будет сильно проще.
Как пересечь границу контекстов
Что же делать, если из одного контекста всё-таки надо обратиться к другому контексту? Несмотря на то что они независимые, такое тоже бывает. Вне зависимости от того, разные ли это у вас сервисы или это разные сборки, работающие в одном процессе — в одну сторону бывает дотянуться проще, потому что если один контекст зависит от другого, то и ссылки на эти объекты есть. В другую же сторону уже не получится, потому что циклические зависимости, которые были в рамках одной сборки неявными, становятся явными. Компилятор уже не позволит нам ссылаться двумя сборками друг на друга.
Классическое решение такой ситуации — использование событий. События могут быть сериализованными и&n