Spring Modulith入門:Spring Bootでモジュラーモノリスを構築する

Spring BootでWebアプリケーションを開発していると、最初はきれいだったプロジェクトが、機能追加とともに徐々に複雑になっていくことがあります。

たとえば、次のような構成です。

controller
├── OrderController
├── ProductController
└── CustomerController

service
├── OrderService
├── ProductService
└── CustomerService

repository
├── OrderRepository
├── ProductRepository
└── CustomerRepository

小規模なうちは分かりやすいですが、サービスが大きくなるにつれて、

  • OrderServiceからProductServiceを呼ぶ
  • ProductServiceからCustomerServiceを呼ぶ
  • さらに別のServiceを呼ぶ

といった依存関係が増えていきます。

結果として、

「このクラスを変更すると、どこまで影響するのか分からない」

という状態になりがちです。

そこで使えるのが Spring Modulith です。

Spring Modulithを利用すると、Spring Bootアプリケーションを「注文」「在庫」「決済」といった業務機能単位に分割し、モジュール間の依存関係まで検証できます。

この記事では、注文と在庫を例に、

Spring Boot
     │
     ├── Order Module
     │
     └── Inventory Module

というモジュラーモノリスを実際に作りながら、Spring Modulithの基本を解説します。

Spring Modulithとは

Spring Modulithは、Spring Bootアプリケーションを業務機能単位のモジュールに分割するためのSpring公式プロジェクトです。

重要なのは、Spring Modulithを使ってもアプリケーションそのものは1つという点です。

                 Spring Boot Application
                         │
          ┌──────────────┼──────────────┐
          │              │              │
       Order          Inventory       Payment
       Module          Module          Module

Order、Inventory、Paymentという複数のモジュールがありますが、デプロイするアプリケーションは1つです。

このようなアーキテクチャを、

モジュラーモノリス(Modular Monolith)

と呼びます。

モノリス・モジュラーモノリス・マイクロサービスの違い

まず、この3つを整理しておきましょう。

一般的なモノリス

Spring Boot
│
├── Controller
├── Service
├── Repository
└── Entity

アプリケーション全体を1つのシステムとして作ります。

シンプルですが、規模が大きくなると依存関係が複雑になりやすい問題があります。

モジュラーモノリス

Spring Boot
│
├── Order
│   ├── API
│   └── Internal
│
├── Inventory
│   ├── API
│   └── Internal
│
└── Payment
    ├── API
    └── Internal

アプリケーション自体は1つですが、内部を業務機能単位に分割します。

マイクロサービス

Order Service
      │
      ├── HTTP / Messaging
      │
Inventory Service
      │
Payment Service

それぞれが独立したアプリケーションとして動作します。

デプロイやデータベース、通信なども分離できますが、そのぶん運用が複雑になります。

Spring Modulithは、この中間に位置するアプローチと考えると分かりやすいでしょう。

なぜモジュラーモノリスなのか

「それなら最初からマイクロサービスにすればいいのでは?」

と思うかもしれません。

しかしマイクロサービスにすると、

  • サービス間通信
  • 認証
  • 分散トランザクション
  • 障害対策
  • ログの追跡
  • OpenTelemetryなどによる分散トレーシング
  • Kubernetesなどのインフラ管理

といった問題も増えます。

一方、モジュラーモノリスなら、

デプロイ単位:1
プロセス:1
↓
内部構造だけを明確に分割

できます。

そのため、

「まだマイクロサービスにするほどではないが、巨大なSpring Bootアプリケーションにはしたくない」

というケースで非常に使いやすい設計です。

今回作るアプリケーション

今回はECサイトのバックエンドをイメージします。

次の2つのモジュールを作ります。

Order
  注文を受け付ける

Inventory
  在庫を管理する

処理の流れは次のとおりです。

HTTP Request
     │
     ▼
Order Module
     │
     │ OrderCompleted Event
     ▼
Inventory Module
     │
     ▼
在庫を減らす

OrderモジュールがInventoryモジュールの内部クラスを直接呼び出すのではなく、イベントを使って連携させます。

