[Перевод] Анемичная модель предметной области — не анти-шаблон, а архитектура по принципам SOLID +17


От переводчика: На проекте, где я работаю, сейчас идет активное переписывание логики, ранее реализованной в виде богатой модели предметной области (с использованием Active Record и Unit of Work). Новый подход включает в себя классы сущностей без поведения и служб без состояния, взаимодействующих посредством интерфейсов — фактически, он представляет собой анемичную модель, с перспективой перехода в дальнейшем на микросервисную архитектуру. Наблюдая в режиме реального времени, как «макаронный монстр» из примерно полутора миллионов LOC постепенно обретает форму, как упрощаются тестирование, масштабирование и кастомизация системы под нуждый различных заказчиков, я был весьма удивлен, узнав, что такой подход часто рассматривается как архитектурный анти-шаблон. Пытаясь разобраться в причинах этого, я наткнулся на данную статью и размещаю здесь ее перевод, чтобы обсудить с сообществом плюсы и минусы подхода.


Оригинал: The Anaemic Domain Model is no Anti-Pattern, it’s a SOLID design


Шаблоны проектирования, анти-шаблоны и анемичная модель предметной области


Говоря об объектно-ориентированной разработке программного обеспечения, под шаблонами проектирования понимают повторяющиеся и эффективные способы решения часто возникающих проблем. Благодаря формализации и описанию таких шаблонов разработчики получают набор «проверенных в бою» архитектурных решений для определенных классов проблем, а также общий словарь для их описания, понятный другим разработчикам. Первым этот термин ввел Эрих Гамма в своей книге «Приемы объектно-ориентированного проектирования. Паттерны проектирования» [5], где он описал несколько часто применяемых шаблонов. По мере того как новое понятие набирало популярность, словарь шаблонов проектирования пополнялся ([6], [17]).


Вслед за ростом популярности концепции паттернов проектирования в обиход была введена идея «анти-шаблонов» ([7], [8]). Как ясно из самого названия, анти-шаблон — это противоположность шаблона. Он тоже описывает повторяющийся способ решения часто возникающей проблемы, однако, как правило, это решение нерабочее или неэффективное, оказывающее негативное влияние на «здоровье» системы (в плане простоты поддержки, расширяемости, надежности и т.д.). Анти-шаблоны служат тем же целям, что и шаблоны: при описании анти-шаблона показывают типичные варианты реализации, раскрывают контекст, в котором он применяется, и объясняют, к каким проблемам в разрабатываемом ПО это приводит.


Но концепция анти-шаблонов имеет свой недостаток: снижение критичности восприятия в вопросе применимости того или иного шаблона. Архитектурные решения, неприменимые в одной ситуации, могут оказаться разумным выбором в другой, однако, если решение признано анти-шаблоном, оно может быть отвергнуто без обсуждения, даже если на самом деле оно вполне подходило для решаемой задачи.


Я убежден, что одним из таких незаслуженно отвергаемых анти-шаблонов является Анемичная модель предметной области (АМПО, Anaemic Domain Model), описанная Мартином Фаулером [1] и Эриком Эвансом [2]. Оба автора описывают этот шаблон как неспособность смоделировать предметную область в объектно-ориентированном стиле, из-за чего бизнес-логика описывается в процедурном стиле. Такой подход противопоставляется Богатой модели предметной области (БМПО, Rich Domain Model) [1], [20] — в ней классы, представляющие сущности предметной области, содержат в себе и данные, и всю бизнес-логику. Да, анемичная модель может быть неудачным выбором для некоторых систем, но совершенно не факт, что то же самое справедливо для любых систем. В этой статье я рассматриваю аргументы, выдвигаемые против анемичной модели, и обосновываю, почему в ряде сценариев АМПО выглядит разумным выбором с точки зрения соответствия принципам SOLID, сформулированным Робертом Мартином ([3], [4]), — принципам, в которых заключены рекомендации по достижению баланса между простотой, масштабируемостью и надежностью при разработке ПО. Решая гипотетическую проблему и сравнивая анемичную и богатую модели, я намерен показать, что АМПО лучше соответствует приципам SOLID. Тем самым я хочу оспорить категоричное мнение об этом подходе, навязанное авторитетами, и показать, что использование АМПО — на самом деле, годное архитектурное решение.


