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

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

Источники

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

Статьи:

Книги:

Комментарии (0)