プロジェクトを作成する

この記事では次の環境を想定します。

Java 21
Spring Boot 4.1.x
Gradle
Spring Modulith 2.1.1

build.gradleは次のようにします。

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.0'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.modulith:spring-modulith-bom:2.1.1'
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.modulith:spring-modulith-starter-core'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.modulith:spring-modulith-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Spring ModulithではBOMが用意されているため、各ライブラリのバージョンを個別に指定する必要はありません。

Spring Modulithではパッケージ構成が重要

Spring Modulithの特徴の1つが、Javaのパッケージ構成を利用してモジュールを定義することです。

Spring Bootのメインクラスを、

com.example.shop

に置いた場合、その直下にあるパッケージが基本的にモジュールとして認識されます。

今回なら次のようにします。

com.example.shop
│
├── ShopApplication.java
│
├── order
│   ├── OrderManagement.java
│   ├── OrderCompleted.java
│   │
│   └── web
│       └── OrderController.java
│
└── inventory
    └── internal
        └── InventoryEventListener.java

Spring Modulithから見ると、

order
inventory

がそれぞれApplication Moduleになります。

つまり、

com.example.shop.order

と

com.example.shop.inventory

が独立したモジュールとして扱われます。

Spring Bootの起動クラス

通常のSpring Bootアプリケーションと同じです。

package com.example.shop;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

Spring Modulithだからといって、特殊な起動方法が必要になるわけではありません。

Orderモジュールを作る

まず注文を担当するOrderモジュールを作ります。

order
├── OrderManagement.java
├── OrderCompleted.java
└── web
    └── OrderController.java

ここで重要なのが、

order

直下と、

order.web

の違いです。

Spring Modulithでは基本的にモジュールのルートパッケージが、ほかのモジュールから利用可能なAPIになります。

つまり、

order.OrderManagement
order.OrderCompleted

はOrderモジュールが外部へ公開するAPIとして扱えます。

一方、

order.web
order.internal

などのサブパッケージは、基本的にモジュール内部の実装として扱われます。

OrderManagementを作る

注文処理を行うクラスを作ります。

package com.example.shop.order;

import java.util.UUID;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderManagement {

    private final ApplicationEventPublisher eventPublisher;

    public OrderManagement(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    public UUID placeOrder(String sku, int quantity) {

        UUID orderId = UUID.randomUUID();

        // 本来はRepositoryなどを利用して注文を保存する
        System.out.println(
                "Order created: " + orderId
                        + ", sku=" + sku
                        + ", quantity=" + quantity
        );

        eventPublisher.publishEvent(
                new OrderCompleted(orderId, sku, quantity)
        );

        return orderId;
    }
}

通常のSpringの、

ApplicationEventPublisher

を使っています。

注文が完了すると、

OrderCompleted

イベントを発行します。

OrderCompletedイベント

イベントはJavaのrecordを使ってシンプルに定義します。

package com.example.shop.order;

import java.util.UUID;

public record OrderCompleted(
        UUID orderId,
        String sku,
        int quantity
) {
}

ここで重要なのは配置場所です。

order
└── OrderCompleted

Orderモジュールのルートパッケージに配置しています。

そのため、Inventoryなどの別モジュールから利用できます。

Controllerを作る

HTTP APIも用意します。

package com.example.shop.order.web;

import java.util.UUID;

import com.example.shop.order.OrderManagement;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderManagement orderManagement;

    public OrderController(OrderManagement orderManagement) {
        this.orderManagement = orderManagement;
    }

    @PostMapping
    public OrderResponse create(
            @RequestBody CreateOrderRequest request
    ) {

        UUID orderId = orderManagement.placeOrder(
                request.sku(),
                request.quantity()
        );

        return new OrderResponse(orderId);
    }
}

record CreateOrderRequest(
        String sku,
        int quantity
) {
}

record OrderResponse(
        UUID orderId
) {
}

ここまでで、

POST /orders

から注文を登録できます。

Inventoryモジュールを作る

続いて在庫管理です。

inventory
└── internal
    └── InventoryEventListener.java