Почему анемичную модель предметной области считают анти-шаблоном?


Фаулер [1] и Эванс [2] описывали АМПО как совокупность классов без поведения, содержащих данные, необходимые для моделирования предметной области. В этих классах практически нет (или нет вовсе) логики по валидации данных на соответствие бизнес-правилам. Вместо этого, бизнес-логика заключена в слое служб, который состоит из типов и функций, обрабатывающих элементы модели в соответствии с бизнес-правилами. Основной аргумент против такого подхода состоит в том, что данные и способы их обработки оказываются разделены, что нарушает один из фундаментальных принципов объектно-ориентированного подхода, т.к. не позволяет модели обеспечивать собственные инварианты. В противоположность этому, хотя БМПО и состоит из того же набора типов, содержащих данные о предметной области, — но вся бизнес-логика также заключена в этих сущностях, будучи реализованной в виде методов классов. Таким образом, БМПО хорошо согласуется с принципами инкапсуляции и сокрытия информации. Как было отмечено Майклом Скоттом в [9]: «Благодаря инкапсуляции, разработчики могут объединять данные и операции по их обработке в одном месте, а также скрывать ненужные детали от пользователей обобщенной модели».


В БМПО слой служб чрезвычайно тонок, а иногда и вовсе отсутствует [20], и все правила, относящиеся к предметной области, реализуются посредством модели. Тем самым утверждается, что сущности предметной области способны полностью самостоятельно обеспечивать свои инварианты, что делает такую модель полноценной с точки зрения объектно-ориентированного подхода.


Не нужно забывать, однако, что способность модели обеспечивать выполнение определенных ограничений, налагаемых на данные, — это лишь одно из множества свойств, которыми должна обладать система. Пусть АМПО жертвует возможностью валидации на уровне отдельных бизнес-сущностей, но взамен она дает невероятную гибкость и простоту поддержки системы в целом, благодаря тому, что реализация логики вынесена в узкоспециализированные классы, а доступ к ним осуществляется через интерфейсы. Эти преимущества имеют особенно большое значение в языках со статической типизацией, таких как Java или C# (в которых поведение класса не может быть изменено во время исполнения программы), т.к. улучшают тестируемость системы путем введения явных «швов» ([10], [11]) с целью устранения чрезмерной связанности.


Простой пример


Давайте представим серверную часть интернет-магазина, где клиент может как покупать товары, так и выставлять на продажу товары для других клиентов со всего земного шара. Приобретение товара приводит к уменьшению средств на счету покупателя. Подумаем, как можно реализовать процесс размещения клиентом заказа на приобретение товара. Согласно требованиям, клиент может разместить заказ, если у него а) достаточно средств на счету, и б) товар доступен в регионе клиента. При использовании БМПО, класс Customer будет описывать сущность «Клиент»; он будет включать все свойства клиента и такие методы как PurchaseItem(Item item) (Купить товар). Аналогично, классы Item и Order представляют модели предметной области, описывающие сущности Товар и Заказ, соответственно. Реализация класса Customer (на псевдо-C#) может быть примерно такой:


/*** КОД С ИСПОЛЬЗОВАНИЕ БМПО ***/

class Customer : DomainEntity // Базовый класс, предоставляющий CRUD-операции
{
    // Опускаем объявление закрытых членов класса

    public bool IsItemPurchasable(Item item) 
    {
        bool shippable = item.ShipsToRegion(this.Region);
        return this.Funds >= item.Cost && shippable;
    }

    public void PurchaseItem(Item item)
    {
        if (IsItemPurchasable(item))
        {
            Order order = new Order(this, item);
            order.Update();
            this.Funds -= item.Cost;
            this.Update();
        }
    }
}

/*** КОНЕЦ КОДА С ИСПОЛЬЗОВАНИЕ БМПО  ***/

Сущности предметной области реализуются с использованием шаблона Active Record [17], в котором используются методы Create/Read/Update/Delete (реализованные на уровне фреймворка или базового класса), позволяющие изменять записи в слое хранения данных (например, в базе данных). Предполагается, что метод PurchaseItem вызывается в рамках транзакции, совершаемой над хранилищем данных и управляемой извне (например, она может открываться в обработчике HTTP-запроса, который извлекает информацию о клиенте и товаре непосредственно из переданных в запросе параметров). Получается, что в нашей БМПО роль сущности «Клиент» состоит 1) в представлении модели данных, 2) реализации бизнес-правил, 3) создании сущности «Заказ» для совершения покупки и 4) взаимодействии со слоем хранения данных посредством методов, определенных для Active Record. Воистину, «богатству» такой модели позавидовал бы царь Крез, а мы ведь рассматривали довольно простой вариант использования.


