Гексагональная архитектура, и как я к ней пришёл

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

Достаточно продолжительное время я в своих проектах использовал обычную многоуровневую архитектуру, где логика приложения находится в компоненте, получающем вызовы извне (REST-эндпоинт, SOAP-сервис, веб-контроллер, слушатель очередей сообщений и т.д.), бизнес-логика в сервисе бизнес-логики, а работа с СУБД в классе-репозитории.

И какое-то время меня такая архитектура полностью устраивала, а выгода от гексагональной архитектуры или от чистой архитектуры, описанной Робертом Мартином в его одноимённой книге, мне оставалась непонятной. Основной смысл обоих архитектурных решений заключается в максимальном применении SOLID и атомизации компонентов исходного кода. Если вкратце, то проект должен содержать только функциональные интерфейсы: интерфейсы управляющих (ведущих/входящих, driving/input) портов в качестве интерфейсов бизнес-логики и интерфейсы управляемых (ведомых/исходящих, driven/output) портов в качестве инфраструктурных интерфейсов, например, репозиториев.

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

  • Большой объём кода в рамках одного класса. В первую очередь это касается компонентов бизнес-логики. С такими классами очень сложно работать без помощи инструментов IDE. С кодом тестовых классов работать ещё сложнее, поскольку тестового когда в 3-5 раз больше тестируемого. Рефакторинг компонентов бизнес-логики может быть достаточно ресурсоёмкой затеей.
  • Негибкость кода, проявляющаяся, например, в невозможности использовать разные реализации одного компонента в разных ситуациях в рамках одного сервиса. Например, мы можем использовать кешированные данные в операциях чтения, но это недопустимо в операциях записи.
  • Отсутствие возможности масштабировать только необходимые компоненты, ровно, как и отсутствие возможности разделения на компоненты чтения и записи.

Постоянные конфликты при слиянии изменений из нескольких веток системы контроля версий, если речь идёт о работе в команде.

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

В итоге, столкнувшись с вышеописанными проблемами, я решил перечитать книгу Роберта Мартина, а заодно почитать статьи на тему гексагональной архитектуры.

Пример типичной для меня многоуровневой архитектуры

Общие положения и терминология

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

Ядро проекта с гексагональной архитектурой представлено как минимум тремя видами компонентов:

  • Управляющими портами, при помощи которых объявляются открытые API ядра.
  • Адаптерами управляющих портов, которые реализуют логику ядра проекта.
  • Управляемыми портами, при помощи которых ядро может обращаться к внешним компонентам: базам данных, очередям сообщений, почтовым серверам и т.д.

Компоненты, обращающиеся к ядру при помощи управляющих портов, называются драйверами. Например, таковыми могут быть REST-эндпоинты или SOAP-сервисы. Компоненты, реализующие управляемые порты тоже называются адаптерами, но они, как и драйверы не являются частью ядра, так как ядро не интересуют такие подробности, например, как в какой базе данных хранятся данные, какая библиотека используется для работы с очередями сообщений, используется ли кеширование и так далее.

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

SOLID как основа гексагональной архитектуры

Принципы SOLID, которые я уже рассматривал в своих постах, можно трактовать в отношении архитектуры проекта следующим образом:

  • Принцип единственной ответственности (SRP) гласит, что отдельный компонент должен отвечать за одно действие.
  • Принцип открытости/закрытости (OCP), гласит, что код должен быть открыт к расширению, но закрыт к изменению. Иными словами, если класс должен быть расширен, то он должен быть абстрактным, если нет, то финальным.
  • Принцип подстановки [Барбары] Лисков (LSP) гласит, что компоненты, реализующие один и тот же интерфейс, должны быть взаимозаменяемыми.
  • Принцип разделения интерфейсов (ISP) гласит, что вместо одного общего интерфейса должно использоваться множество специализированных.
  • Принцип инверсии зависимостей (DIP) гласит, что компоненты должны зависеть от абстракций, а не конкретных классов.

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

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

  • Можно собрать модульный монолит, который предоставляет полный набор API проекта.
  • Можно собрать набор микросервисов, каждый из которых предоставляет API своей предметной области, если их в проекте несколько.
  • Можно собрать отдельные микросевисы для API запросов и API команд, применив архитектурный шаблон проектирования CQRS.
  • В конечном итоге можно собрать для каждого метода API отдельный микросервис, получив в итоге FaaS.

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

Альтернативный вариант — реализовывать несколько взаимосвязанных интерфейсов в одном классе, но помнить, что при необходимости этот класс можно разделить.

Применение, плюсы и минусы

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

Какие плюсы я для себя отметил в применении гексагональной архитектуры:

  • Работать с кодом классов и тестов становится значительно проще. Проще найти, проще прочитать, проще изменить. Плюс IDE больше не напрягается при рефакторинге.
  • Максимальная гибкость конфигурации сервисов. Если я хочу добавить кеширование для одного метода, то я добавляю кеширование только для него. Если мне нужно масштабировать только определённые API, то я масштабирую только их.
  • Меньше конфликтов при слиянии веток в системах версионирования исходного кода, поскольку каждый разработчик работает с отдельным набором интерфейсов и классов.

Но в любой бочке мёда найдётся ложка дёгтя, так и в случае с гексагональной архитектурой.

  • Большое количество интерфейсов и классов. Был у вас интерфейс объявляющий десяток методов и класс, реализующий его, а теперь у вас десяток интерфейсов, десяток классов и ещё десяток тестовых классов. Эффективный объём кода останется практически неизменным, хотя увеличится количество строк кода, зато в разы увеличится количество типов. Тут единственным способом борьбы будет грамотная организация типов по пакетам и модулям.
  • Имена типов и методов. Эта проблема, скорее связана не с самой архитектурой, а с рекомендуемыми подходами к именованию компонентов, которые описаны и в книгах Роберта Мартина и Стивена МакКоннелла, но при использовании гексагональной архитектуры вам постоянно придётся сталкиваться с очень длинными именами типов и методов.
Пример применения гексагональной архитектуры

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