Привет, Хабр!
Еще в C++20 появилась явная поддержка модулей в языке. Интересно, но в Java тоже давно искали похожее решение для упорядочивания больших монолитных проектов. Spring предлагает свой ответ – проект Spring Modulith, цель которого дать разработчику инструмент для построения модульного монолита. Он не делает всю работу, но помогает структурировать код по модулям, проверять архитектурные правила и организовывать взаимодействие между этими модулями.
Что такое Spring Modulith и зачем он нужен
Spring Modulith – сравнительно новый проект из экосистемы Spring, который помогает создавать модульные приложения на Spring Boot. Проще говоря, это набор библиотек и правил, который позволяет разбить монолит по логическим модулям, а затем проверять корректность этих границ во время сборки и тестов. Проект не создаёт модули автоматически – вы сами организуете пакеты и классы в модули (по сути, ограничиваете области видимости), а Modulith лишь даёт конвенции, тесты и документацию на это.
Концепция проста: в Spring Boot приложение, суб-пакеты главного пакета приложения (где лежит класс @SpringBootApplication
) по дефолту считаются отдельными модулями.
Например, если у вас пакет com.acme.app
с классом Application
, то подпакеты com.acme.app.order
, com.acme.app.inventory
и т.д. модульными считаются по умолчанию. При этом любая кодовая логика, которая лежит в подпакетах этих модулей (например, com.acme.app.order.internal
), считается внутренней для модуля, и другие модули не должны к ней обращаться. Spring Modulith позволяет настроить эти границы и проверить их соблюдение.
Если у пакета модуля есть вложенные подпакеты (например,
order.internal
), модуль считается продвинутым, а вложенные пакеты — его внутренностями. Код в них публичен в плане Java, но другим модулям обращаться к нему не должно. Modulith это контролирует.
Также можно явно объявлять вложенные модули и открытые модули. С помощью аннотации @ApplicationModule
(в файле package-info.java
или на классе) можно помечать под-пакеты как вложенные модули, задавать тип модуля (например, открытый модуль Type.OPEN
позволяет игнорировать запреты на доступ к внутренностям), и даже явно перечислять разрешённые зависимости от других модулей через параметр allowedDependencies
.
Пример объявления открытого модуля:
@org.springframework.modulith.ApplicationModule(
type = Type.OPEN
)
package example.inventory;
А вот пример, когда модуль явно ограничивает зависимости только на модуль order
:
@org.springframework.modulith.ApplicationModule(
allowedDependencies = "order"
)
package example.inventory;
Подобная настройка позволяет контролировать архитектуру на уровне кода: разделить API (публичные сервисы модуля и события) и реализацию (внутренние классы), а главное проверять эти правила автоматически.
Проверяем границы модулей
Для проверки соблюдения модульной структуры в Spring Modulith используется API ApplicationModules
. Достаточно вызвать:
ApplicationModules.of(Application.class).verify();
чтобы запустить все встроенные проверки модулярного кода.
Метод verify()
автоматически инжектит информацию о всех пакетах и бинах приложения и оценивает такие правила как:
Нет циклов зависимостей между модулями — граф зависимостей модулей должен быть ацикличным.
Доступ только через публичный API — один модуль не должен обращаться к внутренним пакетам другого. Все перекрёстные зависимости допускаются только через публичные классы API модуля.
Только явно разрешённые зависимости — если вы в
@ApplicationModule
задалиallowedDependencies
, то модуль может зависеть только от перечисленных. Все остальные ссылки приведут к ошибке валидации.
Если при проверке найдены нарушения, verify()
выбрасывает исключение и тест/сборка падает, указывая, в чём проблема. Так можно превратить архитектурные правила в тесты, которые всегда прогоняются. Кроме verify()
, есть метод detectViolations()
, который возвращает список нарушений для более гибкой обработки (например, чтобы логировать или игнорировать некоторые).
Представим два модуля order
и inventory
, и случайную циклическую зависимость. Запуск ApplicationModules.of(App.class).verify()
сразу сообщит об ошибке цикла, а также о том, что некий сервис из inventory.internal
был неправильно затянут в order
напрямую. Это защитит от нежелательных изменений границ модулей.
Взаимодействие через доменные события
Spring Modulith пропагандирует слабосвязанное взаимодействие между модулями — особенно через доменные события. Идея в том, чтобы вместо прямого вызова бинов одного модуля из другого публиковать события через ApplicationEventPublisher
. Так исходный модуль не знает, кто его слушает, и тестировать модули проще.
Например, был сервис заказа OrderManagement
, который после оформления заказа менял статус заказа и звонил напрямую в InventoryService
для обновления склада. Вместо этого можно поступить так:
@Service
public class OrderManagement {
private final ApplicationEventPublisher events;
@Autowired
public OrderManagement(ApplicationEventPublisher events) {
this.events = events;
}
@Transactional
public void completeOrder(Order order) {
// изменения состояния заказа ...
events.publishEvent(new OrderCompleted(order.getId()));
}
}
Мы опубликовали событие OrderCompleted
через стандартный ApplicationEventPublisher
(Spring делает это синхронно внутри той же транзакции).
Затем другой модуль просто слушает это событие:
@Component
public class InventoryManagement {
@ApplicationModuleListener
public void on(OrderCompleted event) {
// ...обновляем остатки на складе
}
}
Аннотация @ApplicationModuleListener
от Spring Modulith — это просто удобный shortcut: она уже включает в себя @TransactionalEventListener
и @Async
(при необходимости).
То не нужно отдельно добавлять @Transactional
или @Async
в слушателе, только убедиться, что в конфигурации приложения включена поддержка асинхронности (@EnableAsync
). Такой подход разрывает прямую зависимость между бинами: модуль order
не знает, кто и как обрабатывает OrderCompleted
, а модуль inventory
просто говорит — я слушаю такие события.
Это дает два эффекта:
Слабая связность. Мы не тащим бин
InventoryService
вOrderManagement
, а значит можно отдельно тестировать модуль заказа без инвентаря и наоборот.Простая модель согласованности. Публикация происходит внутри транзакции
OrderManagement.completeOrder
. Если слушатели тоже работают в рамках одной транзакции, то либо всё коммитится, либо во всем откатывается. (
В общем, связку «сервис - вызывающий код – событие – слушатель» Modulith считает идеальной для модульного монолита.
Тестирование событий и модулей
Поскольку Spring Modulith тесно интегрируется с тестами, то можно проверять и реальные публикации событий. Достаточно добавить в проект зависимость spring-modulith-starter-test
и отметить класс теста как @ApplicationModuleTest
(аналог @SpringBootTest
, но для отдельного модуля). Тогда Spring модуль просто прикручивается и в тестах доступен специальный объект PublishedEvents
или AssertablePublishedEvents
.
Пример модульного теста, который проверяет, что при обработке заказа публикуется событие:
@ApplicationModuleTest
class OrderModuleIntegrationTest {
@Test
void testOrderCompletionPublishesEvent(PublishedEvents events) {
Order order = ...; // подготовка тестового заказа
orderManagement.completeOrder(order);
List<OrderCompleted> published = events.ofType(OrderCompleted.class)
.matching(OrderCompleted::getOrderId, order.getId());
assertThat(published).hasSize(1);
}
}
Здесь в метод теста внедряется объект PublishedEvents
. После вызова бизнес-метода completeOrder
мы запрашиваем в нём все события типа OrderCompleted
, дополнительно фильтруем по полю orderId
и убеждаемся, что именно одно событие было опубликовано. Фреймворк сам прослушивает публикации Spring-событий и аккумулирует их в памяти в течении теста.
Также можно использовать AssertablePublishedEvents
для более стильной проверки:
@ApplicationModuleTest
class OrderModuleIntegrationTest {
@Test
void testOrderCompletionPublishesEvent(AssertablePublishedEvents events) {
// ...выполнение метода...
assertThat(events)
.contains(OrderCompleted.class)
.matching(OrderCompleted::getOrderId, expectedOrderId);
}
}
Кроме проверки событий, в @ApplicationModuleTest
инжектятся реальные бины только модуля (с показацию Standalone или Direct). Если тестируемый модуль зависит от бинов другого модуля, их можно моком пометить @MockitoBean
. При этом Spring Modulith сам ограничивает сканирование пакетов только модулем под тест (и его прямыми зависимостями, если надо). Э
Итак, с помощью Spring Modulith мы получаем:
Вывод структуры модулей. Можно вывести в консоль или график все модули и их бины (метод
modules.forEach(System.out::println)
покажет, какие бины находятся в каких модулях).Проверку правил. Если код нарушил границы (например, прямой импорт из
*.internal
), сборка теста сразу падёт.Организацию общения. Модули договариваются друг с другом через события, и это легко тестируется.
Большинство шаблонов встроено в Spring Modulith, остается только следовать рекомендациям: держать публичный API модуля явным (service-ы и события) и все остальное во внутренних пакетах. При таком подходе монолит фактически превращается в готовую к выколупыванию из него микросервисов систему, но пока вы по-прежнему запускаете одно приложение.
Spring Modulith — отличный способ держать монолит в порядке. Но если хочется понять весь стек Spring — от Boot до продакшн-паттернов, поможет курс «Разработчик на Spring Framework». Чтобы проверить уровень знаний для поступления на курс, пройдите вступительное тестирование.
А на странице курса можно записаться на бесплатные открытые уроки — там практические разборы и только наиболее актуальные темы.