Следующий пример иллюстрирует, как та же логика могла бы быть выражена средствами АМПО, в тех же условиях:


/*** КОД С ИСПОЛЬЗОВАНИЕМ АМПО ***/

class Customer { /* Some public properties */ }
class Item { /* Some public properties */ }

class IsItemPurchasableService : IIsItemPurchasableService
{
    IItemShippingRegionService shipsToRegionService;

    public bool IsItemPurchasable(Customer customer, Item item)
    {
        bool shippable = shipsToRegionService.ShipsToRegion(item);
        return customer.Funds >= item.Cost && shippable;
    }
}

class PurchaseService : IPurchaseService
{
    ICustomerRepository customers;
    IOrderFactory orderFactory;
    IOrderRepository orders;
    IIsItemPurchasableService isItemPurchasableService;

    // Конкретные экземпляры инициализируются в конструкторе

    public void PurchaseItem(Customer customer, Item item)
    {
        if (isItemPurchasableService.IsItemPurchasable(customer, item))
        {
            Order order = orderFactory.CreateOrder(customer, item);
            orders.Insert(order);
            customer.Balance -= item.Cost;
            customers.Update(customer);
        }
    }
}

/*** КОНЕЦ КОДА С ИСПОЛЬЗОВАНИЕМ АМПО  ***/

Сравнение примеров реализации с точки зрения соответствия принципам SOLID


На первый взгляд, АМПО явно проигрывает БМПО. В ее реализации использовано больше классов, а логика размазана по двум доменным службам (IPurchaseService и IItemPurchasableService) и ряду служб приложения (IOrderFactory, ICustomerRepository и IOrderRepository), вместо того чтобы располагаться в пределах модели предметной области. Классы предметной области теперь не содержат никакого поведения, а всего лишь хранят данные и допускают изменение своего состояния вне рамок наложенных ограничений (и — о ужас! — утрачивают способность обеспечивать собственные инварианты). Учитывая все эти явные недостатки, как вообще можно рассматривать такую модель как конкурента куда более объектно-ориентированной БМПО?


Причины, по которым АМПО является превосходным выбором для данного сценария, проистекают из рассмотрения принципов SOLID и их наложения на обе рассматриваемые архитектуры [12]. «S» означает «Принцип единственной ответственности» (Single Responsibility Pronciple, [13]), который гласит, что класс должен делать только что-то одно — но делать это хорошо. В частности, класс должен реализовывать лишь одну абстракцию. «O» — «Принцип открытости/закрытости» (Open/Closed Principle, [14]), постулат о том, что класс должен быть «открытым для расширения, но закрытым для изменения». Это означает, что при разработке класса надо максимально стремиться к тому, чтобы реализацию не пришлось изменять в будущем, тем самым сводя к минимуму последствия вносимых изменений.


Казалось бы, класс Customer в БМПО реализует единственную абстракцию «Клиент», но на самом деле этот класс отвечает за множество вещей. Этот класс моделирует и данные, и логику в рамках одной и той же абстракции, несмотря на то, что бизнес-логика имеет обыкновение меняться куда чаще, чем структура данных. Этот же класс создает и инициализирует сущности «Заказ» в момент совершения покупки, и даже содержит логику, определяющую, может ли клиент совершить покупку. А предоставляя базовые CRUD-операции, определенные в базовом классе, сущность предметной области «Клиент» оказывается еще и связанной с той моделью хранилища данных, которая поддерживается базовым классом. Стоило нам перечислить все эти обязанности, как стало очевидным, что сущность Customer в БМПО являет собой пример слабого разделения ответственности.


