Доброго времени суток, Хабр!
Сегодня хотел бы поговорить об анемичной модели — одном из самых дискуссионных топиков (особенно для приверженцев 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/
ссылка на оригинал статьи https://habr.com/ru/articles/917012/
Добавить комментарий