Привет, Хабр!

Еще в C++20 появилась явная поддержка модулей в языке. Интересно, но в Java тоже давно искали похожее решение для упорядочивания больших монолитных проектов. Spring предлагает свой ответ – проект Spring Modulith, цель которого дать разработчику инструмент для построения модульного монолита. Он не делает всю работу, но помогает структурировать код по модулям, проверять архитектурные правила и организовывать взаимодействие между этими модулями.

Что такое Spring Modulith и зачем он нужен

Spring Modulith – сравнительно новый проект из экосистемы Spring, который помогает создавать модульные приложения на Spring Boot. Проще говоря, это набор библиотек и правил, который позволяет разбить монолит по логическим модулям, а затем проверять корректность этих границ во время сборки и тестов. Проект не создаёт модули автоматически – вы сами организуете пакеты и классы в модули (по сути, ограничиваете области видимости), а Modulith лишь даёт конвенции, тесты и документацию на это.

Концепция проста: в Spring Boot приложение, суб-пакеты главного пакета приложения (где лежит класс @SpringBootApplication) по дефолту считаются отдельными модулями.

Например, если у вас пакет com.acme.app с классом Application, то подпакеты com.acme.app.ordercom.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». Чтобы проверить уровень знаний для поступления на курс, пройдите вступительное тестирование.

А на странице курса можно записаться на бесплатные открытые уроки — там практические разборы и только наиболее актуальные темы.

Комментарии (0)