Анемичная модель наоборот разделяет зоны ответственности таким образом, что каждый компонент представляет единственную абстракцию. Данные из предметной области представлены в виде «плоских» структур данных [18], тогда как бизнес-правила и чисто инфраструктурные задачи (сохранение, создание новых экземпляров объектов и т.п.) заключены в отдельных службах (и доступны посредством абстрактных интерфейсов). Как следствие, связанность классов уменьшается.


Сравнение гибкости решений на базе богатой и анемичной моделей предметной области


Рассмотрим примеры сценариев, при которых нам пришлось бы изменять класс Customer в БМПО.


  • Необходимо добавить новое поле (или изменить тип данных существующего).
  • В конструктор класса Order необходимо передать дополнительный параметр.
  • Бизнес-логика, относящаяся к покупке товара, усложнилась.
  • Возникла необходимость сохранения данных в альтернативное хранилище, которое не поддерживается нашим гипотетическим базовым классом DomainEntity.

Теперь рассмотрим сценарии, в которых нам необходимо изменить типы, описанные в АМПО. Классы бизнес-сущностей, чье предназначение состоит в моделировании предметной области, подлежат изменению тогда и только тогда, когда изменяются требования к составу данных. В случае усложнения правил, по которым определяется возможность приобретения того или иного товара (например, для товара указывается минимально допустимый «рейтинг доверия» клиента, которому этот товар может быть продан), изменению подлежит только реализация IIsItemPurchasableService, в то время как при использовании БМПО нам пришлось бы соответствующим образом изменять класс Customer. Если меняются требования к хранилищу данных — в АМПО задача решается путем передачи в PurchaseService из вышестоящего класса служб приложения новой реализации существующего интерфейса репозитория [17], [19], не требуя модификации существующего кода; в БМПО так легко не отделаться, модификация базового класса затронет все классы бизнес-сущностей, унаследованных от него. В случае, когда для создания экземпляра класса Order необходимо передать дополнительный параметр, реализация IOrderFactory может оказаться в состоянии обеспечить это изменение, не оказывая влияния на PurchaseService. В анемичной модели у каждого класса единственная ответственность, и вносить изменения в класс придется только при изменении соответствующего требования в предметной области (или связанной инфраструктуре).


А сейчас представим, что для реализации нового бизнес-требования мы должны обеспечить возможность возврата средств, если клиент не удовлетворен покупкой. В богатой модели это можно было бы реализовать путем добавления метода RefundItem к сущности «Клиент», аргументируя это тем, что вся логика, относящаяся к клиенту, оказывается заключенной в сущности Customer. Однако процедура возврата денежных средств сильно отличается от процедуры совершения покупки, ответственность за которую ранее была возложена на класс Customer, и в результате мы получаем еще большее смешение ответственностей в пределах одного типа. Выходит, в классах богатой модели могут накапливаться слабо связанные элементы бизнес-логики, повышая сложность их структуры. В анемичной модели механизм возврата денежных средств можно реализовать путем создания нового класса RefundService, который будет реализовывать только логику, непосредственно относящуюся к возвратам. Этот класс может зависеть от нескольких других абстракций (т.е. интерфейсов других доменных и инфраструктурных служб), необходимых для выполнения им своих обязанностей. Обращение к методам класса RefundService может происходить из вышележащих уровней (в ответ на запрос об осуществлении возврата денежных средств), и выходит, что реализацию нового сценария удалось выполнить безо всякого влияния на ранее разработанную функциональность.


В рассмотренном примере проблема закрепления за одним классом не связанных между собой ответственностей, с которой мы столкнулись в БМПО, эффективно решается в анемичной модели при помощи букв I и D из аббревиатуры SOLID. Это, я напомню, «Принцип разделения интерфейса» (Interface Segregation Principle, [15]) и «Принцип инверсии зависимостей» (Dependency Inversion Principle, [16]). Они утверждают, что интерфейсы должны представлять собой наборы сильно сцепленных методов, и что интерфейсы должны использоваться для соединения частей системы воедино (в случае АМПО — соединение служб доменного слоя между собой). Следование принципу разделения интерфейса, как правило, дает в результате небольшие, узкоспециализированные интерфейсы — такие как IItemShippingRegionService и IIsItemPurchasableService из нашего примера, или интерфейс абстрактного репозитория. Принцип инверсии зависимостей заставляет нас опираться на эти интерфейсы, чтобы одна служба не зависела от деталей реализации другой.