Inventoryモジュールは、Orderモジュールから発行されたOrderCompletedを受け取ります。

package com.example.shop.inventory.internal;

import com.example.shop.order.OrderCompleted;

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
class InventoryEventListener {

    @EventListener
    void on(OrderCompleted event) {

        System.out.println(
                "Reserve inventory: sku="
                        + event.sku()
                        + ", quantity="
                        + event.quantity()
        );
    }
}

ここでポイントなのが、

import com.example.shop.order.OrderCompleted;

です。

InventoryモジュールはOrderモジュールの公開APIであるOrderCompletedだけを参照しています。

OrderモジュールのRepositoryや内部Serviceを直接参照していません。

モジュール間の依存が弱くなる

この設計では、

Order
   │
   │ OrderCompleted
   ▼
Inventory

という関係になります。

Order側は、

「注文後にInventoryのどのServiceを呼べばよいか」

を知る必要がありません。

Orderが知っているのは、

注文が完了した

という事実だけです。

そのイベントを誰が処理するのかはOrderから切り離されています。

将来的に、

OrderCompleted
    │
    ├── Inventory
    ├── Mail
    ├── Point
    └── Analytics

のように処理を追加することもできます。

Spring Modulith最大の特徴「依存関係を検証する」

ここからがSpring Modulithのおもしろいところです。

単にパッケージを分けるだけなら、Spring Modulithを使わなくてもできます。

Spring Modulithでは、

本当にそのモジュール構成が守られているか

をテストできます。

次のテストを作ります。

package com.example.shop;

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

class ModulithArchitectureTest {

    @Test
    void verifyModules() {

        ApplicationModules
                .of(ShopApplication.class)
                .verify();
    }
}

これだけです。

.verify();

によってモジュール構造を検証できます。

verify()は何をチェックするのか

代表的には次のような問題を検出できます。

モジュール間の循環依存

たとえば、

Order
  ↓
Inventory
  ↓
Order

という状態です。

OrderがInventoryに依存し、InventoryもOrderに依存していると、アプリケーションの変更が難しくなります。

Spring Modulithでは、このようなモジュール間の循環依存を検出できます。

他モジュールの内部実装へのアクセス

たとえばInventoryに、

inventory
└── internal
    └── InventoryRepository

が存在するとします。

Orderから、

import com.example.shop.inventory.internal.InventoryRepository;

のように直接参照すると、モジュール境界を破っています。

Spring Modulithは、このような依存も検出できます。

つまり、

Order
  ↓
Inventory API

はOKですが、

Order
  ↓
Inventory Internal

はNGです。

Javaだけでは防げない問題を検出できる

ここはSpring Modulithを理解するうえで重要です。

Javaには、

public
private
protected

などのアクセス修飾子があります。

しかし、モジュール内部の事情によって、

public class InventoryRepository

としなければならない場合もあります。

publicにするとJavaコンパイラ上は別パッケージから参照できてしまいます。

そこでSpring Modulithが、

Javaとしてはアクセス可能

しかし

アーキテクチャとしてはアクセス禁止

というルールをテストで検証してくれます。

モジュール一覧を確認する

Spring Modulithがどのようにモジュールを認識しているのか確認することもできます。

@Test
void printModules() {

    ApplicationModules modules =
            ApplicationModules.of(ShopApplication.class);

    modules.forEach(System.out::println);
}

実行すると、

order
inventory

などのモジュール情報を確認できます。

既存プロジェクトへSpring Modulithを導入するときは、まずこれを実行してみると分かりやすいでしょう。

モジュール単位でテストする

Spring Modulithには、

@ApplicationModuleTest

という便利なテスト機能があります。

通常の、

@SpringBootTest

ではアプリケーション全体を起動します。

一方、

@ApplicationModuleTest

では、対象モジュールを中心にテストできます。

Orderモジュールのテストを作ってみます。

package com.example.shop.order;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.UUID;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.PublishedEvents;

@ApplicationModuleTest
class OrderModuleTest {

    @Autowired
    OrderManagement orderManagement;

