Пример проектирования, ориентированного на домен: От хаоса к чистой архитектуре
A Domain-driven Design Example: From Chaos to Clean Architecture
Пример проектирования, ориентированного на домен: От хаоса к чистой архитектуре
Всестороннее исследование принципов Domain-driven Design, примененное на примере бизнеса, связанного аутсорсингом. Статья демонстрирует эволюцию от простой анемичной модели домена к сложному решению на основе DDD, охватывая такие важные понятия, как ограниченные контексты, агрегаты и доменные события. В ней даются практические советы по реализации синхронных и асинхронных паттернов интеграции, при этом сохраняются принципы чистой архитектуры и обеспечивается целостность бизнес-логики. Обсуждаются подробные примеры обработки сложных бизнес-сценариев, контекстной интеграции и эффективных стратегий запроса данных с использованием CQRS.
Требования
Компания предоставляет в лизинг ИТ-специалистов. У них есть несколько сотрудников, а также много фрилансеров в качестве субподрядчиков. В настоящее время они используют листы Excel для управления клиентами, фрилансерами, табелями учета рабочего времени и так далее. Решение Excel не очень хорошо масштабируется. Оно не готово к многопользовательской работе, а также не обеспечивает безопасность и журналы аудита. Поэтому они решили создать новое веб-решение.
Вот основные требования:
Необходимо предоставить каталог фрилансеров с возможностью поиска;
Новое решение должно позволять хранить различные каналы связи, доступные для связи с фрилансером;
Необходимо предоставить каталог проектов с возможностью поиска;
Необходимо предоставить каталог заказчиков с возможностью поиска;
Необходимо вести табель учета рабочего времени для фрилансеров, работающих по контракту.
Основываясь на этих требованиях, команда разработчиков решила смоделировать все с помощью UML, чтобы получить общую картину нового решения. Теперь давайте посмотрим, что у них получилось.
Общая картина
Итак, вот что они создали в первой итерации:
Все довольно просто. Есть заказчики, фрилансеры, проекты и временные таблицы. Также есть управление пользователями для поддержки безопасности на основе ролей. Но подождите, что-то здесь не так. Есть несколько хорошо скрытых недостатков дизайна. Вы можете их заметить?
Вот они:
Это очень большой граф объектов. Если здесь не использовать ленивую загрузку Hibernate/ JPA, то при большой нагрузке память наверняка закончится;
Почему ассоциация между User и Role двунаправленная?
У ContactType есть несколько булевых флагов, чтобы показать, какой это тип, email, phone, mobile;
Класс Freelancer содержит список проектов. Это также означает, что проекты не могут быть добавлены без изменения объекта Freelancer. Это может привести к сбою транзакций при большой нагрузке, так как, возможно, несколько пользователей добавляют проекты для одного и того же заказчика;
Что означает ContactInformation? В требованиях указано "Канал связи". Является ли это тем же самым?
Вся модель, похоже, представляет собой нечто вроде Entiy-Relationship-Diagram вместо модели приложения. Кроме того, где находится бизнес-логика? Команда хотела создать несколько бизнес-сервисов вокруг модели для хранения и получения данных, а сущности (Entities) являются простыми Java-объектами (POJOs), управление которыми (сохранение, извлечение, обновление) берет на себя JPA.
Всё решение — это большой дурно пахнущий код, анемичная доменная модель. Команда тоже с этим согласна. Но каким может быть решение? Что ж, старших сотрудник (senior) команды предложил использовать принципы Domain-driven Design для моделирования решения. Хорошо, теперь давайте посмотрим, как DDD может улучшить дизайн.
Путь DDD
Прежде чем мы начнем углубленно изучать Domain-driven Design, нам следует немного рассказать о принципах, лежащих в основе DDD.
Один из принципов DDD - преодоление разрыва между доменными экспертами (domain experts) и разработчиками путем использования одного и того же языка (Ubiquitous Language) для создания одинакового понимания. Другой принцип - снижение сложности за счет применения объектно-ориентированного проектирования и паттернов проектирования, чтобы не изобретать велосипед.
Но что такое домен? Домен - это "область знаний и деятельности", например, сфера бизнеса, которым управляет компания. Домен также называют "проблемным пространством", то есть проблемой, для которой мы должны разработать решение (для решения которой мы должны спроектировать наше наше приложение).
Итак, давайте посмотрим на требования. Мы можем подумать, что существует домен "Аутсорсинг", и это совершенно верно. Но если мы посмотрим глубже в наш домен, то увидим то, из чего состоит наш домен и то, что обычно называют "Поддомен или Субдомен".
Возможны следующие поддомены (Subdomain):
Поддомен "Управление идентификацией и доступом"
Поддомен "Управление фрилансерами"
Поддомен "Управление клиентами"
Поддомен "Управление проектами"
А-а-а! Мы можем разделить большую проблему на более мелкие. Это поможет нам разработать лучшее решение.
Разделенный Домен можно легко визуализировать. В терминах DDD это называется картой контекстов или контекстной картой (Context Map), и она является отправной точкой для любого дальнейшего моделирования.
Это диаграмма высокого уровня, которая описывает различные Ограниченные Контексты (Bounded Contexts) в нашей системе и отношения между ними.
Теперь нам нужно согласовать субдомен, он же проблемное пространство, с нашим дизайном решения, нам нужно сформировать пространство решений. Пространство решений на жаргоне DDD также называется ограниченным контекстом (Bounded Contexts), и лучше всего согласовать одно проблемное пространство/субдомен с одним пространством решений/ограниченным контекстом.
Строительные блоки
Строительные блоки в Domain-driven Design делятся на тактические и стратегические паттерны.
Новая общая картина
Хорошо, теперь давайте посмотрим на новую общую картину доменной модели:
Хорошо, что здесь произошло? Теперь для каждого идентифицированного субдомена существуют Ограниченные контексты. Ограниченные контексты изолированы, они ничего не знают друг о друге. Их скрепляет только набор общих типов, таких как UserId, ProjectId и CustomerId. В DDD этот набор общих типов называется "общим ядром" (Shared Kernel).
Мы также можем увидеть, что является частью "Core domain", а что нет. Если ограниченный контекст является частью проблемы, которую мы пытаемся решить, и не может быть заменен другой системой, он является частью "Core domain". Если он может быть заменен другой системой, то это "Generic Subdomain". Контекст "Identity and access management" является "Generic Subdomain", так как он может быть заменен готовым решением для управления идентификацией и доступом (Identity and Access Management, IAM), которое уже существует на рынке и может быть использовано вместо самостоятельной разработки решения IAM с нуля, например Active Directory или другим.
Мы применили к модели набор тактических и стратегических паттернов. Эти паттерны помогают нам построить лучшую модель, улучшить отказоустойчивость и повысить ремонтопригодность.
Внутри каждого Ограниченного Контекста существуют Агрегаты (Aggregate) и Объекты Значений (Value Objects). Агрегаты - это иерархии объектов, но только корень иерархии доступен извне агрегата. Агрегаты заботятся о бизнес-инвариантах. Каждый доступ к дереву объектов должен проходить через Агрегат, а не через один элемент внутри него. Это значительно повышает инкапсуляцию.
Агрегаты и сущности (Entity) - это вещи с уникальным идентификатором в нашей модели. Объекты значений - это не вещи, это значения или показатели, например, UserId. Объекты значений разработаны как неизменяемые, они не могут менять свое состояние. Каждый метод, изменяющий состояние, возвращает новый экземпляр объекта-значения. Это помогает нам избавиться от нежелательных побочных эффектов.
Проектирование поведения
Давайте спроектируем некоторое поведение, сценарий использования (use case): "Фрилансер переехал на новое место". Не держа в уме DDD, мы могли бы создать простой Java-объект POJO следующим образом:
Мы можем изменить имя фрилансера, вызвав сеттер экземпляра. Но подождите! Где же наш сценарий использования? Сеттер может быть вызван из других мест. И реализация безопасности на основе ролей может стать громоздкой, поскольку у нас нет контекста вызова, когда вызывается сеттер. Кроме того, в этой модели отсутствует понятие адреса. Он смоделирован очень неявно, только простыми свойствами класса Freelancer.
Применяя Domain-driven Design, мы получаем следующее:
Это гораздо лучше. Теперь есть явный класс Address, который инкапсулирует все состояние адреса. Случай смены адреса теперь явно смоделирован как метод moveTo(), предоставляемый агрегатом Freelancer. Мы можем изменить состояние Freelancer только с помощью этого метода. И, конечно, этот метод можно легко защитить с помощью какой-либо модели безопасности.
Полный Use Case и Устойчивость (Persistence)
Итак, мы продолжаем моделировать сценарий "фрилансер переехал на новое место". Прежде всего, нам нужно некое хранилище для агрегации фрилансеров. В DDD такое хранилище называется репозиторием. Используя хранилище, мы можем искать фрилансера по имени, загружать существующего фрилансера по Id, удалять его из хранилища или добавлять нового фрилансера в хранилище. Как правило, для каждого типа агрегатов должно быть одно хранилище. Обратите внимание, что репозиторий - это интерфейс, описанный в бизнес-терминах. О реализации мы поговорим в следующей главе.
На следующей диаграмме показан смоделированный вариант использования (use case). Вы увидите несколько новых артефактов. Прежде всего, это пользовательский интерфейс, клиент нашей доменной модели. Клиентом может быть все, что угодно: от фронтэнда JSF 2.0 до веб-сервисов SOAP или ресурсов REST. Поэтому, пожалуйста, думайте о клиенте в общих чертах. Клиент отправляет команду в ApplicationService. ApplicationService переводит команду в вызов модели использования домена. Таким образом, FreelancerApplicationService загрузит агрегат Freelancer из FreelancerRepository и вызовет операцию moveTo() для агрегата Freelancer. FreelancerApplicationService также образует границу транзакции. Каждый вызов приводит к новой транзакции. Безопасность на основе ролей также может быть реализована с помощью FreelancerApplicationService. Всегда лучше не включать управление транзакциями в доменную модель. Контроль транзакций - это скорее технический вопрос, чем бизнес, поэтому он не должен быть реализован в доменной модели.
Архитектура приложения
Хорошо, теперь давайте посмотрим на архитектуру приложения. Для каждого Bounded Context должен быть отдельный Deployment Unit. Это может быть WAR-файл Java или EJB JAR. Это зависит от технологии реализации. Мы спроектировали Bounded Context так, чтобы они были независимы друг от друга, и эта цель также должна быть отражена в независимых единицах развертывания.
Каждая единица развертывания содержит следующие части:
Доменный уровень (Domain Layer)
Инфраструктурный уровень (Infrastructure Layer)
Прикладной уровень (Application Layer)
Доменный слой содержит независимую от инфраструктуры логику домена, как мы уже моделировали в этом примере. Инфраструктурный слой предоставляет технологически зависимые артефакты, например реализацию FreelancerRepository на основе Hibernate. Прикладной уровень действует как шлюз для бизнес-логики с интегрированным контролем транзакций.
При использовании этого стиля архитектуры уровень домена нашей бизнес-логики ни от чего не зависит. Мы можем менять реализацию репозитория с Hibernate на JPA или даже NoSQL, например, Riak или MongoDB, не затрагивая при этом никакой бизнес-логики.
Доменный слой (Domain Layer)
Доменный уровень содержит реальную бизнес-логику, но не содержит никакого кода, специфичного для инфраструктуры. Инфраструктурная реализация обеспечивается инфраструктурным уровнем. Модель домена должна быть разработана в соответствии с принципом CQS (Command-Query-Separation). Могут быть методы запросов, которые просто возвращают данные, не влияя на состояние, и методы команд, которые влияют на состояние, но ничего не возвращают.
Слой приложения (Application Layer)
Слой приложения получает команды от уровня пользовательского интерфейса и преобразует их в вызовы сценариев использования на уровне домена. Слой приложения также обеспечивает управление транзакциями для бизнес-операций. Слой приложения отвечает за преобразование агрегированных данных в модель представления, специфичную для клиента, с помощью шаблона Mediator или Data Transformer.
Инфраструктурный слой (Infrastructure Layer)
Инфраструктурный слой обеспечивает части, зависящие от инфраструктуры для всех остальных слоев, таких как реализация на основе Hibernate или JPA. Агрегированные данные могут храниться в RDMBS, таких как Oracle или MySQL, или в виде XML/JSON или даже сериализованных объектов Google ProtocolBuffers в механизме NoSQL, основанном на ключах-значениях или документах. Это зависит от вас, если хранилище обеспечивает контроль транзакций и гарантирует согласованность. Инфраструктуру можно описать как "Все, что окружает доменную модель", то есть базы данных, ресурсы файловой системы или даже потребители веб-сервисов, если мы взаимодействуем с другими системами.
Слой клиентского / пользовательского интерфейса (Client / User Interface Layer)
Клиентский слой (Client Layer) потребляет сервисы приложений и вызывает бизнес-логику этих сервисов. Каждый вызов - это новая транзакция.
Клиентский слой может быть практически любым, начиная с JSF 2.0 Backing Bean в качестве контроллера представления и заканчивая конечной точкой веб-сервиса SOAP или веб-ресурсом RESTful. Для создания пользовательского интерфейса можно использовать даже Swing, AWT или OpenDolphin/JavaFX.
Интеграция контекста
Теперь я хочу написать о контекстной интеграции. Что это такое? Рассмотрим следующие требования домена аутсорсинг:
Клиент может быть удален только в том случае, если для него не назначен проект;
После ввода табеля учета рабочего времени клиенту должен быть выставлен счет.
Синхронная интеграция
Начнем с первого. В этом случае ограниченный контекст управления клиентами должен проверить, зарегистрирован ли проект для данного клиента, прежде чем клиент будет удален. Для этого требуется своего рода синхронная интеграция двух ограниченных контекстов.
Здесь есть много возможностей. Прежде всего, мы хотим, чтобы контексты не зависели друг от друга. Как же нам с этим справиться? Вот проект взаимодействия ограниченного контекста заказчика с ограниченным контекстом управления проектом:
Появился новый термин: доменная служба (Domain Service). Что такое доменная служба? Доменная служба реализует бизнес-логику, которая не может быть реализована сущностью, агрегатом или объектом ValueObject, потому что ей там не место. Например, если вызов бизнес-логики включает в себя работу с несколькими объектами домена или, в данном случае, интеграцию с другим Bounded Context.
ApplicationService вызывает метод deleteCustomerById сервиса CustomerService. CustomerService запрашивает ProjectManagementAdapter вызовом customerExists(), существует ли проект для данного CustomerId. Только если возвращается false, заказчик удаляется из CustomerRepository.
Существует две реализации ProjectManagementAdapter: SOAP и REST. Мы можем либо использовать SOAP для вызова полноценного веб-сервиса с XML-маршалингом и использованием всего стека JAX-WS, либо использовать REST и вызвать http://example.com/customers/customerId/projects и получить код ответа HTTP 404(not found) или 20x(Ok). Решать вам, но REST будет менее сложным, легче интегрируется и лучше масштабируется. Также мы можем начать с REST и перейти на SOAP, если это потребуется. Изменить реализацию без ущерба для доменного слоя довольно легко, мы просто используем другую реализацию адаптера.
На стороне ограниченного контекста управления проектами есть ApplicationWebService, представленный как REST-ресурс или SOAP-сервис, реализующий серверную часть коммуникации. Этот сервис или ресурс делегирует службу ProjectApplication, которая делегирует службу ProjectDomainService, запрашивая, зарегистрирован ли проект для данного CustomerId.
В любом случае мы должны позаботиться о границах транзакций. Вызов веб-сервиса или REST-ресурса не способствует транзакциям из коробки, а использование XA/two-phase-commit увеличит сложность и снизит масштабируемость. Лучше всего не удалять клиента физически, а пометить его как логически удаленный. В случае сбоя транзакции или проблем с параллелизмом можно будет легко вернуть клиента в исходное состояние.
Здесь вы также видите причину, по которой инфраструктурный слой расположен выше всех остальных. Он должен иметь возможность делегировать ему полномочия или реализовывать специфические для технологии артефакты на основе интерфейсов, определенных в слоях ниже.
Пример асинхронного взаимодействия
Хорошо, теперь продолжим с более сложного примера. Рассмотрим требование, согласно которому после ввода табеля необходимо выставить счет заказчику.
Это очень интересный пример. Оно интересно тем, что не требует синхронного вызова. Счет может быть отправлен точно в срок, или несколькими часами позже, или в конце месяца вместе с другими счетами. Или счет может быть дополнен менеджером по работе с ключевыми клиентами заказчика или кем-либо еще - контекст управления Freelancer просто не имеет значения.
Как мы можем смоделировать это с помощью паттернов DDD? Ключевым моментом здесь является фраза "Once a Timesheet is...", это событие, имеющее отношение к бизнесу в нашем домене, и такие события можно моделировать как Domain Events!
Доменное событие создается и направляется в хранилище событий, где хранится для дальнейшей обработки. EventStore является частью блока развертывания Bounded Context, и сохранение события в хранилище происходит в рамках транзакции, управляемой ApplicationService. На стороне инфраструктуры находится таймер, пересылающий сохраненные события в конечную инфраструктуру обмена сообщениями, например, на основе JMS или AMQP, даже вызов REST-ресурса может рассматриваться как доставка сообщений.
Так зачем нам нужен локальный EventStore? Инфраструктура обмена сообщениями может временно стать недоступной, но это не должно повлиять на наш работающий ограниченный контекст. Поэтому события будут поставлены в очередь и доставлены, когда инфраструктура снова станет доступной. Если бы мы связали инфраструктуру обмена сообщениями непосредственно с производителем событий, производитель мог бы не отправить их в случае ошибки инфраструктуры. Даже если мы используем обмен сообщениями, это может вызвать эффект пульсации во всей инфраструктуре, если что-то пойдет не так, и именно по этой причине мы используем обмен сообщениями: развязка системы
Вот как моделируется связанный контекст Freelancer Management:
FreelancerService создает событие TimesheetEntered Domain Event и направляет его в EventStore, который, по сути, является еще одним репозиторием. Затем JMSMessagingAdapter берет ожидающие события из EventStore и пытается переслать их в целевую инфраструктуру обмена сообщениями, пока доставка не будет успешной. Но эта пересылка выполняется в другой транзакции и может быть запущена, например, по таймеру.
Итак, как Контекст управления клиентами обрабатывает события? Это моделируется следующим образом:
Опять же, на инфраструктурном уровне должны располагаться все остальные слои, так как в случае контекстной интеграции он должен вызывать службу приложения.
Вот происхождение JMSMessageReceiver, расположенного в инфраструктурном слое. MessageReceiver также отвечает за дедупликацию событий. Это может произойти в случае системного сбоя, когда уже доставленные События доставляются повторно или что-то еще пошло не так. Поскольку инфраструктурный уровень расположен над прикладным уровнем, он может вызывать CustomerApplicationService, который сам вызывает CustomerService, реализующий бизнес-логику для отправки счета.
В этом сценарии границы транзакции находятся на ApplicationService. Мы можем утверждать, что JMSMessageReceiver может вызывать CustomerService, и делать это в рамках JMS-транзакции. Это тоже приемлемое решение.
Сложная часть - дедупликация событий. Это может произойти в случае сбоя инфраструктуры или отключения системы. Этого можно избежать, присвоив каждому событию уникальный идентификатор и отслеживая, какие идентификаторы уже были обработаны.
Еще одна сложная часть - упорядочивание событий. Это зависит от инфраструктуры обмена сообщениями. Если инфраструктура поддерживает упорядочивание событий, то все в порядке. Если нет, то это нужно реализовать самостоятельно. В любом случае, хорошей практикой является разработка событий как идемпотентной операции. Это означает, что каждое событие может быть обработано несколько раз, и каждый раз с одним и тем же результатом без нежелательных побочных эффектов.
Запрос данных из нескольких ограниченных контекстов или агрегатов
Иногда нам нужно собрать данные, распределенные по нескольким агрегатам или даже ограниченным контекстам. Это может оказаться непростой задачей. В рамках одного Bounded Context мы можем использовать специализированные представления базы данных и получать данные с помощью Hibernate или JPA, но получение данных по нескольким Bounded Contexts может привести к большому количеству удаленных вызовов методов и другим проблемам; такое решение может плохо масштабироваться. Мы также должны учитывать, что использование представления может нарушить бизнес-инвариантность хорошо спроектированного агрегата. Это проблема, о которой мы действительно должны позаботиться!
Итак, каким может быть решение? Мы можем подумать о CQRS или Command-Query Responsibility Segregation! По сути, мы разделяем модель на модель команд, которая содержит бизнес-логику, и модель запросов, которая используется для получения данных. Так, в нашем примере модель команд будет состоять из всех ограниченных контекстов, которые мы хотим запросить, и модели запросов, которая используется для запроса агрегированных данных (и оптимизирована для эффективного запроса данных). Модель команд и модель запросов синхронизируются с помощью событий домена! Как только в командной модели запускается бизнес-операция, выдается событие домена, которое обрабатывается моделью запросов, и данные обновляются.
Используя CQRS, мы можем создавать высокопроизводительные системы обработки данных, а интеграция с Business Intelligence больше не является проблемой. Только подумайте: модель запросов может быть по сути хранилищем данных.
В качестве заключения
Мне очень нравится идея проектирования, ориентированного на домен. С помощью этой техники даже очень сложная логика домена может быть легко выделена и смоделирована. Это приводит к созданию более совершенных систем, улучшению пользовательского опыта, а также к повышению надежности и ремонтопригодности решений. Спасибо Эрику Эвансу и Вону Вернону! DDD / Domain-driven Design - это объектно-ориентированное программирование, выполненное правильно.
Данная статья показалась мне интересной и я решил сохранить ее перевод.
В первую очередь для себя, ну и еще, чтобы почитать ваши комментарии)