Анемичная модель предметной области лучше поддерживает автоматизированное тестирование


Более гибкая и податливая структура приложения, а также следование вышеупомянутым принципам, позволяют анемичной модели проявить свои преимущества над БМПО в упрощении автоматизированного тестирования. Сильно сцепленные, но слабо связанные между собой компоненты общаются посредством интерфейсов и собираются воедино посредством внедрения зависимостей, что позволяет без особого труда подменять зависимости «пустышками», mock-объектами. Отсюда, в АМПО несложно реализовывать такие сценарии для автоматизированного тестирования, которые было бы гораздо труднее реализовать в рамках БМПО, тем самым улучшается простота поддержки автоматизированных тестов. При снижении «стоимости» автоматизированных тестов разработчики более охотно создают и поддерживают их в актуальном состоянии. В качестве иллюстрации, попробуем разработать модульный тест для метода IsItemPurchasable.


Согласно предъявленным требованиям, товар считается доступным для покупки, если у клиента достаточно средств на счету, и он находится в регионе, куда этот товар может быть доставлен. Положим, мы пишем тест, проверяющий, что если у клиента достаточно средств на счету, но он не находится в регионе, куда осуществляется доставка данного товара, то этот товар недоступен для покупки. В БМПО такой тест, вероятно, включал бы создание экземпляров Клиент (Customer) и Товар (Item), настройку Клиента таким образом, чтобы средства на его счету превышали стоимость Товара, и чтобы его регион не входил в перечень регионов, куда этот товар доставляется. После чего мы должны были бы убедиться, что customer.IsItemPurchasable(item) возвращает значение false. Однако метод IsItemPurchasable зависит от деталей реализации метода ShipsToRegion класса Item. Изменение бизнес-логики, относящейся к товару, приведет к изменению результатов этого теста. Такой эффект нежелателен, так как данный тест должен проверять исключительно логику, заключенную в классе Customer, а логика метода ShipsToRegion, заключенная в сущности «Товар», должна покрываться отдельным тестом. Поскольку бизнес-логика заключена в сущностях, описывающих предметную область и предоставляющих открытый интерфейс для доступа к заключенной в них логике, классы оказываются сильно связанными, что приводит к лавинообразному эффекту при внесении изменений, из-за чего автоматизированные тесты становятся хрупкими.


С другой стороны, в АМПО логика метода IsItemPurchasable вынесена в отдельную специализированную службу, которая зависит от абстрактных интерфейсов (метод IItemShippingRegionService.ShipsToRegion). Для рассматриваемого теста мы можем попросту создать заглушку для IItemShippingRegionService, в которой будет реализован метод ShipsToRegion, всегда возвращающий false. Разделив бизнес-логику по изолированным модулям, мы защитили каждую часть от изменений деталей реализации в других частях. На практике это означает, что небольшое изменение логики скорее всего приведет к «падению» лишь тех тестов, которые непосредственно проверяют поведение того кода, в который были внесены изменения, что можно использовать для проверки правильности нашего представления об изменяемом коде.


Рефакторинг БМПО с целью соблюдения принципов SOLID приводит к «анемии» модели


Сторонники архитектуры, использующей БМПО, могут возразить, что описанный гипотетический пример не соответствует «истинной» богатой модели. Они скажут, что в правильно реализованной богатой модели нельзя смешивать сущности предметной области с задачами по их записи в хранилище — вместо этого предпочтительнее использовать объекты передачи данных (DTO, Data Transfer Object, [17], [18]), посредством которых происходит обмен со слоем хранения данных. Они разнесут в пух и прах идею прямого вызова конструктора класса Order непосредственно из логики класса Customer — разумеется, ни в одной вменяемой реализации сущности предметной области не будут вызывать конструктор напрямую, здравый смысл заставляет использовать фабрику [5]! Но по мне, это выглядит как попытка применять мощь принципов SOLID к инфраструктурным службам, при полном их игнорировании в приложении к модели предметной области. Если нашу гипотетическую БМПО рефакторить для соответствия принципам SOLID, будут выделены более мелкие сущности: из сущности Клиент могут быть выделены сущности «Покупка клиента» (CustomerPurchase) и «Возврат ден.средств клиента» (CustomerRefund). Но может статься, что и новые модели будут по-прежнему зависеть от элементарных бизнес-правил, изменяемых независимо друг от друга, а от них, в свою очередь, будут зависеть другие сущности. Во избежание дублирования логики и сильной связанности классов эти правила придется и дальше рефакторить, выделяя их в отдельные модули, доступ к которым осуществляется посредством интерфейсов. В итоге, богатая модель, отрефакторенная до полного соответствия принципам SOLID, стремится к состоянию анемичной модели!