    @Test
    void publishesOrderCompletedEvent(
            PublishedEvents events
    ) {

        UUID orderId =
                orderManagement.placeOrder(
                        "SKU-001",
                        2
                );

        var published =
                events.ofType(OrderCompleted.class)
                        .matching(
                                OrderCompleted::orderId,
                                orderId
                        );

        assertThat(published).hasSize(1);
    }
}

このテストでは、

注文する
↓
OrderCompletedが発行される

というOrderモジュールの責務を確認しています。

モジュール単位でテストするメリット

アプリケーションが大きくなると、

@SpringBootTest

によるテストは起動するBeanが増えていきます。

さらに、

Orderのテストなのに
Paymentも
Inventoryも
Customerも
起動する

という状態になりがちです。

モジュール単位でテストできれば、

Order Module
だけに注目

できます。

また、あるモジュールが大量の別モジュールへ依存している場合、

「このモジュール、責務を持ちすぎていないか?」

と気付くきっかけにもなります。

モジュール構成図も生成できる

Spring Modulithには、モジュール構成からドキュメントを生成する機能もあります。

たとえば次のようなテストを書けます。

package com.example.shop;

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

class DocumentationTest {

    @Test
    void createDocumentation() {

        ApplicationModules modules =
                ApplicationModules
                        .of(ShopApplication.class)
                        .verify();

        new Documenter(modules)
                .writeModulesAsPlantUml()
                .writeIndividualModulesAsPlantUml();
    }
}

すると、モジュール間の関係を表すPlantUMLなどを生成できます。

イメージとしては、

┌──────────────┐
│    Order     │
└──────┬───────┘
       │ OrderCompleted
       ▼
┌──────────────┐
│  Inventory   │
└──────────────┘

のような構成です。

設計書を手作業で更新するのではなく、実際のコードからアーキテクチャドキュメントを生成できるのは実務でも便利です。

allowedDependenciesでさらに厳しくする

規模が大きくなってきたら、

「このモジュールはどのモジュールに依存してよいか」

まで明示できます。

package-info.javaを利用します。

たとえばInventoryモジュールがOrderだけに依存できるようにする場合です。

@org.springframework.modulith.ApplicationModule(
        allowedDependencies = "order"
)
package com.example.shop.inventory;

これによって、

Inventory
    ↓
Order

は許可されます。

しかし、

Inventory
    ↓
Payment

など、定義されていない依存を作ると検証で検出できます。

大規模なシステムほど効果が大きい機能です。

Named Interfaceとは

さらに細かくAPIを公開したい場合、

@NamedInterface

を利用できます。

通常、

order

のルートパッケージがAPIになります。

しかし、

order
└── api

のような別パッケージを明示的に公開したいケースもあります。

その場合、

@org.springframework.modulith.NamedInterface("api")
package com.example.shop.order.api;

のように定義できます。

そして依存側では、

@org.springframework.modulith.ApplicationModule(
        allowedDependencies = "order::api"
)
package com.example.shop.inventory;

と指定できます。

これによって、

Inventory
↓
Order API

だけを許可し、

Inventory
↓
Order Internal

を禁止できます。

レイヤードアーキテクチャとの違い

従来のSpring Bootでは、

controller
service
repository
entity

という「技術」でパッケージを分けるケースが多いでしょう。

これはレイヤードアーキテクチャとして非常に一般的です。

しかし規模が大きくなると、

service
├── OrderService
├── PaymentService
├── InventoryService
├── CustomerService
├── PointService
├── CouponService
└── MailService

のようになり、1つのパッケージへ大量のクラスが集まってきます。

Spring Modulithでは逆に、

order
├── OrderController
├── OrderService
├── OrderRepository
└── Order

inventory
├── InventoryController
├── InventoryService
├── InventoryRepository
└── Inventory

payment
├── PaymentController
├── PaymentService
├── PaymentRepository
└── Payment

というように業務機能を中心にまとめる考え方になります。

つまり、

技術単位

ではなく、

業務・ドメイン単位

で分割します。

Spring Modulithを使えばDDDになるわけではない

