Доброго времени суток, Хабр!
Сегодня хотел бы поговорить об анемичной модели — одном из самых дискуссионных топиков (особенно для приверженцев DDD) и о том, как, по моему мнению, правильно её готовить. Для кого-то анемичная модель — это антипаттерн, тогда как для других это единственный правильный способ реализации приложений. Многие использовали её годами и даже не знали, как она называется, и что кем-то она считается антипаттерном. Реальность же такова, что анемичная модель — это инструмент, который может подходить или не подходить в зависимости от ситуации, но при этом является очень популярным и в реальности «стандартом де-факто» для многих программистов и организаций. Хотя в последние годы я и вижу тенденцию к тому, что DDD и, соответственно, богатая доменная модель становятся всё популярнее, пока что, по моему мнению, им далеко до популярности анемичной модели.
Данная статья так-же доступна на английском.
Примеры в этой статье приведены на Java, однако изложенные идеи применимы практически ко всем языкам программирования — не только объектно-ориентированным.
Об авторе:
Разработчик и архитектор с 10-летним опытом в финтехе, e-commerce, enterprise SaaS, pharma logistics, продуктовых и аутсорсинговых компаниях.
Для кого эта статья
В первую очередь эта статья будет интересна для всех программистов на ООП языках, которые занимаются энтерпрайз- и веб-разработкой. Больше всего пользы от неё получат те, кто использует анемичную модель повсеместно. DDD-адепты найдут для себя в ней много полезного - ведь DDD это необязательно Доменная модель и даже там есть место анемичной модели - например, в supporting subdomains. А потенциально вы можете почерпнуть из этой статьи полезные идеи даже если вы разрабатываете чисто технические решения и если пишете на функциональных языках.
Дисклеймер
Я старался использовать в статье минимальное количество англицизмов, но не всегда это было возможно, так как я большую часть своей карьеры проработал в международных компаниях, и для меня их использование является повседневным и естественным, даже на русском языке. В жизни я практически никогда не использую термины типа "Сущность" вместо "Энтити" или "Корень Агрегата" вместо "Агрегейт Рут". В этой статье я постарался это сделать, но думаю, что много где упустил. Поэтому, если вы особо впечатлительны и топите за чистоту русского языка, будьте осторожны и не говорите потом в комментариях, что я вас не предупреждал :)
Предыстория
Я очень долго шёл к этой статье, а точнее — к тому, чтобы сесть и наконец-то её закончить.
Больше трёх лет назад я работал в аутсорсинговой компании на проекте с довольно сложной бизнес-логикой. Проект писался с нуля, и когда мы только пришли на него, диалог был примерно такой: "У нас тут DDD, вот ссылка про него, почитайте". На этом разговор закончился. На тот момент для меня DDD было чем-то из разряда антипаттернов, так как все случаи, когда я встречал его на проектах до этого, по факту были просто реализацией Access Record и бизнес-логики, размазанной и по сервисам, и по моделям, из-за чего было сложнее поддерживать код и разбираться в нём. Так же плохо я относился к смешению данных и поведения — опять же по опыту. Мы тогда забили на DDD, так как никто его особо не понимал, и сделали всё через анемичную модель/транзакшн скрипт, правда немного модифицированную.
В тот момент как-то в разговоре с другом он упомянул, что его компания сейчас пытается внедрить DDD, и посоветовал почитать пару книг: "Юнит-тесты" Владимира Хорикова, "DDD" Вона Вернона и "Чистую архитектуру" Боба Мартина. После прочтения этих книг, а в особенности "Юнит-тестов" В. Хорикова, я осознал, что мы не одиноки в наших страданиях: у нас были такие же проблемы, что описаны в книге — хрупкие юнит-тесты, юнит-тесты с десятками моков, в которых сложно понять, что происходит, сложности с пониманием и рефакторингом и т. д. В книге формальным решением для борьбы с хрупкими тестами выступала классическая школа юнит-тестирования, но фактически решением являлось разделение бизнес-логики и инфраструктуры, так как без этого классическую школу применить нереально. Так как переход на полноценный DDD не представлялся возможным, я попытался понять, как можно применить советы из книги к нашему проекту — и так родилось то, что я называю “Чистой Анемичной Моделью”(Pure Anemic Model), и данная статья.
Что такое классическая школа юнит тестирования: В отличие от лондонской школы классическая школа рассматривает вопрос изоляции юнит тестов как изоляцию самих юнит-тестов друг от друга а не как изоляцию тестируемой системы от ее коллабораторов. Подробнее про классическую и лондонскую школы юнит-тестирования можно почитать в книге В. Хорикова.
Об Анемичной модели и DDD
Фаулер и некоторые авторы разделяют Сценарий Транзакции и Анемичную Модель, но в данной статье я буду использовать термин "Анемичная (Доменная) Модель" как обозначение и того, и другого. По факту анемичная модель является сценарием транзакции, использованным не по назначению.
Существует два основных способа имплементации бизнес-логики: Анемичная Доменная Модель (Anemic Domain Model) / Сценарий Транзакции (Transaction Script) и Богатая Доменная Модель (Rich Domain Model):
1. Анемичная Доменная Модель — техника, которая была с нами всегда, но явно описанная Мартином Фаулером в его блоге как антипаттерн, суть которого заключается в разделении данных и поведения. То есть сама модель у нас имеет только поля, геттеры и сеттеры, при этом вся бизнес-логика пишется практически всегда в сервисах — это своего рода псевдо-функциональный подход.
Плюсы:
Низкий порог входа, проще для разработки и понимания.
Чисто тактический паттерн, не требуется сотрудничества с бизнесом для его имплементации.
-
Подходит для:
Простой бизнес-логики.
Так называемых supporting сабдоменов(поддерживающая функциональность, которая не является основной для бизнеса), ETL, CRUD.
* Когда есть разрыв в общении между бизнесом и командой разработки. Да, кто-то скажет, что нужно решать такую проблему и возможно вводить DDD, но такое не всегда возможно. Например, есть огромное количество аутсорс-компаний, которые не могут влиять на бизнес, при этом разработчики могут общаться не напрямую с бизнес-экспертами, а через посредников в виде менеджеров, аналитиков и т. д.
* Когда нет чёткого плана действий, и у бизнеса есть много разработок в надежде что какая-нибудь из них выстрелит.
* Когда важно как можно быстрее выкатить MVP (например в стартапах), и только если продукт "взлетит", замедляться и вдумчиво в него вкладываться.
Минусы:
Не подходит для сложной бизнес-логики.
Error-prone — из-за того что валидация и бизнес-логика не находятся в самой модели, всегда существует возможность создать или обновить модель неправильно.
Смешивание бизнес-логики и инфраструктуры мешает сосредоточиться на чём-то одном и усложняет понимание бизнес-логики.
Рано или поздно сервисы начнут вызывать друг друга, что приведёт к сложным цепочкам вызовов, циркулярным зависимостям и, в итоге, к "big ball of mud".
Хрупкие юнит-тесты. Если у вас сложная бизнес-логика и большое количество инфраструктурных зависимостей в сервисе, юнит-тесты зачастую будут иметь огромное количество моков, и в них будет сложно разобраться. Они будут часто падать, и их будут фиксить чисто для галочки.
Хоть Мартин Фаулер и называет анемичную модель антипаттерном, исходя из моего опыта, это самый распространённый способ дизайна дата-модели и бизнес-логики. Из сотен разработчиков, лидов, менеджеров и архитекторов, с которыми я знаком, которых я встречал или которых собеседовал, наверное, меньше половины слышали о DDD, и из них довольно маленький процент пытался его применять на практике. И буквально единицы были просветлёнными понимали его и применяли по назначению.
2. Богатая доменная модель — объектная модель, которая включает в себя как поведение (бизнес-логику), так и данные.
Плюсы:
Чёткое соблюдение инвариантов — валидация является частью модели. Из-за этого намного сложнее случайно создать модель с невалидным состоянием (Always valid domain).
Бизнес-логика объекта инкапсулирована в модели.
Идёт в комплекте с другими тактическими паттернами DDD — такими как объекты-значения и агрегаты, которые помогают в работе со сложной бизнес-логикой. Хотя в принципе эти паттерны при желании можно использовать и в анемичной модели, это будет сложнее, и обычно никто так не делает. Да и насколько процентов тогда анемичная модель будет анемичной?
Проще тестирование, так как зачастую доменная модель предполагает отсутствие инфраструктурных зависимостей. Благодаря этому можно сосредоточиться на тестировании бизнес-логики, а не на создании бесконечных моков.
Минусы:
Основной минус — это сложность. Правильная (которая используется вместе со стратегическими паттернами DDD и отталкивается от анализа бизнес-сабдомена) реализация доменной модели, особенно если у команды нет с ней опыта, занимает сильно больше времени. По моим ощущениям это от +30%, если у команды есть опыт и домен понятен и стабилен, до нескольких сотен процентов в худших случаях, когда бизнес-требования не понятны, а разработчики не хотят заниматься hypothesis driven design (или, по-простому, делать, как сказано, не вникая в суть).
Из-за своей сложности модель избыточна для многих видов простых приложений и сервисов.
* Некоторые считают, что она плохо применима в высоконагруженных приложениях, но я с этим не до конца согласен.
* — означает не общепринятые тезисы, а по мнению автора статьи.
В данной статье речь пойдет только о тактических паттернах.
Проблемы
Итак, перед тем как приступать к решению, перечислю ещё раз список проблем анемичной модели, которые мы постараемся решить:
Сложность понимания и поддержки: бизнес-логика раскидана по сервисам, что усложняет понимание и поддержку, особенно когда логика становится сложнее CRUD-а.
-
Сложные и хрупкие юнит-тесты, которые:
Ломаются от рефакторинга;
Сложно поддерживаются;
С большим количеством моков, что усложняет понимание самих тестов;
Практически не приносят пользы и не выявляют багов;
Из-за регулярных падений начинаешь игнорировать и просто фиксишь их кое-как, чтобы они проходили;
Требуют высокий процент покрытия, из-за чего тесты пишутся ради покрытия, а не как защита от багов.
Производительность: несмотря на распространённое мнение, что анемичная модель лучше подходит для производительных приложений, описанное решение позволит писать более оптимизированный код.
Я не стал перечислять проблемы, которые нам не решить данным подходом — например, нарушение принципа always valid domain или неиспользование силы ООП.
Решение
Основная идея подхода заключается в том, что для решения проблем анемичной модели мы почерпнём идеи из DDD, функционального программирования и некоторых других подходов. Применив их к анемичной модели, мы сделаем её более понятной, расширяемой и поддерживаемой.
Самый основной принцип: Разделить бизнес-логику и инфраструктуру. Этот принцип далеко не нов, он используется давно и в разных видах архитектур и подходов, таких как гексагональная архитектура, DDD, функциональное программирование и т.д.
Схематично это можно показать вот так:

Дальнейшие принципы исходят из того, как это сделать:
Вынести бизнес-логику в узкоспециализированные классы-компоненты без инфраструктурных зависимостей.
Стараться делать компоненты и их методы чистыми функциями, у которых нет состояния и которые не зависят и не влияют на другие компоненты, сервисы и т. д.
Стараться использовать плоскую структуру вместо вложенной для компонентов.
Превратить сервисы (Application Services в DDD) в Простые Объекты (Humble Object) — по факту классы без бизнес-логики, отвечающие только за то, чтобы управлять флоу.
Если какая-либо логика не подходит для компонентов, но при этом не является частью инфраструктуры, писать её в так называемых доменных сервисах (Идея почерпнута из DDD, но отличается).
Не делать компоненты бинами (совет).
Писать юнит-тесты только для бизнес-логики (совет).
Подробнее о каждом пункте далее.
О названии
Я долго думал, как назвать данный подход. Вариантами были: «Правильная анемичная модель» (Proper Anemic Model), «Функциональная анемичная модель» (Functional Anemic Model) и «Чистая анемичная модель» (Pure Anemic Model). В итоге я остановил свой выбор на последнем.
Cлово “компонент” и так перегружено, но к сожалению я так и не придумал ничего лучше. В нашем контексте компонент означает класс с кодом отвечающим только за логику(поведение).
1. Вынос бизнес-логики в компоненты
Первое, что нам нужно сделать — это вынести бизнес-логику в узкоспециализированные классы-компоненты без инфраструктурных зависимостей.
Пример 1:
Данные пользователя могут быть обновлены, если он не задизейблен:
class UserService {
UserRepository userRepository;
void update(Long userId, UserUpdateDTO userDTO) {
var user = userRepository.getById(userId);
if (user == null || user.status == Status.DISABLED) {
throw new ValidationException();
}
// set params from userDTO to user
userRepository.update(user);
}
}
// refactored:
class UserServiceRefactored {
UserRepository userRepository;
UserValidator userValidator;
void update(Long userId, UserUpdateDTO userUpdateDTO) {
var user = userRepository.getById(userId);
userValidator.validate(user);
// set params from userDTO to user
userRepository.update(user);
}
}
class UserValidator {
void validate(User user) {
if (user == null || user.status == Status.DISABLED) {
throw new ValidationException();
}
}
}
Кто-то может заметить, что в реальности и в анемичной модели зачастую существуют валидаторы. Но я не стал для наглядности усложнять пример, в том числе потому, что в реальности эти валидаторы тоже, скорее всего, будут содержать свои зависимости.
Пример 2:
Скидка считается на основе бонусов пользователя либо суммы заказа.
class OrderService {
UserRepository userRepository;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
if (user.hasBonuses()) {
return order.getAmount() - user.getBonuses();
}
if (order.getAmount() > 1000) {
return order.getAmount() * 0.1;
}
return 0;
}
}
class OrderServiceRefactored {
UserRepository userRepository;
DiscountCalculator discountCalculator;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
return discountCalculator.calculateDiscount(user.getBonuses(), order.getAmount());
}
}
class DiscountCalculator {
public double calculateDiscount(Double existingBonuses, Double amount) {
if (existingBonuses != null) {
return amount - existingBonuses;
}
if (amount > 1000) {
return amount * 0.1;
}
return 0;
}
}
Может возникнуть вопрос, а в чём тут преимущество? Действительно, для таких маленьких примеров оно может быть ничтожно. Но давайте представим пример чуть сложнее:
public class OrderService {
UserRepository userRepository;
LoyaltyProgramService loyaltyProgramService;
OrderHistoryService orderHistoryService;
Logger logger;
CampaignService campaignService;
public double calculateDiscount(Long userId, OrderDTO order) {
logger.log("Calculating discount for user " + userId);
var user = userRepository.getById(userId);
if (user == null || user.status == Status.DISABLED) {
throw new ValidationException();
}
Double bonuses = loyaltyProgramService.getBonuses(userId);
if (bonuses != null) {
return order.getAmount() - bonuses;
}
if (order.getAmount() <= 1000) {
return 0.0;
}
double previousOrderTotal = orderHistoryService.getPreviousOrderTotal(userId);
double additionalDiscount = 0.0;
if (previousOrderTotal > 5000) {
additionalDiscount = 0.05;
}
double campaignDiscount = campaignService.getActiveCampaignDiscount(userId);
if (campaignDiscount > 0) {
additionalDiscount += campaignDiscount;
}
return order.getAmount() * (0.1 + additionalDiscount);
}
}
Насколько сложнее его стало читать? Инфраструктурная логика смешана с бизнес-логикой. А как будут выглядеть наши тесты? У нас будет куча моков, которые нужно будет править при любом рефакторинге, и очень мало пользы:
class OrderServiceTest {
@Mock
UserRepository userRepository;
@Mock
LoyaltyProgramService loyaltyProgramService;
@Mock
OrderHistoryService orderHistoryService;
@Mock
CampaignService campaignService;
@Mock
Logger logger;
@InjectMocks
OrderService orderService;
@Test
void shouldCalculateDiscountWithHistoryAndCampaign() {
// Given
Long userId = 1L;
User user = new User(1, ACTIVE);
when(userRepository.getById(userId)).thenReturn(user);
when(loyaltyProgramService.getBonuses(userId)).thenReturn(null);
when(orderHistoryService.getPreviousOrderTotal(userId)).thenReturn(6000.0);
when(campaignService.getActiveCampaignDiscount(userId)).thenReturn(0.03);
OrderDTO order = new OrderDTO(2000);
// When
double discount = orderService.calculateDiscount(userId, order);
// Then
assertEquals(360, discount);
}
}
А теперь посмотрим на этот же пример, но с применением обсуждаемого подхода:
class Solution {
class DiscountServiceRefactored {
UserRepository userRepository;
LoyaltyProgramService loyaltyProgramService;
OrderHistoryService orderHistoryService;
Logger logger;
CampaignService campaignService;
UserValidator userValidator;
DiscountCalculator discountCalculator;
public double calculateDiscount(Long userId, OrderDTO order) {
logger.log("Calculating discount for user " + userId);
var user = userRepository.getById(userId);
userValidator.validate(user);
var bonuses = loyaltyProgramService.getBonuses(userId);
var previousOrderTotal = orderHistoryService.getPreviousOrderTotal(userId);
var campaignDiscount = campaignService.getActiveCampaignDiscount(userId);
return discountCalculator.calculateDiscount(bonuses, order.getAmount(), previousOrderTotal, campaignDiscount);
}
class DiscountCalculator {
public double calculateDiscount(
Double existingBonuses,
Double orderAmount,
Double previousOrderTotal,
double campaignDiscount) {
if (existingBonuses != null) {
return orderAmount - existingBonuses;
}
if (orderAmount <= 1000) {
return 0.0;
}
var additionalDiscount = 0.0;
if (previousOrderTotal > 5000) {
additionalDiscount = 0.05;
}
if (campaignDiscount > 0) {
additionalDiscount += campaignDiscount;
}
return orderAmount * (0.1 + additionalDiscount);
}
}
class UserValidator {
void validate(User user) {
if (user == null || user.status == Status.DISABLED) {
throw new ValidationException();
}
}
}
}
}
Не правда ли, стало намного проще для чтения, понимания, рефакторинга и написания юнит-тестов? При этом в этих тестах не будет ни одного мока, и они будут меняться только при изменении бизнес-логики. Помимо этого теперь у нас есть чёткие границы — есть два класса с чистой бизнес-логикой, и ещё один класс-оркерстратор, который отвечает за управление флоу и взаимодействие классов.
Внимательный читатель мог заметить, что в случае, если у пользователя уже есть бонусы, или если сумма заказа меньше или равна тысяче, мы впустую делаем вызовы к orderHistoryService и campaignService. Я опишу решение данной проблемы далее в разделе “Проблемы, с которыми можно столкнуться. Трилемма”.
2. Делать методы компонентов чистыми функциями
В идеале у ваших компонентов не должно быть состояния, они не должны зависеть и влиять на другие компоненты, сервисы, что угодно. Это не всегда будет получаться в полной мере, но об этом далее (пункт 3).
Частично мы уже коснулись этого в пункте #1, когда сказали, что у компонентов не должно быть инфраструктурных зависимостей. Но помимо этого компонент всё ещё может иметь состояние, что перестаёт делать его чистой функцией.
Почему это так важно?
Чистые функции проще для понимания и поддержки, так как у них нет состояния, побочных эффектов и т.д. Поэтому, вызывая компонент, вы будете точно знать, что он делает ровно то, что вам нужно.
Если уж мы отказываемся от преимуществ ООП, давайте хотя бы использовать преимущества функционального подхода, потому что иначе мы теряем любые.
Пример:
class UserService {
private UserValidator userValidator;
public void registerUser(User user) {
userValidator.validate(user);
if (userValidator.isValid) {
// Proceed with registration
}
}
}
class UserValidator {
boolean isValid = false;
public void validate(User user) {
if (user != null && user.status != Status.DISABLED) {
isValid = true;
}
}
}
В данном случае подразумевается, во-первых, что скоуп бина UserValidator как минимум не singleton. Во-вторых, здесь присутствует Connascence of Execution / Sequential coupling (вид зависимости в котором важен порядок выполнения).
Намного более правильным было бы сделать так:
class UserValidator {
void validate(User user) {
if (user == null || user.status == Status.DISABLED) {
throw new ValidationException();
}
}
}
или так:
class UserValidator {
ValidationResult validate(User user) {
if (user == null || user.status == Status.DISABLED) {
return ValidationResult.failure("User is null or disabled");
}
return ValidationResult.success();
}
}
Пример чуть посложнее:
class ProductService {
private ProductProcessor productProcessor;
private ProductRepository productRepository;
public void process(List<ProductDTO> productDTOs) {
productDTOs.forEach(productDTO -> productProcessor.add(productDTO));
productProcessor.processProducts();
productRepository.saveAll(productProcessor.getProcessedProducts());
}
}
class ProductProcessor {
List<Product> products;
public void add(ProductDTO productDTO) {
Product product = convert(productDTO);
products.add(product);
}
public void processProducts() {
for (Product product : products) {
// Do something with each product
}
}
public List<Product> getProcessedProducts() {
return products;
}
}
Отрефакторенная версия:
class ProductServiceRefactored {
private ProductProcessorRefactored productProcessor;
private ProductRepository productRepository;
public void process(List<ProductDTO> productDTOs) {
var products = productProcessor.processProducts(productDTOs);
productRepository.saveAll(products);
}
}
class ProductProcessorRefactored {
public List<Product> processProducts(List<ProductDTO> products) {
for (var product : products) {
// Do something with each product
}
// return list of products
}
}
Многим данные примеры могут показаться надуманными, но они лишь отражают реальный код, который я видел в своей карьере.
Если вы всё же решите создавать компоненты, которые хранят и состояние, и логику, старайтесь хотя бы делать их немутабельными — по принципу объектов-значений (value object — это неизменяемый объект без собственной идентичности, определяемый только своим значением).
3. Вложенная структура против Плоской
Как и понятно по названию - тут суть в том, чтобы компоненты не были сильно вложенными, а в идеале вообще была плоская структура:
// before
class OrderService {
PriceCalculator priceCalculator;
double calculateTotalPrice(OrderDTO order) {
var totalPrice = priceCalculator.calculate(order);
return totalPrice;
}
class PriceCalculator {
DiscountCalculator discountCalculator;
TaxCalculator taxCalculator;
public double calculate(OrderDTO order) {
var discount = discountCalculator.calculate(order);
var amountWithDiscount = order.getAmount() - discount;
var tax = taxCalculator.calculate(amountWithDiscount);
return amountWithDiscount + tax;
}
}
class DiscountCalculator {
public double calculate(OrderDTO order) {
// some logic
return 0;
}
}
class TaxCalculator {
public double calculate(double amount) {
// some logic
return 0;
}
}
}
// after
class OrderServiceRefactored {
PriceCalculator priceCalculator;
DiscountCalculator discountCalculator;
TaxCalculator taxCalculator;
double calculateTotalPrice(OrderDTO order) {
double discount = discountCalculator.calculate(order);
double tax = taxCalculator.calculate(order, discount);
double totalPrice = priceCalculator.calculate(order, discount, tax);
return totalPrice;
}
class PriceCalculator {
public double calculate(OrderDTO order, double discount, double tax) {
return order.getAmount() - discount + tax;
}
}
class DiscountCalculator {
public double calculate(OrderDTO order) {
// some logic
return 0;
}
}
class TaxCalculator {
public double calculate(OrderDTO order, double discount) {
// some logic
return 0;
}
}
}
Если вам кажется, что и первый способ имеет место быть, то так и есть. Но, скорее всего, в долгосрочной перспективе он сильно усложнится. Например, что если DiscountCalculator
и TaxCalculator
будут требовать дополнительные параметры?
class PriceCalculator {
DiscountCalculator discountCalculator;
TaxCalculator taxCalculator;
public double calculate(OrderDTO order, Customer customer, Region region) {
// Рассчитываем скидку с учётом типа клиента
var discount = discountCalculator.calculate(order, customer);
var amountWithDiscount = order.getAmount() - discount;
// Рассчитываем налог с учётом региона
var tax = taxCalculator.calculate(amountWithDiscount, region);
return amountWithDiscount + tax;
}
}
Теперь при каждом изменении любого из нижележащих компонентов будет меняться и PriceCalculator. И это может быть ок, если все эти компоненты сильно связаны и не переиспользуются в других местах. Но если это не так и компоненты не сильно друг от друга зависимы, то это будет скорее проблемой. Также возможно, что это будут не просто дополнительные параметры, а промежуточные инфраструктурные вызовы — и тогда всё станет ещё сложнее. Но про это мы поговорим в разделе "Проблемы, с которыми можно столкнуться. Трилемма".
Также при сильной вложенности гораздо проще допускать баги и неоптимальные реализации. Я видел подобное много раз и без подхода, который мы рассматриваем - в обычной анемичной модели. Например:
class PriceCalculator {
DiscountCalculator discountCalculator;
TaxCalculator taxCalculator;
public double calculate(OrderDTO order) {
var discount = discountCalculator.calculate(order);
var tax = taxCalculator.calculate(order);
return order.getAmount() - discount + tax;
}
}
class TaxCalculator {
DiscountCalculator discountCalculator;
public double calculate(OrderDTO order) {
var amountWithDiscount = order.getAmount() - discountCalculator.calculate(order);
// some logic
return 0;
}
}
Здесь, например, дважды вызывается DiscountCalculator. Это может быть легко заметно в примере кода, который я привёл, но представьте, что у вас десятки классов с тысячами строк и сотнями методов, и это уже не так просто будет заметить. Хорошо, если это просто лишний вызов простого метода в памяти без сложных калькуляций — он ничего не будет стоить. Но здесь есть и поле для багов: например, если PriceCalculator будет думать, что нужно передать всю сумму уже со скидкой, а TaxCalculator будет ещё раз отнимать скидку от этой суммы. В этом случае вы будете очень рады, если у вас всё будет покрыто полезными тестами :)
Итак, данный пункт имеет как свои плюсы, так и минусы.
Преимуществ тут несколько:
-
Простота понимания и поддержки, так как компоненты не зависят друг от друга. Это упрощает:
Понимание кода. Зачастую намного проще понимать вызов нескольких функций подряд, чем прокликивать граф с большой вложенностью;
Рефакторинг;
Переиспользование.
Проще писать юнит-тесты, так как они будут меньше, и не встаёт вопросов, что нужно тестировать в верхнем компоненте, а что в нижележащих.
Помогает решить проблему с вложенными компонентами когда компоненты верхнего уровня могут делать слишком много.
В случае, если между вызовами компонентов будут промежуточные инфраструктурные вызовы, плоская структура будет сильно проще.
Но это также может иметь и свои минусы:
Если компоненты сильно связаны логически, то нарушается инкапсуляция и абстракция. Ведь можно поспорить, что калькуляция цены — это единая операция, и она должна быть инкапсулирована в одном компоненте, который можно легко протестировать.
Если несколько компонентов постоянно вызываются вместе, то происходит дублирование логики.
Потенциально больше параметров в методах.
Поэтому всегда анализируйте вашу логику. Возможно, она у вас лёгкая, и вам в принципе не нужно много компонентов — достаточно и одного. А возможно, она суперсложная, с десятками компонентов и промежуточными инфраструктурными вызовами, и тогда плоская структура даст свои плоды. Главное, если уж делаете вложенность, старайтесь не делать её слишком глубокой.
Чуть подробнее мы ещё затронем преимущества плоской структуры далее в разделе "Проблемы, с которыми можно столкнуться. Трилемма".
4. Превратить сервисы - в Простые Объекты.
Простые Объекты (Humble Object) - классы без бизнес логики отвечающие только за то чтобы управлять координацией бизнес и инфраструктурных компонентов в рамках бизнес операции.
Превращение сервисов (Application Services в DDD) в такие объекты позволяет максимально разделить бизнес и инфрастуктурную логику. При этом когда большая часть вашей бизнес логики переедет в узкоспециализированные компоненты то сервисы сами превратятся в Простые Объекты. UserServiceRefactored или OrderServiceRefactored из пункта #1 примеры таких объектов.
Application Services
Если в анемичной модели бизнес-логика и инфраструктура смешаны в сервисах, то в DDD application-сервис отвечает только за координацию бизнес-операций, обращаясь к доменному слою (сущностям и доменным сервисам) и инфраструктурным компонентам (например, репозиториям).
5. Использовать Доменные Сервисы
Если какая-либо логика не подходит для компонентов, но при этом не является частью инфраструктуры, её можно писать в так называемых доменных сервисах. Эта идея почерпнута из DDD, но с некоторыми отличиями.
В DDD доменный сервис служит для тех операций, которым нет естественного места в сущности и у него не должно быть состояния (Э. Эванс). Доменные сервисы в DDD могут включать зависимости и служить для разных кейсов — например, для обёртывания операций, включающих несколько сущностей с промежуточной логикой, или для вычисления некоторых значений.
В нашем случае отличия будут в том, что доменный сервис будет заниматься только тем, чтобы оборачивать операцию, включающую несколько сущностей с промежуточной логикой. При этом он не должен включать инфраструктурные зависимости и не будет заниматься вычислением значений, так как для этого уже есть компоненты. В большинстве случаев он может быть полезен в случае наличия логики (например if-else) в (Application) сервисе.
Пример:
Предположим, у нас есть сущность Order (заказ), и у неё есть Milestones (точки на его пути). Также у нас есть сущность Lane — заранее подготовленный путь для Order. Milestones могут создаваться на основе существующих Milestones из Lane или на основе данных из запроса (если Order создаётся не на основе существующего Lane).
// (Application) service
class OrderService {
OrderCreator orderCreator;
LaneRepository laneRepository;
public Order create(OrderDTO orderDTO, String orderSource) {
Order order;
if (orderSource.equals("LANE")) {
Lane lane = laneRepository.findById(orderDTO.getLaneId());
order = orderCreator.create(orderSource, convertLaneMilestones(lane.getMilestones()));
} else if (orderSource.equals("REQUEST")) {
order = orderCreator.create(orderSource, orderDTO.getOrderMilestones());
} else {
order = orderCreator.create(orderSource, List.of());
}
return order;
}
}
// компонент
class OrderCreator {
public Order create(String orderSource, List<OrderMilestones> orderMilestones) {
// some logic
}
}
Как мы видим в нашем сервисе проросла бизнес логика. Как мы может это решить? Самым простым вариантом было бы перенести логику в OrderCreator:
class OrderServiceRefactored {
OrderCreator orderCreator;
LaneRepository laneRepository;
public Order create(OrderDTO orderDTO, String orderSource) {
Lane lane = laneRepository.findById(orderDTO.getLaneId());
var order = orderCreator.create(
orderDTO.getOrderMilestones(),
orderSource,
lane.getMilestones());
return order;
}
}
class OrderCreator {
public Order create(List<OrderMilestones> orderMilestones, String orderSource,
List<LaneMilestone> laneMilestones) {
Order order;
if (orderSource.equals("LANE")) {
order = create(orderSource, convertLaneMilestones(laneMilestones));
} else if (orderSource.equals("REQUEST")) {
order = create(orderSource, orderMilestones);
} else {
order = create(orderSource, List.of());
}
return order;
}
private List<OrderMilestones> convertLaneMilestones(List<LaneMilestone> milestones) {
// some logic
}
public Order create(String orderSource, List<OrderMilestones> orderMilestones) {
// some logic
}
}
Это может быть вполне себе рабочим вариантом. Единственным минусом будет то, что теперь OrderCreator знает слишком много, и сигнатура его метода не оптимальна, так как включает параметры нужные для разных кейсов.
Ещё одним вариантом может быть создание ещё одного компонента и поместить логику if-else в него:
class OrderServiceRefactored {
LaneRepository laneRepository;
OrderCreatorDispatcher orderCreatorDispatcher;
public Order create(OrderDTO orderDTO, String orderSource) {
Lane lane = laneRepository.findById(orderDTO.getLaneId());
var order = orderCreatorDispatcher.create(
orderDTO.getOrderMilestones(),
orderSource,
lane.getMilestones());
return order;
}
}
class OrderCreatorDispatcher {
OrderCreator orderCreator;
public Order create(List<OrderMilestones> orderMilestones, String orderSource,
List<LaneMilestone> laneMilestones) {
Order order;
if (orderSource.equals("LANE")) {
order = orderCreator.create(orderSource, convertLaneMilestones(laneMilestones));
} else if (orderSource.equals("REQUEST")) {
order = orderCreator.create(orderSource, orderMilestones);
} else {
order = orderCreator.create(orderSource, List.of());
}
return order;
}
private List<OrderMilestones> convertLaneMilestones(List<LaneMilestone> milestones) {
// some logic
}
}
Тоже вполне себе рабочий вариант. В данном случае минусом будет уже то, что мы создаём более глубокую вложенную структуру.
Теперь попробуем сделать тоже самое, но через доменный сервис:
class OrderServiceRefactored {
LaneRepository laneRepository;
OrderCreationDomainService orderCreationDomainService;
public Order create(OrderDTO orderDTO, String orderSource) {
Lane lane = laneRepository.findById(orderDTO.getLaneId());
var order = orderCreationDomainService.create(
orderDTO.getOrderMilestones(),
orderSource,
lane.getMilestones());
return order;
}
}
class OrderCreationDomainService {
OrderCreator orderCreator;
public Order create(List<OrderMilestones> orderMilestones, String orderSource,
List<LaneMilestone> laneMilestones) {
Order order;
if (orderSource.equals("LANE")) {
order = orderCreator.create(orderSource, convertLaneMilestones(laneMilestones));
} else if (orderSource.equals("REQUEST")) {
order = orderCreator.create(orderSource, orderMilestones);
} else {
order = orderCreator.create(orderSource, List.of());
}
return order;
}
private List<OrderMilestones> convertLaneMilestones(List<LaneMilestone> milestones) {
// some logic
}
}
class OrderCreator {
public Order create(String orderSource, List<OrderMilestones> orderMilestones) {
// some logic
}
}
Хотя внешне мало что изменилось, мы избежали вложенности компонентов(хоть и в ущерб доменному сервису, но такова его судьба) и при этом сохранили их чистоту.
Несмотря на это, явной необходимости в доменном сервисе всё же нет. Любое из решений будет рабочим и приемлемым. Даже оставить всё как есть иногда может быть ок — в случае, если if-else не будет разрастаться, можно просто покрыть его интеграционными тестами.
Я долго думал, включать ли данный пункт. Так как, как мы видим, практически всегда можно обойтись без доменного сервиса — поместив логику в один из существующих компонентов или создав новый. Более того, за несколько лет, что мы использовали данный подход, мы ни разу так и не создали ни одного доменного сервиса. Но в итоге я всё же решил, что стоит добавить его в эту статью, так как теоретически доменный сервис может быть компромиссом, чистотой которого можно поступиться ради производительности, чистоты других компонентов (Подробнее про это в разделе "Проблемы, с которыми можно столкнуться. Трилемма") и плоской структуры.
6. Не делать компоненты бинами (совет)
В случае, если ваш компонент является бином, у людей всегда будет соблазн что-то в него внедрить (inject), и не всегда получится заметить это на код-ревью. В случае же, если это простой класс, человеку уже предстоит больше задуматься о том, стоит ли это делать, да и на ревью это проще заметить.
Т.е.:
class OrderService {
final DiscountCalculator discountCalculator = new DiscountCalculator();
...
}
вместо:
class OrderService {
@Inject
DiscountCalculator discountCalculator;
...
}
Как альтернатива, вы можете помечать такие классы своей кастомной аннотацией и потом проверять статическим анализом, что в них не внедряются инфраструктурные бины. Хотя лично я не вижу в этом особых плюсов.
Почему не стоит делать методы статическими: В случае со статическими методами намного сложнее будет понимание кода, так как вы уже не сможете, просто взглянув на зависимости класса, сказать, от каких компонентов он зависит, а от каких нет (можно, конечно, использовать для этого импорты, но, имхо, это не так удобно).
Помимо этого, если вам всё же придётся иметь состояние в компонентах или использовать в них зависимости (про это ниже), то статические методы не подойдут, и у вас будет разнородный подход, что скажется на удобстве и понимании.
7. Писать юнит тесты только для бизнес логики (совет)
Так как подавляющая часть бизнес-логики будет отделена от инфраструктуры, а сервисы превратятся в Простые Объекты, то нет смысла писать на них юнит-тесты — так как это будут просто моки, проверяющие моки. Куда более эффективно покрывать их интеграционными тестами.
Пример:
class OrderServiceRefactored {
// покрывается UT
PriceCalculator priceCalculator;
// покрывается UT
DiscountCalculator discountCalculator;
// покрывается UT
TaxCalculator taxCalculator;
// покрывается IT
double calculateTotalPrice(OrderDTO order) {
double discount = discountCalculator.calculate(order);
double tax = taxCalculator.calculate(order, discount);
double totalPrice = priceCalculator.calculate(order, discount, tax);
return totalPrice;
}
}
Проблемы с которыми можно столкнуться
На примерах выше всё выглядит довольно просто. Теперь давайте рассмотрим возможные челленджи:
1. Как делить на компоненты
Тут нет чётких правил, но есть рекомендации, ± такие же, как и с любым другим разделением классов:
Используйте принципы SOLID. Если компонент используется разными акторами, разделите его на два компонента.
Не делайте классы слишком большими или слишком маленькими, если это не имеет чёткого обоснования.
Не пишите всю логику в доменных сервисах. Используйте доменные сервисы только если вы не смогли обосновать вынос логики в компоненты или создание нового компонента. На практике, при правильном разделении на компоненты, доменные сервисы могут вообще не пригодиться.
В случае, если часть большого компонента может переиспользоваться в других компонентах, лучше вынесите её в компонент поменьше и переиспользуйте, чем вызывать основные компоненты друг из друга. Но старайтесь быть осторожнее, так как это создаст вложенную структуру (смотри пункт 3).
2. Трилемма: чистота против полноты и производительности
В своей замечательной статье Владимир Хориков описывает один из фундаментальных вопросов DDD — что нам выбрать, когда мы стоим перед выбором между производительностью, чистотой и полнотой модели.
Я приведу описание проблемы и решения применительно к нашему подходу, но очень советую прочитать оригинальную статью.
Давайте начнём с примера из первого пункта, который мы уже рассматривали выше:
class OrderService {
UserRepository userRepository;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
if (user.hasBonuses()) {
return order.getAmount() - user.getBonuses();
}
if (order.getAmount() > 1000) {
return order.getAmount() * 0.1;
}
return 0;
}
}
class OrderServiceRefactored {
UserRepository userRepository;
DiscountCalculator discountCalculator;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
return discountCalculator.calculateDiscount(user.getBonuses(), order.getAmount());
}
}
class DiscountCalculator {
public double calculateDiscount(Double existingBonuses, Double amount) {
if (existingBonuses != null) {
return amount - existingBonuses;
}
if (amount > 1000) {
return amount * 0.1;
}
return 0;
}
}
Теперь представим, что в зависимости от результата первого условия (того, есть ли у пользователя бонусы), должен выполниться инфраструктурный вызов:
class OrderService {
UserRepository userRepository;
PromotionsService promotionsService;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
if (user.hasBonuses()) {
return order.getAmount() - user.getBonuses();
}
var promoDiscount = promotionsService.getActivePromoDiscount();
if (promoDiscount != null) {
return order.getAmount() * promoDiscount;
}
if (order.getAmount() > 1000) {
return order.getAmount() * 0.1;
}
return 0;
}
}
Теперь выделение отдельного компонента уже не так тривиально. По факту у нас три основных выбора — между полнотой модели, её чистотой и производительностью.
Выбор 1: Разделить логику между компонентом и сервисом/другим компонентом.
Хорошо для производительности и чистоты компонента, но плохо для полноты компонента:
class Solution1 {
class OrderServiceRefactored {
UserRepository userRepository;
PromotionsService promotionsService;
DiscountCalculator discountCalculator;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
if (user.hasBonuses()) {
return order.getAmount() - user.getBonuses();
}
var promoDiscount = promotionsService.getActivePromoDiscount();
return discountCalculator.calculateDiscount(promoDiscount, order.getAmount());
}
}
class DiscountCalculator {
public double calculateDiscount(Double promoDiscount, Double amount) {
if (promoDiscount != null) {
return amount * promoDiscount;
}
if (amount > 1000) {
return amount * 0.1;
}
return 0;
}
}
}
Как мы видим, теперь часть бизнес-логики находится в сервисе. Мы могли бы перенести её в отдельный метод того же DiscountCalculator или вообще в отдельный компонент, но насколько это бы имело смысл и сделало ситуацию лучше? Ведь данная операция должна быть вполне самодостаточна. И теперь логика одной операции размазана на несколько классов.
Разделить логику на несколько компонентов может быть хорошей идеей, но об этом далее.
Выбор 2: Добавить зависимость в компонент с бизнес-логикой.
Хорошо для производительности и полноты компонента, но плохо для чистоты компонента:
class Solution2 {
class OrderServiceRefactored {
UserRepository userRepository;
DiscountCalculator discountCalculator;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
return discountCalculator.calculateDiscount(user.getBonuses(), order.getAmount());
}
}
class DiscountCalculator {
// infrastructure dependency
PromotionsService promotionsService;
public double calculateDiscount(Double existingBonuses, Double amount) {
if (existingBonuses != null) {
return amount - existingBonuses;
}
var promoDiscount = promotionsService.getActivePromoDiscount();
if (promoDiscount != null) {
return amount * promoDiscount;
}
if (amount > 1000) {
return amount * 0.1;
}
return 0;
}
}
}
В этом случае мы нарушаем основной принцип нашего подхода — разделение бизнес-логики и инфраструктуры. Мы могли бы передавать зависимость как параметр метода:
public double calculateDiscount(Double existingBonuses, Double amount, PromotionsService promotionsService)
Или как функцию:
public double calculateDiscount(Double existingBonuses, Double amount, Supplier<Double> promotion)
Или даже создать функциональный интерфейс вместо Supplier-а:
public double calculateDiscount(Double existingBonuses, Double amount, PromoDiscountProvider promotion) {
...
}
...
@FunctionalInterface
interface PromoDiscountProvider {
Double getActivePromoDiscount();
}
Но это всё равно не избавило бы нас от этой зависимости, а просто сделало бы её неявной.
Хуже всего этот вариант работает с вложенной структурой из пункта 3 — ведь в этом случае, если нижележащим компонентам нужна инфраструктурная зависимость, её придётся тянуть по всему графу, начиная с самого верха.
Выбор 3: Заранее получить все нужные данные.
Хорошо для полноты и чистоты компонента, но плохо для производительности:
class Solution3 {
class OrderServiceRefactored {
UserRepository userRepository;
DiscountCalculator discountCalculator;
PromotionsService promotionsService;
public double calculateDiscount(Long userId, OrderDTO order) {
var user = userRepository.getById(userId);
var activePromoDiscount = promotionsService.getActivePromoDiscount();
return discountCalculator.calculateDiscount(user.getBonuses(), activePromoDiscount, order.getAmount());
}
}
class DiscountCalculator {
public double calculateDiscount(Double existingBonuses, Double activePromoDiscount, double amount) {
if (existingBonuses != null) {
return amount - existingBonuses;
}
if (activePromoDiscount != null) {
return amount * activePromoDiscount;
}
if (amount > 1000) {
return amount * 0.1;
}
return 0;
}
}
}
Это самый правильный способ с точки зрения богатой доменной модели — у нас чистая модель без фрагментации бизнес-логики. При этом, в зависимости от количества вызовов, которые нужно сделать заранее, это может быть далеко не самым оптимальным с точки зрения производительности.
Итого у нас есть 3 варианта:
-
Разделить бизнес-логику:
a. Между сервисами и компонентами
b. Создать отдельные компоненты
Добавить инфраструктурные зависимости в компоненты с бизнес-логикой.
Заранее получать все нужные данные/объекты в сервисе и передавать их в компоненты.
Касательно совета, что делать — тут моё мнение похоже на оригинальное мнение автора, хоть немного и отличается:
Если производительность для вас не проблема, то выбирайте третий вариант. В энтерпрайз-приложениях зачастую это предпочтительнее, и во многих случаях вы даже не почувствуете разницы в производительности. С другой стороны, в реальности у вас могут быть гораздо более сложные кейсы, например, фильтрация огромного списка по бизнес-правилам, а потом вызов инфраструктурной зависимости для отфильтрованных объектов. Или параметры для вызова зависимости будут зависеть от промежуточного результата.
Иначе разделите бизнес-логику между компонентом и сервисом, а если получится, между компонентами. В примере выше не было смысла создавать отдельный компонент. Но зато, если мы вернёмся к примеру №3 (Вложенная структура против Плоской), то там как раз-таки разделение на компоненты даёт свои преимущества и обусловлено не только производительностью, но и логически.
Самый нежелательный вариант — это второй, так как он нарушает саму суть нашего подхода. Его я бы использовал только в крайнем случае и с подходом с функциональными интерфейсами. Также можно условиться, что зависимости используются только в доменных сервисах (если это возможно), а не в компонентах, что поможет хотя бы в том, что они будут изолированы только там.
Данный пример довольно прост, но он наглядно показывает одну из основных проблем нашего подхода. Я также не стал описывать все плюсы и минусы каждого из решений подробно, так как это был бы в основном копипаст оригинальной статьи.
Резюме
Итак, подводя итоги и возвращаясь к проблемам, которые мы решаем и плюсам которые даёт правильно имплементированная анемичная модель. Благодаря чёткому разделению бизнес и инфраструктурной логики:
Мы упрощаем сложность понимания и поддержки, что является самой главной проблемой в сложных и долгоживущих проектах.
Это помогает больше концентроваться на бизнес логике, пусть и не настолько сильно как в богатой доменной модели.
Мы начинаем писать полезные юнит-тесты, которые действительно проверяют бизнес-логику. Помимо этого хорошие юнит тесты позволяют выявить проблемы с дизайном(Владимир Хориков, Принципы юнит-тестирования).
Позволяет сильно проще перейти на DDD в дальнейшем, если в этом будет необходимость.
Помимо этих плюсов, такой подход зачастую может помочь выявить или продотвратить проблемы с производительностью. Очень часто цепочки вызовов могут тянуться на сотни методов в десятках классов — очень легко в таком случае не заметить вложенных циклов и вызовов сторонних сервисов или базы данных в таких вложенных методах. В случае, когда у вас вся логика находится в одном или нескольких компонентах без инфраструктурных зависимостей, вам намного проще заметить или предовратить проблему.
Займёт ли такая имплементация больше времени? Безусловно, но оно того стоит. Как говорил Джон Остенхаут в своей книге о философии ПО, нужно всегда придерживаться стратегического программирования. Вкладывая время в правильную анемичную модель, вы в том числе упрощаете себе завтрашний день. К тому же, данный подход не должен сильно замедлить разработку, при этом в долгосрочной перспективе он принесёт значительно больше пользы. И он намного проще, чем переход на тот же DDD.
Кто-то может сказать, что предложенная здесь реализация анемичной модели слишком много почерпнула из DDD — да, так оно и есть, но при этом она как была, так и осталась анемичной.
Пару слов про DDD:
Далее идёт личное мнение автора, подкреплённое его опытом и опытом других людей, не претендующее на абсолютную правоту:
Вся сила DDD — в стратегических паттернах. Использовать их можно и нужно в большинстве ситуаций, даже без использования тактических, особенно при проектировании архитектуры, разбиении на сабдомены, сервисы и т.д., даже если это саппортинг или дженерик сабдомены и бизнес-логика суперпростая.
-
Богатая доменная модель без применения стратегических паттернов DDD (так называемый DDD-Lite) для меня в большинстве случаев является антипаттерном, так как, помимо того что это поощряет неполноценную доменную модель, это также:
сложнее для реализации, понимания и поддержки;
в долгоживущих проектах без должного контроля смешение данных и поведения непременно приведёт к нечитаемому коду и сложноуловимым багам;
со временем с огромной вероятностью логика будет размазываться по моделям и сервисам;
в отрыве от понимания бизнеса могут быть неправильно созданы сущности и агрегаты, что приведёт к проблемам с поддержкой, пониманием и производительностью. А рефакторить их, скорее всего, будет сложнее, чем анемичную модель;
DDD, перешедший в анемичную модель, сложнее для понимания, так как программист может думать, что это просто POJO, а внутри будет логика.
Исключением могут быть ситуации когда команда уже имеет богатый опыт с полноценным DDD и строго следит за реализацией и поддержкой DDD-Lite.
Говорить на одном языке с бизнесом (ubiquitous language) — добро в любом случае, но не всегда это возможно или необходимо в краткосрочной перспективе и для всех членов команды.
Опять же, это основано на том, что я видел, и во что превращались проекты с DDD-Lite без нужного контроля, и с другой стороны, насколько стратегические паттерны DDD помогают в понимании бизнеса и построении правильной модели. С удовольствием послушаю мнение людей, которые успешно используют DDD-Lite на протяжении долгого времени, в комментариях к статье.
PS
Я не призываю отказаться от DDD или утверждаю, что анемичная модель может его заменить. Я всего лишь хочу показать правильный, по моему мнению, способ работать с анемичной моделью, если вы уже с ней работаете, а внедрять DDD у вас нет ни желания, ни возможности, ни потребности.
Источники
Примеры кода из статьи:
Статьи:
https://enterprisecraftsmanship.com/posts/domain-model-purity-completeness/
https://enterprisecraftsmanship.com/posts/how-to-know-if-your-domain-model-is-properly-isolated/
https://vladikk.com/2016/04/05/tackling-complexity-ddd/ (или на русском https://habr.com/ru/articles/587520/)
https://vladikk.com/2018/01/26/revisiting-the-basics-of-ddd/
https://ddd-practitioners.com/home/glossary/supporting-subdomain/
Книги:
https://www.oreilly.com/library/view/unit-testing-principles/9781617296277/
https://www.oreilly.com/library/view/learning-domain-driven-design/9781098100124/
https://www.oreilly.com/library/view/implementing-domain-driven-design/9780133039900/
https://www.amazon.com/Philosophy-Software-Design-John-Ousterhout/dp/1732102201
https://www.oreilly.com/library/view/domain-driven-design-tackling/0321125215/
https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/
https://www.informit.com/store/balancing-coupling-in-software-design-universal-design-9780137353521
https://www.oreilly.com/library/view/applying-domain-driven-design/0321268202/