Заключение


Исследовав реализацию простого примера, мы пришли к выводу, что анемичная модель предметной области ближе соответствует принципам SOLID, чем богатая модель. Мы увидели преимущества, которые дает соответствие принципам SOLID: слабую связанность и сильную сцепленность, повышающие гибкость архитектуры приложения. Свидетельством возросшей гибкости явилось улучшение тестируемости приложения из-за легкости реализации «заглушек» для зависимостей. Рассматривая пути достижения этих же качеств в рамках БМПО, мы обнаружили, что рефакторинг богатой модели закономерно приводит к ее «анемичности».


Таким образом, если соответствие принципам SOLID является признаком хорошо структурированного объектно-ориентированного приложения, а анемичная модель ближе соответствует этим принципам, чем богатая модель, то анемичную модель не следует считать анти-шаблоном; ее надлежит рассматривать как жизнеспособный вариант архитектуры при моделировании предметной области.


Ссылки


Развернуть

[1] Fowler, Martin. Anaemic Domain Model. http://www.martinfowler.com/bliki/AnemicDomainModel.html, 2003.


[2] Evans, Eric. Domain-driven design: tackling complexity in the heart of software. Addison-Wesley Professional, 2004.


[3] Martin, Robert C. The Principles of Object-Oriented Design. http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod, 2005.


[4] Martin, Robert C. Design principles and design patterns. Object Mentor, 2000: 1-34.


[5] Erich, Gamma, et al. Design patterns: elements of reusable object-oriented software. Addison Wesley Publishing Company, 1994.


[6] Wolfgang, Pree. Design patterns for object-oriented software development. Addison-Wesley, 1994.


[7] Rising, Linda. The patterns handbook: techniques, strategies, and applications. Vol. 13. Cambridge University Press, 1998.


[8] Budgen, David. Software design. Pearson Education, 2003.


[9] Scott, Michael L. Programming language pragmatics. Morgan Kaufmann, 2000.


[10] Hevery, Misko. Writing Testable Code. http://googletesting.blogspot.co.uk/2008/08/by-miko-hevery-so-you-decided-to.html, Google Testing Blog, 2008.


[11] Osherove, Roy. The Art of Unit Testing: With Examples in. Net. Manning Publications Co., 2009.


[12] Martin, Robert C. Agile software development: principles, patterns, and practices. Prentice Hall PTR, 2003.


[13] Martin, Robert C. SRP: The Single Responsibility Principle. http://www.objectmentor.com/resources/articles/srp.pdf, Object Mentor, 1996.


[14] Martin, Robert C. The Open-Closed Principle. http://www.objectmentor.com/resources/articles/ocp.pdf, Object Mentor, 1996.


[15] Martin, Robert C. The Interface Segregation Principle. http://www.objectmentor.com/resources/articles/isp.pdf, Object Mentor, 1996.


[16] Martin, Robert C. The Dependency Inversion Principle, http://www.objectmentor.com/resources/articles/dip.pdf, Object Mentor, 1996.


[17] Fowler, Martin. Patterns of enterprise application architecture. Addison-Wesley Longman Publishing Co., Inc., 2002.


[18] Fowler, Martin. Data Transfer Object. http://martinfowler.com/eaaCatalog/dataTransferObject.html, Martin Fowler site, 2002.


[19] Fowler, Martin. Repository. http://martinfowler.com/eaaCatalog/repository.html, Martin Fowler site, 2002.


[20] Fowler, Martin. Domain Model. http://martinfowler.com/eaaCatalog/domainModel.html, Martin Fowler site, 2002.




К сожалению, не доступен сервер mySQL