モノリスのままだと内部結合が増えて辛い、でもマイクロサービスに分割するのはオーバーキル。そんな悩みを持つチームは多いですよね。中間解として注目されているのが「モジュラーモノリス」です。

この記事では Spring Modulith 1.x を使って、パッケージ境界の宣言・検証・イベント駆動通信・永続化までを一気通貫で整理します。

モジュラーモノリスとSpring Modulithの位置付け

伝統的モノリスは内部のクラスを自由に呼び合えるため、放っておくと依存がスパゲッティ化します。一方マイクロサービスは独立デプロイの恩恵がある反面、運用コストとネットワーク境界の複雑さを抱え込みます。

モジュラーモノリスはその中間。 デプロイ単位は1つのまま、コード内部だけを強い境界で区切る 設計です。

Spring Modulith は Spring チーム公式のプロジェクトで、次の4つを標準で提供してくれます。

  • パッケージレベルの境界宣言
  • 境界違反の検証
  • モジュール間のイベント駆動通信
  • アーキテクチャドキュメントの自動生成

ApplicationEvent の基本については Spring BootでApplicationEventを使ったイベント駆動設計 を先に読んでおくと理解が早いです。

前提条件と依存関係

Spring Modulith 1.x は Spring Boot 3.x と Java 17 以上が前提です。 BOM を使ってバージョンを揃えるのが安全ですね。

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.modulith</groupId>
      <artifactId>spring-modulith-bom</artifactId>
      <version>1.2.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-core</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

starter-core がモジュール検出と境界検証、 starter-jpa がイベント永続化、 starter-test がテスト支援を担います。

@ApplicationModuleでパッケージ境界を宣言する

Spring Modulith は メインアプリケーションクラス直下のサブパッケージを自動的に1つのモジュールとして認識 します。たとえば com.example.app.order 配下は丸ごと order モジュールです。

メタ情報は package-info.java に書きます。

@org.springframework.modulith.ApplicationModule(
    displayName = "Order Module",
    allowedDependencies = {"shared"}
)
package com.example.app.order;

allowedDependencies を絞ることで、他モジュールへの参照を明示的に制限できます。デフォルトでは internal という名前のサブパッケージは外部から参照不可、それ以外がそのモジュールの公開 API として扱われます。

より細かく制御したい場合は @NamedInterface で公開する範囲を指定できます。

ApplicationModules.verify()で境界違反を検出する

境界は宣言しただけでは守られません。テストで継続的に検証しましょう。

import org.springframework.modulith.core.ApplicationModules;
import org.junit.jupiter.api.Test;

class ModularityTests {

    @Test
    void verifiesModularStructure() {
        ApplicationModules.of(Application.class).verify();
    }
}

たった3行ですが、 internal パッケージへの直接依存や、 allowedDependencies に書かれていないモジュールへの参照があると例外で落ちます。例外メッセージにはどのクラスがどのモジュールに違反したかが具体的に出るので、修正箇所を迷わず特定できます。

このテストを CI に組み込んでおけば、レビューをすり抜けた境界違反も自動的に止まります。

@ApplicationModuleListenerでモジュール間を疎結合化する

モジュール間の通信を「直接呼び出し」から「イベント」に置き換えます。発火側はシンプルです。

@Service
@RequiredArgsConstructor
public class OrderService {
    private final ApplicationEventPublisher events;

    @Transactional
    public void placeOrder(OrderRequest req) {
        // ... 注文の永続化
        events.publishEvent(new OrderCreated(req.orderId()));
    }
}

受信側は @ApplicationModuleListener を使います。

@Component
class InventoryEventHandler {

    @org.springframework.modulith.ApplicationModuleListener
    void on(OrderCreated event) {
        // 在庫を引き当てる
    }
}

このアノテーションは @TransactionalEventListener(AFTER_COMMIT)@Async@Transactional を組み合わせたショートカットです。発行側のトランザクションがコミットされた後に、非同期で別トランザクションとして処理されます。

結果、 Order モジュールは Inventory モジュールのクラスを一切 import せずに済みます。

Event Publication Registryでイベントを永続化する

非同期処理にはイベント消失のリスクがつきまといます。 Spring Modulith はこの問題に Event Publication Registry という仕組みで答えてくれます。

spring-modulith-starter-jpa を入れると event_publication テーブルが自動生成され、発行されたイベントがリスナーごとに行として記録されます。リスナーが正常終了すると completion_date が埋まり、失敗すると未完了のまま残ります。

未完了イベントはアプリ再起動時に自動再配信されます。任意のタイミングでリトライしたい場合は次のように書けます。

@Component
@RequiredArgsConstructor
class EventRetryJob {
    private final IncompleteEventPublications incomplete;

    @Scheduled(fixedDelay = 60_000)
    void retry() {
        incomplete.resubmitIncompletePublications(p -> true);
    }
}

これは Outboxパターンによるトランザクショナルメッセージング と似た発想ですが、外部メッセージブローカが不要で、モノリス内で完結する点が大きな違いです。

既存ApplicationEvent実装からの移行ステップ

既に ApplicationEventPublisher@EventListener で組んでいるプロジェクトは、次の順で段階的に移行できます。

  1. パッケージ構造を機能単位に再編成し、各ルートに package-info.java を配置する
  2. ApplicationModules.verify() を流して境界違反を可視化し、まずは内部を片付ける
  3. @EventListener@ApplicationModuleListener に置き換える
  4. spring-modulith-starter-jpa を入れてイベント永続化を有効化する

注意したいのは循環依存です。 A モジュールが B のイベントを購読し、 B が A のイベントを購読する形は許容されますが、コンパイル時依存が双方向になっている場合は verify が落ちます。イベント型を共通モジュール(例: shared)に置く運用が無難です。

Documenterでモジュール構成図を生成する

設計図はコードと一緒に腐っていくものですが、 Modulith はこれを自動生成してくれます。

@Test
void writeDocumentation() {
    var modules = ApplicationModules.of(Application.class);
    new org.springframework.modulith.docs.Documenter(modules)
        .writeModulesAsPlantUml()
        .writeIndividualModulesAsPlantUml()
        .writeDocumentation();
}

出力は PlantUML と AsciiDoc で、モジュール依存図とイベントフロー図が含まれます。 CI で生成物をアーティファクトとして保存しておけば、ドキュメントが常に最新になります。

採用すべきケースと避けるべきケース

向いているのは 5〜30 人規模のチームで、ドメイン境界が見え始めた成長期のサービスです。マルチテナント SaaS の内部分割にも相性が良く、 マルチテナンシー実装ガイド と組み合わせるとさらに整理しやすくなります。

逆に、チームが地理的・時間的に分散していてデプロイ独立性が必須な場合や、数人で作る短命の PoC では過剰投資になります。

将来マイクロサービスに分割する選択肢を残したい場合でも、 Modulith で整理した境界はそのまま分割線として再利用できるので、無駄になりにくいのも嬉しいポイントです。

まとめ

Spring Modulith は「境界を宣言し、検証し、イベントで繋ぎ、永続化し、ドキュメント化する」5点セットを Spring 標準として提供してくれます。 ApplicationEvent の資産を活かしながら段階的に導入できるので、いきなりマイクロサービスに飛びつく前の現実的な一手として試してみる価値がありますよ。