Всем привет!
Хочу рассказать про опыт применения гексагональной архитектуры в одном из наших микросервисов. С чем столкнулся и какие выводы сделал.
Боль
Как обычно выглядит java проект на spring boot?
Если его представить в виде картинки, получится так:
Как это хранить в директориях проекта?
Можно разделять по пакетам по типу сущностей: контроллеры отдельно, сервисы отдельно:
└── com/
└── yourcompany/
├── controller/
├── service/
│ └── ... # 9000+ classes with business or adapter logic, who cares?
├── repository/
├── model/
├── mapper/
├── config/
├── exception/
└── util/
Или по доменам: весь код, относящийся к конкретному объекту отправляем в один пакет
в теории получится что-то такое:
└── com/
└── yourcompany/
├── cat/
│ ├── CatController.java
│ ├── CatService.java
│ ├── CatRepository.java
│ ├── CatModel.java
│ └── CatConfig.java
├── domain2/
├── ....
├── i.dont.know.where.to.put.new.classes/
└── domainWTF/
Проблема в том, что красиво это выглядит только для простого CRUD приложения.
Как только появляется что-то ещё: очереди, кэш, кастомные мониторинги - расположение классов в приложении перестаёт быть очевидным.
С ростом проекта становится сложнее понимать что отвечает за приём данных, что обрабатывает, что куда-то отправляет. Тяжелее искать нужные классы. В итоге всё сводится к заучиванию ключевых сервисов и поиску в IDE по имени класса.
Как следствие, внедрение новых сотрудников занимает гораздо больше времени, чем могло бы.
Вдохновлённый докладом Рустама Ахметова на JUG.ru про архитектуру я решил попробовать применить гексагональную архитектуру. Как раз имелся подопытный проект который нужно было начать и в дальнейшем развивать.
Как это выглядит в java проектах?
В директории гексагонального проекта вы увидите такую структуру:
└── yourcompany/
├── adapter/ # All required for external communications
│ ├── in/ # Incoming requests adapters
│ │ ├── http/
│ │ └── kafka/
│ └── out/ # Outgoing requests adapters
│ ├── persistense/
│ ├── cache/
│ └── kafka/
├── application/
│ ├── domain/ # Core logic
│ └── port/ # Core API
│ ├── in/
│ └── out/
└── common/ # Neither business logic nor adapters
Если это изобразить на схеме, получится так:
Прямоугольниками обозначены классы и интерфейсы.
Разные слои гексагона - показывают расположение в структуре пакетов.
Стрелками направление работы приложения.
Слой адаптеров и слой приложения взаимодействуют через слой интерфейсов. (На схеме прямоугольники с пунктирными границами)
Под направлением работы приложения показываю что кого вызывает в процессе работы. Например,
Интернет вызывает адаптер, который обращается в приложение через соответствующие интерфейсы.
Приложение вызывает базу данных через PersistenceAdapter. И ходит в кэш через Redis адаптер.
Приложение обращается в кафку, но и кафка может вызывать приложение
На момент написания статьи прошло больше 6 месяцев и вот что я понял:
Что это даёт?
Короткий ответ - Слабая Связность
Простой ответ - это отделение “скучного” кода от “логики”. Отделение на таком уровне, что логику можно безболезненно вынести в другой проект.
Можно полноценно протестировать каждый адаптер, бизнес логику в отрыве от всего остального приложения.
Проще заменить один адаптер на другой, например если вы жили на hibernate, а хотите перейти на jdbc. Или с memcached на redis. У вас есть требования к адаптеру в виде его интерфесов, и эти требования обложены тестами (я надеюсь).
Про это и так будет написано в любой книжке по чистому коду
Вот на что ещё обратил внимание:
При разработке новых фичей, могут прилетать мерж реквесты на 100+ файлов, которые достаточно лениво долго ревьюить.
В гексагональной архитектуре особое внимание уделяется папке application/domain/
, т.к. поддержание её в состоянии наиболее критично для развития и поддержки вашего сервиса.
Это конечно не значит что код адаптеров можно вообще не ревьюить, просто теперь к изменениям в директории application/domain/
гораздо больше внимания.
Для примера, в моём проекте такое распределение по количеству файлов:
└── yourcompany/ # Total: 69 files
├── adapter/ # 37 files
├── application/
│ ├── domain/
│ │ ├── model/ # 4 files
│ │ └── service/ # 9 files
│ └── port/ # 16 files
├── common/ # 2 files
└── App.java
Из 69 файлов, только 9 являются бизнесовыми сервисами! И они все лежат рядом, на ладони, ничего лишнего.
А вся чешуя в виде Entity, Repository, Mapper, Dto всё убрано к адаптерам, где они и используются.
Да, но…
Количество boilerplate-а
Значительно увеличивается количество файлов в проекте. Создание всех этих
*UseCase
или*Port
интерфейсов может кажется бессмысленным, когда можно сразу заинжектить нужный класс. Цена слабой связности.Благодаря внутренней модели для любого адаптера необходим маппер. К этому просто нужно привыкнуть. Частично решается использованием мапперов типа MapStruct или ModelMapper.
Стоит ли оно того? В каких-то проектах нет.
Неявные утечки зависимостей
Пример кода c моего проекта:
package yourcompanyname.application.port.out;
import yourcompanyname.application.domain.model.Client;
import org.springframework.data.domain.Page; // <-- shouldn't be here
import org.springframework.data.domain.Pageable; // <-- shouldn't be here
public interface FindClientsPort {
Page<Client> findAll(Pageable pageable);
}
В код application/
утекла зависимость адаптера (org.springframework.data
)
Помните выше я писал:
Отделение “скучного” кода от “логики”.
Отделение на таком уровне, что логику можно безболезненно вынести в другой проект.
В итоге обросли дополнительной связью, и в будущем это может по нам ударить.
Решается просто - бойлерплейтом: добавляем копию интерфейса Pageable
в application/model/
и по мапперу в каждый адаптер где он будет использоваться.
Очень просто проверить, есть ли зависимости на пакет adapter, через поиск строки .adapter
внутри директории application/
. Но простого способа понять утекли ли зависимости я не нашёл. На это нужно обращать внимание при ревью нового кода.
Не всегда очевидно, куда сохранять очередной сервис
Вопрос: куда складывать Scheduler-ы?
Если с входящими запросами, например через http или очередью всё понятно, но когда приложению нужно запланировать выполнение определённого функционала. В моём случае я вывел Sceduler в “адаптеры”, но не исключаю, что в другой ситуации, это может быть частью внутренней логики.
Нюансы с persistance
В большинстве проектов где я работал, persistance Entity являлась моделью приложения. Гексагональная модель пушит нас для ядра приложения использовать свою внутреннюю модель. Из за этого любые взаимодействия с persistence адаптером требуют конвертации объектов из DomainModel в Entity и обратно.
Из этого следует как минимум 2 пункта:
Вы не можете оперировать одним и тем же экземпляром Entity в рамках разных вызовов адаптера. Придётся заново подгружать или где-то хранить.
Не будет работать простое
@Lazy
над связями. Приложение ожидает от вызова адаптера получить полноценный доменный объект, без магии с подгрузкой ленивых данных.
В большинстве случаев эти пункты будут скорее плюсом, т.к. опять таки уменьшается связность между разными частями приложения. Но это может ударить по производительности.
Выводы
Подопытный проект вопреки моим ожиданием оброс множеством дополнительных требований и судя по всему продолжит развиваться. Пол года это небольшой срок для проекта, но пока полёт считаю успешным, а выбор гексагональной архитектуры классным опытом для меня и команды в целом.
Самая большая польза лично для меня - за это время сильно улучшилось и укрепилось понимание той самой чистой архитектуры. Раньше я не уделял столько внимания структуре пакетов, больше думал про код. Мораль можно сформулировать так:
Важен не только код, но и то, где он находится в проекте.
Чего я бы хотел добиться этой статьей - вдохновить людей попробовать, на каком-нибудь безопасном проекте. Лучше один раз попробовать чем прочитать 20 статей.
Покажите мне код!
Для примера накидал тестовый проект, который демонстрирует интеграцию с разными внешними системами.