Spring Modulithを調べると、DDD(Domain-Driven Design)という言葉がよく出てきます。

ただし、

Spring Modulithを使う
=
DDDが完成する

というわけではありません。

Spring Modulithが提供してくれるのは、

ドメイン単位で分割しやすくする仕組み

です。

どこまでをOrderとするか、どこからInventoryにするかといった境界は、開発者が業務要件を考えて設計する必要があります。

Spring Modulithが向いているケース

特に向いているのは、

中〜大規模なSpring Bootアプリケーション

機能が増えてService同士の依存が複雑になっている場合です。

将来的にマイクロサービス化する可能性がある

あらかじめ、

Order
Inventory
Payment

という境界を作っておけば、将来サービスを分離するときにも考えやすくなります。

ただし、「Spring Modulithを使えば簡単にマイクロサービス化できる」という意味ではありません。

データベース境界や分散トランザクション、ネットワーク通信など別の問題は残ります。

チーム開発

Team A → Order
Team B → Inventory
Team C → Payment

のように責務を整理しやすくなります。

アーキテクチャのルールを自動テストしたい

Spring Modulithの大きな強みです。

人間のレビューだけでは、

OrderからInventoryのRepositoryを直接呼んでいる

といったルール違反を見逃す可能性があります。

Spring ModulithならCIで、

./gradlew test

を実行するだけでアーキテクチャを継続的に検証できます。

Spring Modulithが向いていないケース

逆に、非常に小さなCRUDアプリなら無理に導入する必要はありません。

たとえば、

Controller
↓
Service
↓
Repository

だけで完結する小規模アプリなら、Spring Modulithを導入してもメリットを感じにくいでしょう。

モジュール境界について考えるコストも発生します。

重要なのは、

Spring Modulithを使うこと

ではなく、

システムの責務と依存関係を整理すること

です。

実務ではイベント連携が重要になる

今回の記事では、

Order
   │
   │ OrderCompleted
   ▼
Inventory

というイベント連携を紹介しました。

この設計を進めると、次の疑問が出てきます。

イベント処理中にアプリが落ちたら?

Inventoryの処理が失敗したら?

イベントをKafkaやSQSへ送りたい場合は?

注文DBへの保存は成功したのにイベント送信が失敗したら?

ここから、

  • Transactional Event
  • Event Publication Registry
  • Transactional Outbox Pattern
  • Kafka
  • Amazon SQS
  • 冪等性

といったバックエンド設計につながっていきます。

Spring Modulithは単なるパッケージ整理ツールではなく、こうしたイベント駆動アーキテクチャを学ぶ入り口としても非常に面白い技術です。

まとめ

Spring Modulithを利用すると、Spring Bootアプリケーションを業務機能単位で整理できます。

今回のポイントをまとめると、

従来

Controller
↓
Service
↓
Repository

だけで考えるのではなく、

Spring Boot
│
├── Order Module
├── Inventory Module
└── Payment Module

という単位でアプリケーションを設計できます。

さらにSpring Modulithでは、

モジュール検出
↓
依存関係検証
↓
モジュール単位のテスト
↓
イベント連携
↓
ドキュメント生成

までサポートされています。

特に重要なのが、

ApplicationModules
        .of(ShopApplication.class)
        .verify();

です。

単に「パッケージを分けました」で終わらず、そのアーキテクチャが守られていることをテストで保証できるのがSpring Modulithの大きなメリットです。

Spring Bootアプリケーションが大きくなり、

Service同士の依存関係が複雑になってきた

マイクロサービスにするほどではないが、アプリケーションを整理したい

という場合は、Spring Modulithを検討してみる価値があります。

そしてSpring Modulithを理解した後におすすめなのが、

Transactional Outbox Pattern

です。

「注文をDBへ保存できたのに、イベントの送信には失敗した」という問題をどのように解決するのか。

この問題を理解すると、Spring Modulithだけでなく、KafkaやAmazon SQSを使ったイベント駆動システムの設計にもつながっていきます。

ぜひご参考ください!

是非フォローしてください

最新の情報をお伝えします

類似投稿