Рано или поздно практически любой разработчик программного обеспечения приходит к поиску архитектурных решений, которые бы облегчали ему процесс разработки и развития проектов. В первую очередь это касается различных шаблонов проектирования, вроде фабрик, посетителей, синглтонов и многих других. Но в какой-то момент становится понятно, что их недостаточно, и нужны какие-то крупномасштабные решения, позволяющие организовать архитектуру всего проекта. В большинстве случаев выбор падает на какую-нибудь многоуровневую архитектуру, одной из главных целей которых является инверсия зависимостей.
Достаточно продолжительное время я в своих проектах использовал обычную многоуровневую архитектуру, где логика приложения находится в компоненте, получающем вызовы извне (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, то я масштабирую только их.
- Меньше конфликтов при слиянии веток в системах версионирования исходного кода, поскольку каждый разработчик работает с отдельным набором интерфейсов и классов.
Но в любой бочке мёда найдётся ложка дёгтя, так и в случае с гексагональной архитектурой.
- Большое количество интерфейсов и классов. Был у вас интерфейс объявляющий десяток методов и класс, реализующий его, а теперь у вас десяток интерфейсов, десяток классов и ещё десяток тестовых классов. Эффективный объём кода останется практически неизменным, хотя увеличится количество строк кода, зато в разы увеличится количество типов. Тут единственным способом борьбы будет грамотная организация типов по пакетам и модулям.
- Имена типов и методов. Эта проблема, скорее связана не с самой архитектурой, а с рекомендуемыми подходами к именованию компонентов, которые описаны и в книгах Роберта Мартина и Стивена МакКоннелла, но при использовании гексагональной архитектуры вам постоянно придётся сталкиваться с очень длинными именами типов и методов.
Подводя итог, хочется отметить, что не существует такой архитектуры программного обеспечения, которая бы позволяла писать меньше кода, но существуют архитектуры, позволяющие сделать процесс разработки, развития и поддержки более удобным, что само по себе уже хорошо. И гексагональная архитектура, на мой взгляд, таковой является.