Команда Spring АйО перевела статью, которая рассказывает о том, как луковичная и гексагональная архитектура часто используется программистами в неоправданно сложном варианте, взятом из учебника, и как эти архитектурные стили можно упростить, получив от этого существенные преимущества в плане затрат на разработку и поддержку.

Чистая архитектура (Clean Architecture), Луковичная архитектура (Onion Architecture), и Гексагональная архитектура (Hexagonal Architecture) (она же Ports-and-Adapters, “Порты и адаптеры”) в наши дни стали нормой для системных архитектур бэкендов. Влиятельные специалисты продвигают эти архитектурные концепции, не уделяя при этом достаточно внимания тому факту, что это лишь сложные (слишком) концептуальные шаблоны архитектур, лишь упрощенные версии которых приходится применять при решении реальных задач. Применение этих архитектур “как в учебнике” может привести к overengineering-у, что, в свою очередь, заставляет разработчиков прилагать чрезмерные усилия, вызывает ненужные риски и может стать препятствием при глубоком стратегическом рефакторинге в будущем. Цель этой статьи состоит в том, чтобы указать, в каких местах можно сделать упрощения этих архитектур и объяснить, на какие компромиссы при этом придется пойти.
На всем протяжении истории создания архитектурных концепций для программного обеспечения, многие влиятельные специалисты указывали на важность разделения периферийных интеграционных проблем и доменной области (бизнес логики), в которой идет работа с главными наиболее сложными вопросами, решаемыми при создании приложения. Кульминацией этого процесса стало появление Domain-Driven Design, в котором подчеркивалось, как важно поддерживать гибкость доменной области, постоянно ища способы углубить его, очищать и проводить рефакторинг, чтобы улучшить способы моделирования вашей задачи.
Упомянутые выше три архитектурных стиля можно совместно описать как “Concentric Architectures”, поскольку они организуют код не слоями, а кольцами, чьим общим центром является Домен (непосредственно, бизнес логика). Примечание: в этой статье термины “слой” и “кольцо” будут использоваться как взаимозаменяемые.
Прагматические вариации слоёных архитектур в этой статье могут помочь вам упростить архитектуру вашего приложения, но каждая из них требует, чтобы вся команда понимала, что представляет собой code smell, а в чем состоит ценность гексогональной архитектуры (связанность (cohesion), объединение(coupling), DRY, ООП). Каждый раз, когда вы хотите что-то изменить, начинайте с малого и постоянной взвешивайте возможные упрощения потенциальным усложнением, которые вы вносите в кодовую базу.
Если вы предпочитаете смотреть видео, первая часть этого видео охватывает большую часть обсуждаемых здесь проблем.
Бесполезные интерфейсы
На протяжении всей моей карьеры я сталкивался с различными вариантами приведенных ниже идей (они все неправильные):
Доменные классы должны использоваться только через интерфейсы, которые они реализуют
Каждый Слой должен использоваться и использовать сам только интерфейсы (в слоёной архитектуре)
Слой приложения должен реализовывать интерфейсы Входного Порта (в гексагональной архитектуре)
Помимо избыточных файлов интерфейсов, которые приходится постоянно синхронизировать с изменениями в сигнатурах их реализаций, изучение кода в таком проекте может быть одновременно и постоянной загадкой (а нет ли другой реализации?) и сущим мучением (использование Ctrl+Click на методе может перекинуть вас в интерфейс вместо определения метода).
Да и зачем вообще создавать столько новых интерфейсов?
Чрезмерное использование интерфейсов может брать начало из очень старого архитектурного принципа: “Зависеть от Абстракций, а не от Реализаций“. Первый уровень понимания этого принципа предполагает использование большего количества интерфейсов и абстрактных классов, чтобы позволить производить обмен между различными реализациями через полиморфизм в рантайме. Второй уровень понимания гораздо более мощный, но он не имеет никакого отношения к ключевому слову interface: он говорит о разделении задачи на подзадачи (абстракции), поверх которых можно построить более простое и чистое решение (например, TCP/IP стек). Но для целей нашей дискуссии давайте сфокусируемся на первом уровне.
Использование интерфейса позволяет менять разные реализации в рантайме:
Альтернативные реализации одного и того же контракта (или паттерн Strategy Design)
цепочки “Фильтров” (или паттерн Chain of Responsibility), например фильтры для веб и/или безопасности
Фейки для тестов (почти исчезнувшие в современных mock-фреймворках): MyRepoFake реализует IMyRepo
Обогащенные варианты реализаций (паттерн Decorator):
Collections.unmodifiableList()
Прокси к конкретным реализациям (Примечание: языки, базирующиеся на JVM, НЕ требуют, чтобы интерфейсы являлись прокси конкретных классов)
Все перечисленное - это способы повысить гибкость вашей архитектуры, используя полиморфизм. Однако, было бы ошибкой вводить эти паттерны до того, как они будут использованы в реальности. Другими словами, опасайтесь собственных мыслей вроде “Давайте введем интерфейс на всякий случай”, поскольку это является точным воплощением code smell-а под названием Теоретическая общность (Speculative Generality). Закладываться в архитектуре на будущее, возможно, имело смысл несколько десятилетий назад, когда редактирование кода требовало большого количества усилий. Но с тех пор инструменты серьезно эволюционировали. Используя инструменты для рефакторинга, включенные в современные IDE (например, IntelliJ), вы можете извлечь интерфейс из существующего класса и заменить референсы на класс новым интерфейсом во всей кодовой базе за несколько секунд и почти ничем не рискуя. Наличие такого инструмента должно поощрять нас экстрактить интерфейсы только тогда, когда они нам нужны.
Ставьте под вопрос необходимость любого интерфейса, у которого есть только одна реализация в том же модуле.
На самом деле, единственная причина терпеть интерфейс с единственной реализацией – это тот случай, когда интерфейс располагается в другом модуле компиляции по отношению к его реализации:
В библиотеке, используемой вашими клиентами, например my-api-client.jar
Интерфейс во внутреннем кольце, реализация во внешнем (здесь речь про слои в гексогональной архитектуре) = принцип Dependency Inversion (инверсия зависимостей)
Принцип инверсии зависимостей (Отличительный признак любой концентрической архитектуры) позволяет Внутреннему слою (например, Домену) вызывать методы из Внешнего слоя (например, Инфраструктуры), но без сцепления с их имплементацией. Например, сервис внутри Домена может извлекать информацию из другой системы, вызывая методы интерфейса, задекларированные в слое Домена, но реализованные во внешнем слое Инфраструктуры. Другими словами, Домен может вызывать Инфраструктуру, но не может видеть реализацию вызываемого метода. Направление вызова (Domain→Infra) и направление зависимости в коде (Infra→Domain) обратны друг другу (инверсивны), отсюда и название, “инверсия зависимостей”.
Вот еще один (трагикомический) аргумент в пользу интерфейсов, который я один раз услышал:
– Виктор, нам нужны интерфейсы, чтобы прояснить публичный контракт наших классов!
– А что не так с тем, чтобы просто посмотреть на структуру класса для публичных методов? – ответил я
– Да… Но в классе больше 50 публичных методов, поэтому нам нравится перечислять их списком в отдельном файле и красиво группировать их.
– Я: (потерял дар речи)…
– О, и, кстати, в классе реализации больше 2000 строк кода. ?
Я думаю, это очевидно, что бесполезные интерфейсы не были их настоящей проблемой…
Вывод:
Интерфейс заслуживает права на существование, если и только если:
У него в проекте больше одной реализации в проекте, или
Он используется для реализации инверсии зависимостей для защиты внутреннего слоя, или
Он упакован в клиентскую библиотеку
Если интерфейс не попадает ни в одну из этих категорий, подумайте о том, чтобы удалить его (например, используя “Inline” рефакторинг в IntelliJ).
Но давайте вернемся к цитатам из начала раздела:
-
Интерфейсы для Сущностей Домена – неправильно! Ни одна из трех перечисленных выше причин не применима:
Наличие альтернативных реализаций для Сущностей абсурдно
Никакой код не является более сокральным и важным, чем ваш Домен (Инверсия зависимостей против Домена?!)
Выставление наружу Сущностей в вашем API – очень опасное решение
-
Интерфейсы портов ввода в гексагональной архитектуре – неправильно!
У них только одна реализация (само приложение)
Контроллер API НЕ нуждается в защите (он во внешнем слое)
Если вы выставляете наружу интерфейсы портов ввода напрямую, это уже не классическая гексагональная архитектура.
Строгие слои (Strict Layers)
Сторонники этого подхода утверждают, что:
“Каждый слой должен вызывать только следующий непосредственно за ним слой”
Для этой дискуссии давайте предположим, что у нас 4 слоя:
Контроллер
Сервис приложения
Сервис домена
Репозиторий
Если мы настаиваем на принципе Strict Layers, контроллер(1) должен общаться только с сервисом приложения(2) → сервис домена(3) → репозиторий(4). Другими словами, сервису приложения(2) НЕЛЬЗЯ общаться напрямую с репозиторием(4);
Комментарий от команды Spring АйО
В "настоящей" гекогональной архитектуре доменный слой не зависит ни от кого и не должен зависить не от кого. Для общения со слоем "persistence" (реопзиторный слой), он так же объявляет порты (интерфейсы), реализации которых (они же адаптеры), представлены уже в слое persistence.
С точки зрения сборки кода, зависимость именно Persistence -> Domain, а не наоборот.
Тут автор скорее всгео имеет в виду, что бизнес логика, она же домен, получила от одного адаптера (допустим, из UI) через порт данные, и уже бизнес логика сама решила, что по результатам действия данной операции надо вызвать порт "сохранения". Адаптер для этого порта "сохранения", конечно, будет реализован в слое persistence
вызов должен всегда проходить через сервис домена(3). Такой подход приводит к появлению методов со стандартным кодом, например:
class CustomerService {
..
public Customer findById(Long id) {
return customerRepo.findById(id);
}
}
Приведенный выше код является почти выдержкой из учебника, приводящей пример code smell-а, называемого Middle Man (Посредник), поскольку этот метод представляет собой “непрямой вызов без абстракции” – он не добавляет никакой новой семантики (абстракции) к методу, которому он делегирует (customerRepo.findById
). Приведенный выше метод не улучшает ясность кода, а вместо этого добавляет еще одно звено в цепочку вызовов.
Немногие последователи принципа Strict Layers в наши дни приводят аргумент о том, что данное правило требует принятия меньшего количества решений (что желательно для мид и джуниор команд) и снижает сцепление между верхними слоями. Однако, ключевая роль сервиса приложения состоит в оркестрации сценария использования, так что высокий уровень сцепления между включенными в сценарий частями обычно ожидаем. Другими словами, архитектурная цель сервиса приложения состоит в оркестрации логики, чтобы позволить компонентам низшего уровня меньше сцепляться между собой.
Альтернатива принципу Strict Layers называется “Relaxed Layers“ (свободные слои), и этот принцип позволяет пропускать слои, если вызовы идут в одном направлении. Например, сервис приложения(2) может свободно вызывать репозиторий(4) напрямую, тогда как в другое время он может захотеть вызвать сначала сервис домена(3), если в таком вызове есть хоть какая-то логика. Это может уменьшить количество стандартного кода, но требует привычки проводить рефакторинг, чтобы постоянно извлекать логику с растущей сложностью в сервисы домена.
Методы REST контроллера в одну строку
В течение более чем десятилетия мы все соглашались, что:
Ответственность слоя REST контроллера состоит в управлении проблемами, относящимися к HTTP и делегировании всей логики сервису приложения
И в самом деле, 10-20 лет назад это имело смысл. Когда HTML веб страницы генерировались на стороне сервера (вспомните .jsp + Struts2), это требовало многочисленных церемоний в контроллере. Но в наши дни, если они общаются по HTTP, наши приложения и микросервисы обычно только выставляют наружу REST API, передавая всю логику рендеринга HTML странички во фронтенд/на мобильных устройствах. Кроме того, фреймворки (например, Spring), которые мы используем сегодня, эволюционировали настолько, что при аккуратном использовании могут снизить зону ответственности контроллера до всего лишь последовательности аннотаций.
Ответственность REST-контроллера, относящаяся к HTTP, в наши дни сводится к следующему:
Маппинг HTTP запросов на методы – через аннотации (
@GetMapping
)Авторизация действия пользователя – через аннотации (
@Secured
,@PreAuthorized
)Валидация рабочей нагрузки запроса – через аннотации (
@Validated
)Чтение/запись заголовков HTTP запросов/ответов headers – лучше всего делать это через веб-фильтр
Установка кода статуса ответа – лучше всего делать это через хендлер глобальных исключений (
@RestControllerAdvice
)Управление загрузкой/выгрузкой файлов – больше не считается чем-то изысканным, напротив, это единственная оставшаяся уродливая вещь в HTTP
Примечание: Приведенные выше примеры взяты из фреймворка Spring (самый популярный фреймворк в Java), но почти во всех других Java фреймворках и во всех зрелых языках, используемых в web разработке, есть эквиваленты для почти всех упомянутых возможностей.
Соответственно, если только вы не загружаете какие-то файлы и не занимаетесь другими видами HTTP кунг-фу (ЗАЧЕМ?!), у вас не должно остаться никакой относящейся к HTTP логики в ваших REST контроллерах – фреймворк покончил с ней. Если мы предположим, что преобразование данных (DTO ↔ Домен) НЕ происходит в контроллере, тогда все методы в REST контроллере будут однострочными, всего лишь делегируя каждый вызов метода к методу с похожим именем в другом слое:
@RestController
class WhiteController {
..
@GetMapping("/white")
public WhiteDto getWhite() {
return whiteService.getWhite();
}
}
О нет! Это code smell “Middle Man”, который мы видели ранее – стандартный код.
Если то, что вы прочли выше, похоже на вашу архитектуру, вы можете подумать о том, чтобы объединить свой контроллер со следующим слоем (например, с сервисом приложения).
При разработке REST API объединяйте контроллеры и сервис приложения.
Да, я имею в виду аннотации, как на HTTP эндпоинтах (@GetMapping..
), на методах в первом слое, где содержится логика, будь это сервис приложения или просто некий “сервис” (в нашем примере, в WhiteService).
< неловкая тишина >
Я знаю, это идет вразрез со всеми старыми привычками, которым мы следовали до недавнего времени. Возможно, чем дольше вы проработали в этой сфере, тем более странным кажется это решение. Но времена изменились. В системе с REST API упрощения возможны, и их должно быть больше.
На практических занятиях, которые я провожу, я часто натыкался на архитектурные решения, которые позволяли REST контроллеру содержать логику: маппинг, более сложные валидации, логику оркестрации и даже элементы бизнес-логики. Это эквивалентное решение, в котором сервис приложения был технически объединен с впереди стоящим контроллером. Оба одинаково хороши: (a) наличие контроллера, который выполняет часть логики приложения и (b) выставление наружу сервиса приложения как REST API.
⚠️ Однако есть одна ловушка: не аккумулируйте комплексные правила бизнеса в компоненте REST API. Вместо этого постоянно ищите объединяемые фрагменты логики домена, чтобы переместить их в модель Домена (например внутри сущности) или в сервис Домена.
Одно последнее замечание: если вы ставите слишком много аннотаций на методы вашего REST эндпоинта ради документации (характерно для OpenAPI), вы могли бы подумать о том, чтобы извлечь интерфейс и переместить все REST аннотации в него. Затем сервис приложения реализует этот интерфейс и возьмет на себя метаданные.
Тесты с использованием Mock-ов
Unit Testing – наш король. Профессиональные разработчики тщательно пишут юнит-тесты для всего своего кода. Поэтому:
Каждый слой должен тестироваться с заменой более нижнего слоя на mock
Когда архитектура диктует принцип Strict Layers или позволяет использовать однострочные методы REST контроллера (даже если преобразование данных осуществляется в контроллерах), добросовестная команда задаст очевидный вопрос: надо ли покрывать юнит-тестами эти глупые однострочные методы? Как? Если тестировать эти глупые методы с помощью mock-ов, это приведет к тому, что код тестов будет в 5 раз длиннее, чем то, что они тестируют. Что еще хуже, это будет ощущаться как что-то бесполезное – каковы шансы того, что баг проникнет в такой метод?
Люди могут начать раздражаться (“Какая гадость это ваше тестирование! ?”) или, что хуже, снизить стандарты своего тестирования (“Давайте не будем тестировать ВОТ ЭТО?”).
На самом деле, вы лишь сталкиваетесь с честной обратной связью от ваших тестов – ваша система просто полна overengineering-а. Опытный пользователь TDD сразу подхватит идею, но остальные могут не сразу принять эту истину: “когда тестирование стало сложным, это значит, что архитектуру продукта можно улучшить“.
Для полноты картины я хотел бы добавить еще одно предложение к классическому утверждению, приведенному выше:
Когда тестирование стало сложным, это значит, что архитектуру продукта можно улучшить, либо ваше тестирование слишком подробное.
Если ваши интеграционные тесты полностью покрывают этот метод, действительно ли вам нужен юнит тест в изоляции? Почитайте о Honeycomb Testing для микросервисов и обдумайте идею о том, что юнит тестирование – всего лишь необходимо зло. При тестировании микросервисов, тестируйте снаружи внутрь: начните с интеграционного тестирования > затем добавьте юнит тестирование (чтобы покрыть краевые случаи).
Отделяйте DTO приложения от REST DTO
При изучении чистой архитектуры от дядюшки Боба у многих людей появляется впечатление, что структуры данных, выставляемые наружу REST эндпоинтами, должны отличаться от объектов, выставляемых наружу слоем приложения (давайте называть их “ApplicationDto
”). То есть, появляется дополнительный набор классов, преобразованных из одного в другое. Высокая цена такого решения обычно познается довольно быстро, и большинство инженеров сдаются. Остальные, однако, настаивают, и каждое поле, которое они добавляют в дальнейшем к данным, приходится добавлять к трем структурам данных: один раз в DTO, отправляемом/получаемом как JSON, другой раз в объекте типа ApplicationDto
, и еще раз в сущности Домена, которая сохраняет данные.
#Не делайте этого!
Вместо этого:
Делайте пропогейшен REST API DTO в слой приложения.
Да, сервису приложения было бы сложнее работать с API моделями, но это лишь еще одна причина сохранять его легким, не нагруженным доменными сложностями.
Но есть ли валидная причина отделять ApplicationDto
от REST API DTO?
Да, есть. На случай, если один и тот же сценарий использования выставляется наружу по двум или более каналам: например, по REST, Kafka, gRPC, WSDL, RMI, RSocket, Server-side HTML (например, Vaadin,..) и т.д.. Для этих сценариев использования имеет смысл делать так, чтобы ваш сервис приложения говорил на своем языке структур данных через свои публичные методы. Далее REST контроллер преобразует их в/из REST API DTO, в то время как например эндпоинт gRPC будет заниматься преобразованиями в/из свои protobuf объекты. Видя перед собой такие сценарии, прагматичный инженер, возможно, применит эти техники только к нескольким сценариям использования, а именно к тем, которые этого требуют.
Отделяйте Persistence от модели Домена
Наконец-то мы добрались до одной из самых горячих дискуссий вокруг чистой и луковичной архитектуры:
Должен ли я позволять проблемам, связанным с Persistence, загрязнять мою модель Домена?
Другими словами, должен ли я ставить аннотацию @Entity на свою модель Домена и позволять своему фреймворку ORM сохранять/загружать экземпляры объектов моей модели Домена?
Если вы ответили НЕТ на приведенные выше вопросы, то вам необходимо:
Создать копию всех ваших сущностей Домена за пределами Домена, например,
CustomerModel
(Домен) в отличие отCustomerEntity
(Persistence)Создать интерфейсы для репозиториев в Домене, которые только загружают/сохраняют сущности Домена, например,
customerRepo.save
(CustomerModel)
Реализовать интерфейсы репозитория в инфраструктуре, преобразовав сущности Домена в/из ORM сущностей
Если коротко: боль! баги! раздражение!
Это одно из самых дорогостоящих решений, поскольку оно по сути увеличивает размер кода в четыре или более раз для CRUD операций.
Я встречал команды, которые выбрали этот путь и заплатили описанную выше цену, но все они пожалели о своем решении через год-два, если не принимать во внимание несколько исключений.
Но что заставляет уважаемых технических лидеров принимать такие дорогостоящие решения?
В чем состоит опасность использования ORM?
Использование такого мощного фреймворка как ORM никогда не бывает бесплатным.
Далее перечислены главные проблемы, в которые люди попадают, используя ORM:
Магические возможности: авто-сброс dirty сущностей и прочие write-behind, ленивая загрузка, пропогейшен транзакций, PK присвоение,
merge()
,orphanRemoval
, …Неочевидные проблемы с производительностью, которые могут навредить вам позднее: N+1 запросы, ленивая загрузка, доставка бесполезных данных из базы, наивное ООП моделирование ...
Подход к моделированию, отталкивающийся от базы данных: мышление в терминах таблиц и внешних ключей мешает вам думать о вашем Домене на более высоких уровнях абстракции и находить лучшие способы его перемоделирования (Domain Distillation)
Игнорировать все вышесказанное не очень мудро. В конце концов, все это относится к самой важной и ценной части вашего приложения: модели Домена.
Поэтому вот что вы можете со всем этим сделать:
1) Магические возможности: изучите или избегайте. Количество удивленных реакций моей аудитории во время практических занятий по JPA всегда беспокоило меня – это как люди за рулем Ferrari без водительских прав. В конце концов, возможности Hibernate/JPA намного более сложны, чем те, которые вы найдете в Spring Framework. Но по какой-то причине, все всегда думают, что знают JPA. Пока эта магия не одарит вас трудноуловимым багом.
Вы также можете заблокировать некоторые из магических возможностей, например, отделив сущности от Repo и не оставляя транзакции открытыми, но такие подходы обычно приводят к проблемам с производительностью и/или неприятными краевыми случаями. Так что лучше убедиться в том, что вся ваша команда хорошо освоила JPA.
2) Мониторьте производительность с самого начала: существуют коммерческие инструменты, которые помогают менее опытным командам заметить типичные проблемы с производительностью, связанные с использованием ORM, но самый простой способ их избежать (и изучить полезные вещи в то же самое время) – это следить за генерируемыми SQL запросами при помощи инструментов, таких как Glowroot, p6spy или воспользоваться любым другим способом логировать исполняемые JDBC команды.
3) Моделируйте свой Домен согласно принципам ООП. Даже если вы используете инкрементальные скрипты (а вам следует так поступать!), постоянно отслеживайте возможные способы рефакторинга и пробуйте их применять, позволяя вашей ORM генерировать схему, пока вы занимаетесь этими исследованиями. Объектно-ориентированный способ мышления всегда может предложить намного более высокие уровни понимания нужд Домена, чем мышление в терминах таблиц, колонок и внешних ключей.
По итогу, мой опыт показал мне, что изучить ORM дешевле и проще для команды, чем убегать от нее, если говорить о долгосрочной перспективе. Так что позвольте ORM зайти в ваш Домен, но изучите ее!
Слой приложения, отделенный от слоя инфраструктуры
В оригинальной луковичной архитектуре, слой приложения отделялся от слоя инфраструктуры, с целью хранить логику сервиса приложения отдельно от API третьих сторон. Каждый раз, когда мы хотим получить какие-то данные от API третьей стороны из слоя приложения, нам приходится создавать новые объекты данных + новый интерфейс в слое приложения, и затем реализовать его в инфраструктуре (упомянутый ранее принцип инверсии зависимостей). Это довольно высокая цена, которую приходится платить за отделение.
Но поскольку большинство наших бизнес-правил реализованы в слое Домена, слою приложения остается лишь оркестровать сценарии использования, не занимаясь особо логикой. Поэтому риск от манипуляций с DTO от третьих сторон в слое приложения вполне терпим, если сравнивать его со стоимостью отделения.
Объединяйте слои приложения и инфраструктуры.
= “Прагматичная Луковица” ?
Объединение слоев приложения и инфраструктуры позволяет получить свободный доступ к сторонним API DTO из сервисов приложения.
Таким образом у вас остается только два слоя/кольца: Приложение (включая инфраструктуру) и Домен.
Риск: если основные бизнес-правила не помещаются в слой Домена, но остаются в слое Приложения, их реализация может быть загрязнена и становится зависима от третьесторонних DTO и API. Поэтому сервисы приложения должны очищаться от бизнес-правил даже еще более тщательно.
Здесь появляется интересный краевой случай. Если у вас мало persistent данных (вы не храните много данных), тогда у вас не будет очевидной модели Домена, чтобы сделать маппинг на/с данных от третьей стороны. В таких системах, если там присутствует серьезная логика для реализации поверх этих третьесторонних объектов, в какой-то момент может потребоваться создать ваши собственные структуры данных, на которые можно будет сделать маппинг внешних DTO, чтобы у вас остался способ контролировать структуры, с помощью которых вы пишете свою сложную логику.
Недостаточно доменной сложности
Как и любой инструмент, концентрические архитектурные стили подходят не любому проекту по разработке ПО. Если доменная сложность вашей задачи достаточно низкая (только CRUD), или главный вызов, исходящий от вашего приложения, НЕ заключается в сложности его бизнес-правил, тогда луковичная/гексагональная/порт-адаптер/чистая архитектура может быть не наилучшим выбором, и вам может больше подойти вертикальная нарезка, анемичная модель, CQRS или другой тип архитектуры.
Заключение
В этой статье мы посмотрели на следующие источники оверинжениринга, расхода ресурсов впустую и багов при применении концентрических архитектурных стилей (луковичная/гексагональная/чистая и т.д.)
– Бесполезные интерфейсы => удалите их, если у них нет ≥2 реализаций или инверсии зависимостей
– Strict Layers => Relaxed Layers = позвольте вызовам пропускать слои при движении в одном направлении
– Однострочные REST контроллеры => объедините их со следующим слоем, но держите сложность под контролем
– Тесты с mock-ами => объедините слои или тестируйте фрагменты большего размера
– Разделение DTO приложения<>DTO контроллеров => используйте единый набор объектов, если у вас нет нескольких каналов вывода
– Разделение Persistence и модели Домена => не делайте этого! Используйте единую модель, но изучите магию ORM и западни, в которые можно попасть относительно производительности.
– Разделение приложения и инфраструктуры => объедините слои приложения и инфраструктуры, но будьте настойчивее при переносе бизнес-правил в Домен

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.
Комментарии (8)
lleo_aha
11.06.2025 10:38Несмотря на то что согласен почти со всем, дальше это обычно приводит к тому что "упрощенная более понятная архитектура без овер инжиниринга" требует, например, добавить код логгирования api 3rd party вызовов всего лишь в 60-70 мест вместо одного. И по времени это существенно дешевле чем "заметить тут очевидное решение и выделить API client gateway".
Т.е. при устойчивом и корректном росте приложения:
"строгая" архитектура переусложнена на старте (но вы сами же пишете что сейчас IDE съедают эту сложность), но при этом остается предсказуемой для внесения изменений
тогда как "упрощенная" архитектура может быть более понятной сейчас, но впоследствии цена внесения изменений только растёт
onets
11.06.2025 10:38Хорошо что все собранно в одной статье - я и сам местами стал приходить к тем же выводам.
С другой стороны - если сначала делать просто, то потом дольше переделывать на более сложный подходящий вариант.
И еще - как только мы начнем думать о том как все это тестировать, то все эти пункты могут быть переработаны. Например интеграционное тестирование. Можно тестировать начиная с контроллеров (это когда поднимают экземпляр сервисами дергают его по http). А можно поднимать только бизнес слой + бд и тогда однострочные контроллеры - благо.
Lewigh
11.06.2025 10:38Все это выглядит как лечение симптомов а не причины. Причем лечение симптомов закономерно приводящее к другим проблемам. Начнем с того, что упрощения автора во многих случаях просто делают пресловутую гексагональную архитектуру бесполезной. К примеру, он предлагает делать прямые вызовы репозиториев, при этом не задумываясь зачем вообще репозитории чаще всего спрятаны и к каким проблемам это может привести. Немножко подумаем и поймем что репозиторий можно отдавать только на чтение. Почему? Потому что если мы позволяем модифицировать данные в любом месте проекта то весь смысл слоеной архитектуры, теряется.
Следующий момент который автор не осознает - это аспекты промышленной разработки. В реальности, когда над проектом работают несколько человек то важнейший аспект это простота и единообразие подхода. К сожалению или счастью, средний разработчик не искушен понимание архитектурных изысков и уместности и давать ему возможность делать на своей усмотрение - плохая затея. Проект очень скоро будет представлять из себя клубок запутанного кода. Каждый разработчик как уникальная снежинка начнет делать по своему. Намного лучше тупо писать эти интерфейсы везде к примеру и где-то писать больше кода, чем заставлять среднего разраба вставать перед выбором писать или нет а другого разраба пытаться понять чем руководствовался первый.
Другой намного более важный вопрос которым стоит задаться: гексагональная архитектура - это вообще корректный подход к разработке плюсы которого перевешиваю минусы? Если немного отстранится от хайпа и карго культов, мы имеет сильно овер-инженерный подход, который даже "по учебнику" получается не очень и предлагается интерпретировать все правила вольным для каждого разработчика образом походу теряя те преимущества которые данный подход обещал. Может быть немного притормозить и оглянутся по сторонам?
summerwind
11.06.2025 10:38Каждый раз, когда читаю очередную похожую статью, возникает ощущение, что наше понимание того, что является хорошим или плохим кодом, постепенно движется куда-то не в ту сторону. Упрощение ради самого упрощения стало идеей фикс.
Безусловно, в статье есть и дельные мысли. Но не покидает чувство попытки любые правила назвать "старыми привычками" и преподносить антипаттерны как полезные рекомендации. Те же толстые контроллеры, за которые раньше били по рукам новичков, теперь вдруг стали хорошим подходом.
AlexViolin
11.06.2025 10:38Резюмирую - то что удалось придумать и понять нового в архитектуре ПО за последние 20 лет в статье объявлено как ненужное и лишь усложняющее разработку проектов.
olku
11.06.2025 10:38Концентрическая архитектура имеет другое устоявшееся название - DDD Lite. Вполне себе практическая штука, когда команда перестает вывозить когнитивную нагрузку. Есть сомнения в переходе на два слоя, но четыре, как в классическом DDD, это черезчур.
Hivemaster
11.06.2025 10:38Эти упрощения хороши, когда все разработчики в компании опытные, умеют в абстракции и отлично понимают, что такое хорошо, и что такое плохо. Когда же на одного архитектора приходится десяток проектов, в каждом из которых джуны, не понимающие архитектурных принципов, так как ещё просто не успели шишек набить, и мидлы с лозунгом Вовки "и так сойдёт", в кодовых базах мгновенно образуется лапша. К тому же, строго формализованную архитектуру легче автоматически валидировать.
guryanov
Да, согласен. Сначала сторонники этих слоеных архитектур говорят, ну и что, что в 2 раза больше работы, зато... А потом приходят топ-менеджеры "А вот в компании X код пишет ИИ на 10% быстрее, давайте вас всех уволим"