Преодоление сложности в самом сердце Анемичной Модели

от автора

Доброго времени суток, Хабр!

Сегодня хотел бы поговорить об анемичной модели — одном из самых дискуссионных топиков (особенно для приверженцев 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 (или, по-простому, делать, как сказано, не вникая в суть).

  • Из-за своей сложности модель избыточна для многих видов простых приложений и сервисов.

  • * Некоторые считают, что она плохо применима в высоконагруженных приложениях, но я с этим не до конца согласен.

* — означает не общепринятые тезисы, а по мнению автора статьи.

В данной статье речь пойдет только о тактических паттернах.

Проблемы

Итак, перед тем как приступать к решению, перечислю ещё раз список проблем анемичной модели, которые мы постараемся решить:

  1. Сложность понимания и поддержки: бизнес-логика раскидана по сервисам, что усложняет понимание и поддержку, особенно когда логика становится сложнее CRUD-а.

  2. Сложные и хрупкие юнит-тесты, которые:

    • Ломаются от рефакторинга;

    • Сложно поддерживаются;

    • С большим количеством моков, что усложняет понимание самих тестов;

    • Практически не приносят пользы и не выявляют багов;

    • Из-за регулярных падений начинаешь игнорировать и просто фиксишь их кое-как, чтобы они проходили;

    • Требуют высокий процент покрытия, из-за чего тесты пишутся ради покрытия, а не как защита от багов.

  3. Производительность: несмотря на распространённое мнение, что анемичная модель лучше подходит для производительных приложений, описанное решение позволит писать более оптимизированный код.

Я не стал перечислять проблемы, которые нам не решить данным подходом — например, нарушение принципа always valid domain или неиспользование силы ООП.

Решение

Основная идея подхода заключается в том, что для решения проблем анемичной модели мы почерпнём идеи из DDD, функционального программирования и некоторых других подходов. Применив их к анемичной модели, мы сделаем её более понятной, расширяемой и поддерживаемой.

Самый основной принцип: Разделить бизнес-логику и инфраструктуру. Этот принцип далеко не нов, он используется давно и в разных видах архитектур и подходов, таких как гексагональная архитектура, DDD, функциональное программирование и т.д.

Схематично это можно показать вот так:

Дальнейшие принципы исходят из того, как это сделать:

  1. Вынести бизнес-логику в узкоспециализированные классы-компоненты без инфраструктурных зависимостей.

  2. Стараться делать компоненты и их методы чистыми функциями, у которых нет состояния и которые не зависят и не влияют на другие компоненты, сервисы и т. д.

  3. Стараться использовать плоскую структуру вместо вложенной для компонентов.

  4. Превратить сервисы (Application Services в DDD) в Простые Объекты (Humble Object) — по факту классы без бизнес-логики, отвечающие только за то, чтобы управлять флоу.

  5. Если какая-либо логика не подходит для компонентов, но при этом не является частью инфраструктуры, писать её в так называемых доменных сервисах (Идея почерпнута из DDD, но отличается).

  6. Не делать компоненты бинами (совет).

  7. Писать юнит-тесты только для бизнес-логики (совет).

Подробнее о каждом пункте далее.

О названии

Я долго думал, как назвать данный подход. Вариантами были: «Правильная анемичная модель» (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 варианта:

  1. Разделить бизнес-логику:

    a. Между сервисами и компонентами

    b. Создать отдельные компоненты

  2. Добавить инфраструктурные зависимости в компоненты с бизнес-логикой.

  3. Заранее получать все нужные данные/объекты в сервисе и передавать их в компоненты.

Касательно совета, что делать — тут моё мнение похоже на оригинальное мнение автора, хоть немного и отличается:

  • Если производительность для вас не проблема, то выбирайте третий вариант. В энтерпрайз-приложениях зачастую это предпочтительнее, и во многих случаях вы даже не почувствуете разницы в производительности. С другой стороны, в реальности у вас могут быть гораздо более сложные кейсы, например, фильтрация огромного списка по бизнес-правилам, а потом вызов инфраструктурной зависимости для отфильтрованных объектов. Или параметры для вызова зависимости будут зависеть от промежуточного результата.

  • Иначе разделите бизнес-логику между компонентом и сервисом, а если получится, между компонентами. В примере выше не было смысла создавать отдельный компонент. Но зато, если мы вернёмся к примеру №3 (Вложенная структура против Плоской), то там как раз-таки разделение на компоненты даёт свои преимущества и обусловлено не только производительностью, но и логически.

  • Самый нежелательный вариант — это второй, так как он нарушает саму суть нашего подхода. Его я бы использовал только в крайнем случае и с подходом с функциональными интерфейсами. Также можно условиться, что зависимости используются только в доменных сервисах (если это возможно), а не в компонентах, что поможет хотя бы в том, что они будут изолированы только там.

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

Резюме

Итак, подводя итоги и возвращаясь к проблемам, которые мы решаем и плюсам которые даёт правильно имплементированная анемичная модель. Благодаря чёткому разделению бизнес и инфраструктурной логики:

  1. Мы упрощаем сложность понимания и поддержки, что является самой главной проблемой в сложных и долгоживущих проектах.

  2. Это помогает больше концентроваться на бизнес логике, пусть и не настолько сильно как в богатой доменной модели.

  3. Мы начинаем писать полезные юнит-тесты, которые действительно проверяют бизнес-логику. Помимо этого хорошие юнит тесты позволяют выявить проблемы с дизайном(Владимир Хориков, Принципы юнит-тестирования). 

  4. Позволяет сильно проще перейти на DDD в дальнейшем, если в этом будет необходимость.

  5. Помимо этих плюсов, такой подход зачастую может помочь выявить или продотвратить проблемы с производительностью. Очень часто цепочки вызовов могут тянуться на сотни методов в десятках классов — очень легко в таком случае не заметить вложенных циклов и вызовов сторонних сервисов или базы данных в таких вложенных методах. В случае, когда у вас вся логика находится в одном или нескольких компонентах без инфраструктурных зависимостей, вам намного проще заметить или предовратить проблему.

Займёт ли такая имплементация больше времени? Безусловно, но оно того стоит. Как говорил Джон Остенхаут в своей книге о философии ПО, нужно всегда придерживаться стратегического программирования. Вкладывая время в правильную анемичную модель, вы в том числе упрощаете себе завтрашний день. К тому же, данный подход не должен сильно замедлить разработку, при этом в долгосрочной перспективе он принесёт значительно больше пользы. И он намного проще, чем переход на тот же DDD.

Кто-то может сказать, что предложенная здесь реализация анемичной модели слишком много почерпнула из DDD — да, так оно и есть, но при этом она как была, так и осталась анемичной.

Пару слов про DDD:

Далее идёт личное мнение автора, подкреплённое его опытом и опытом других людей, не претендующее на абсолютную правоту:

  • Вся сила DDD — в стратегических паттернах. Использовать их можно и нужно в большинстве ситуаций, даже без использования тактических, особенно при проектировании архитектуры, разбиении на сабдомены, сервисы и т.д., даже если это саппортинг или дженерик сабдомены и бизнес-логика суперпростая.

  • Богатая доменная модель без применения стратегических паттернов DDD (так называемый DDD-Lite) для меня в большинстве случаев является антипаттерном, так как, помимо того что это поощряет неполноценную доменную модель, это также:

    • сложнее для реализации, понимания и поддержки;

    • в долгоживущих проектах без должного контроля смешение данных и поведения непременно приведёт к нечитаемому коду и сложноуловимым багам;

    • со временем с огромной вероятностью логика будет размазываться по моделям и сервисам;

    • в отрыве от понимания бизнеса могут быть неправильно созданы сущности и агрегаты, что приведёт к проблемам с поддержкой, пониманием и производительностью. А рефакторить их, скорее всего, будет сложнее, чем анемичную модель;

    • DDD, перешедший в анемичную модель, сложнее для понимания, так как программист может думать, что это просто POJO, а внутри будет логика.

  • Исключением могут быть ситуации когда команда уже имеет богатый опыт с полноценным DDD и строго следит за реализацией и поддержкой DDD-Lite.

  • Говорить на одном языке с бизнесом (ubiquitous language) — добро в любом случае, но не всегда это возможно или необходимо в краткосрочной перспективе и для всех членов команды.

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

PS

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

Источники

Примеры кода из статьи:

Статьи:

Книги:

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Станете ли вы применять этот подход после прочтения статьи?

42.86% Да3
0% Нет0
57.14% Не уверен4
0% Я использую чистый DDD0
0% Мне не актуально0

Проголосовали 7 пользователей. Воздержались 2 пользователя.

ссылка на оригинал статьи https://habr.com/ru/articles/917012/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *