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を使ったイベント駆動システムの設計にもつながっていきます。
ぜひご参考ください!
是非フォローしてください
最新の情報をお伝えします
