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

Как я уже писал, мы взяли из DDD тактические шаблоны.

Сущность
Сущность

 

Объект-значение
Объект-значение
Агрегат
Агрегат

Сущность

Если какое-то понятие предметной области является уникальным и отличным от всех других объектов в системе, то для его моделирования используется сущность.

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

Для этого используются уникальные идентификаторы.

Сущность в коде нашего проекта должна иметь:

  1. Приватный конструктор

  2. Фабричный метод создания

  3. Проверяемые исключения

Такой подход позволяет соблюсти все инварианты создания, а также ответить на вопросы — что и как необходимо передать для создания сущности.

Особое внимание стоит уделить полям сущности, все поля должны быть представлены объектами значениями.

P.S. В примере код из старой части проекта, который постепенно переводится на DDD.

Объект-значение

Если для объекта не важна индивидуальность, он полностью определяется своими атрибутами, его следует считать объектом-значением.

Чтобы выяснить, является ли какое-то понятие значением, необходимо проверить, обладает ли оно большинством из следующих характеристик:

  • Оно измеряет, оценивает или описывает объект предметной области;

  • Его можно считать неизменяемым;

  • Оно моделирует нечто концептуально целостное, объединяя связанные атрибуты в одно целое;

  • При изменении способа измерения или описания его можно полностью заменить;

  • Его можно сравнивать с другими объектами с помощью отношения равенства значений;

  • Оно предоставляет связанным с ним объектам функцию без побочных эффектов.

Объекты значения в нашем проекте делятся на два типа.

№ 1

С несколькими параметрами:

  • Обязательно data class.

  • Должен принимать простые объекты значения (если при создании необходима бизнес-логика, реализуется через приватный конструктор и фабричный метод, как сущность).  

№ 2

С одним параметром:

  • Обязательно value class.

  • Содержит примитив.

  • Создается через фабричный метод.

  • Проверяет бизнес-правила перед созданием.

Агрегат

Агрегат является самым сложным из всех тактических инструментов DDD.

Агрегатом называется кластер из объектов сущностей или значений. То есть эти объекты рассматриваются как единое целое с точки зрения изменения данных.

У каждого агрегата есть корень (Aggregate Root) и граница, внутри которой всегда должны быть удовлетворены инварианты.

Стоит вспомнить, что у нас всё же BFF и сложная логика с транзакциями, и удовлетворение инвариантов располагается в бэкофисных системах.

P.S. Или мы просто до них не доросли ;)

Валидация предметной области

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

Это позволяет сохранить инварианты и развивать разные сценарии независимо или зависимо, если используется одно и то же понятие бизнес-области.

Таким образом, ситуация, когда имя пользователя обязательно для любого контекста и инкапсулировано в объекте Username может быть пусто — невозможна.

Непроверяемые исключения

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

Объяснение довольно простое.

Нам необходимо было поддержать требование 3-го пункта BFF и контролировать то, что мы отдаём на фронт.

Для этого мы воспользовались таким понятием функционального программирования как Either, которое содержит в себе два значения.

  1. Результат успешного выполнения.

  2. Результат неудачного выполнения

За основу реализации мы взяли Either из библиотеки Arrow-kt.

Но так как доменная область у нас независима от фреймворка (вспоминаем дядюшку Боба), мы внесли Either в наши исходники и сделали «частью» языка Kotlin.

В сухом остатке:

  1. Конструкция throw используется только на уровне представления для возврата ошибок клиентам (поддержка механизма Spring).

  2. Все ошибки из инфраструктуры трансформируются в Either.

  3. Все контракты описаны и всегда понятно, чего стоит ожидать (тот самый принцип «L» ;)).

Пример контракта:

У нас есть интерфейс сервиса GiftCardService, который позволяет удалять карту. Обратите внимание, он сразу нам говорит о контракте — дообогащённая корзина может не вернуться, а ещё может произойти ошибка.

Как у нас это реализовано?

Пример реализации:

В реализации интерфейса видим, что у нас есть взаимодействие с другим сервисом, который также возвращает Either. В данном случае мы перекладываем исключения с уже более дообогащенным контекстом для понимания того, что, где и как произошло.

Пример работы с контрактом:

Тот самый уровень приложения. Обратите внимание на то, что мы берем наш бизнесовый сервис GiftCard, удаляем, ловим ошибку GiftCardServiceException, и оборачиваем его в ошибку для конкретного сценария, то есть удаление подарочной карты ApplicationServiceException, таким образом никакой другой сценарий не должен выдавать это исключение.

Соответственно, если нам понадобится дообогатить это исключение, откорректировать его, мы всегда знаем, что это исключение может использоваться только в этом сценарии, поэтому все, кто использует это исключение, используют этот сценарий и подписываются на контракты этого сценария.

Swagger

А как же вы поддерживаете валидацию в предметной области и контракт для клиента, спросите вы?

У нас используется подход «контракт-first».

Если необходимо внести изменения контракта для клиента, сначала корректируется спецификация Open API и по ней уже генерируется код контроллера.

Плюсы подхода:

  1. Контракт всегда (ну почти) соответствует коду (подробнее в минусах).

  2. Важные параметры валидации срабатывают ещё до попадания данных в приложение.

  3. Нельзя «забыть» что-то не реализовать.

Минусы:

  1. Используемый генератор не позволяет полностью воспользоваться удобством OpenAPI, например, реализовать наследование.

  2. Возможны случаи несоответствия контракта OpenAPI и доменной модели в части валидации (прецедента не было).

  3. Но самое важное – именно предметная область является хранителем бизнес-логики и валидации.

Что дальше?

В планах:

  1. Улучшение единого языка.

  2. Уточнение границ контекстов и предметной области.

  3. Реализация доменных событий.

  4. Уход от анемичной модели.

  5. Синхронизация валидации предметной области между бэком и фронтом.

  6. Проработка и синхронизация команды в части единого языка.

  7. Написание тестов для архитектуры.

  8. and much more…

Подводя итог

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

Из минусов:

  1. Больше кода.

  2. Обучение подходу.

  3. Синхронизация в команде.

И напоследок «Старайтесь понять всё, но используйте только то, что необходимо»

Предыдущие посты серии:

Гексагональная архитектура и DDD на опыте интернет-магазина Спортмастер. Часть 1 Гексагональная архитектура и DDD на опыте интернет-магазина Спортмастер. Пробуем новое

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


  1. gandjustas
    20.01.2023 00:40

    А можно увидеть диаграмму последовательности вызовов "как было" и "как стало"? А что из обрывков кода на скрине категорически неясно что же поменялось.


    1. ezhov-da Автор
      21.01.2023 12:03

      Последовательность вызовов, если мы говорим о "Controller -> Service -> Repository" не претерпела кардинальных изменений. Основные изменения были сделаны в архитектуре проекта, которые описываются в части 2 данного цикла статей, а так же изменения обязанности классов и их конструирования (пример с валидацией). Данная часть отражает наложение тактических шаблонов DDD на наш код и описывает ответственность за работу с ошибками в каждом контексте.


      1. gandjustas
        21.01.2023 12:34

        Поэтому я и спрашиваю диаграмму последовательности, чтобы увидеть эти детали - какие метода каких классов вызываются и какие экземпляры каких классов создаются.


  1. dopusteam
    21.01.2023 21:30

    А в чем плюс приватного конструктора и фабричного метода, вместо просто конструктора с проверками внутри?

    Ага, понял, возможность вернуть неуспешный результат?