レガシーモノリスから段階的に移行するモジュラーモノリス実践ガイド
この記事でわかること
- レガシーモノリスからモジュラーモノリスへ段階的に移行する5ステップの実践手順
- Event Stormingによる境界付けられたコンテキストの特定方法と具体的なワークショップ進行例
- Java/Spring Modulith・Python/import-linter・Go/go-cleanarchの3言語でのモジュール境界強制の実装方法
- 国内企業(ZOZO・メルカリ・hacomono)の成功と失敗から学ぶ導入判断基準
- モジュラーモノリスを「最終形」として運用する場合と、マイクロサービスへ進化させる場合の判断フレームワーク
対象読者
- 想定読者: レガシーなモノリスの保守・改善を担当するソフトウェアエンジニア
-
必要な前提知識:
- いずれかのWebフレームワーク(Spring Boot / FastAPI / Go net/http等)の基本的な使い方
- Git・CI/CDの基本操作
- 「結合度」「凝集度」の概念(MLでいう「特徴量間の相関」と「特徴量グループの一貫性」に近いイメージ)
結論・成果
CNCF Q1 2026レポートによると、マイクロサービスを採用した組織の**42%**がサービスの一部をより大きなデプロイ単位に統合しており、その受け皿がモジュラーモノリスです。Spring Modulith GitHubスターは9,100(前年比278%増)に達し、Neal FordとSam Newmanは2026年を「モノリスのルネサンス」と表現しています。
本記事で紹介する段階的移行アプローチを適用した国内外の事例では、以下の成果が報告されています。
| 企業・事例 | 指標 | 改善内容 |
|---|---|---|
| Shopify | 開発者オンボーディング時間 | 55%削減 |
| Shopify | クロスモジュール回帰バグ | 前年比68%削減 |
| hacomono | AI開発との親和性 | モジュール限定でLLMコンテキスト効率向上 |
| ZOZO | 開発速度 | 段階的リプレイスで機能リリースと並行 |
モジュラーモノリスの基本概念を理解する
モジュラーモノリスとは、単一のデプロイ単位(1つのプロセス・1つのランタイム)でありながら、内部が明確に定義されたモジュールに分割されたアーキテクチャです。MLエンジニアにとって身近な例えでいえば、1つの学習パイプライン内で「前処理」「特徴量エンジニアリング」「モデル学習」「評価」がそれぞれ独立したモジュールとして分離されている状態に似ています。
モノリス・モジュラーモノリス・マイクロサービスの比較
| 特性 | モノリス | モジュラーモノリス | マイクロサービス |
|---|---|---|---|
| デプロイ単位 | 1つ | 1つ | サービスごと |
| モジュール境界 | なし(暗黙的) | 明示的・強制的 | プロセス境界 |
| 通信方式 | メソッド呼び出し | モジュール内API・イベント | ネットワーク(HTTP/gRPC) |
| データ所有 | 共有DB | モジュールごとのスキーマ | サービスごとのDB |
| 運用コスト | 低 | 低〜中 | 高 |
| チーム規模の目安 | 〜10名 | 〜30名 | 30名〜 |
| デバッグ | スタックトレース1本 | スタックトレース1本 | 分散トレーシング必須 |
なぜマイクロサービスではなくモジュラーモノリスか: マイクロサービスはネットワーク越しの通信、分散トランザクション、サービスディスカバリなどの運用コストが高く、チーム規模やドメイン複雑性が一定レベルに達するまでは費用対効果が低いケースが多いです。Modular Monolith 2026 Complete Guideによると、「10名以下のチームにはマイクロサービスは過剰」と回答した組織が84%に上ります(2024年の61%から上昇)。
注意: モジュラーモノリスは万能ではありません。規制要件によるプロセス分離が必要な場合や、モジュールごとに極端にスケーリング特性が異なる場合は、最初からマイクロサービスを検討すべきです。
Step 1: Event Stormingでドメイン境界を発見する
段階的移行の最初のステップは、現行システムのドメイン境界を発見することです。コードを直接分析する前に、ビジネスプロセスの理解からスタートします。
Event Stormingワークショップの進め方
Event Stormingは、付箋を使ってビジネスプロセスを「イベント(起きたこと)」中心に可視化するワークショップ手法です。MLパイプラインでいえば、「データ取得完了」「前処理完了」「学習開始」「エポック完了」「評価完了」といったイベントを並べて、パイプラインの構造を理解する作業に相当します。
ZOZOTOWNでは、カート・決済システムのリプレイスにあたり、関係者全員でEvent Stormingワークショップを実施し、以下の4ステップで境界付けられたコンテキストを特定しています(ZOZOTOWNカート・決済システムのモジュラモノリス設計)。
ワークショップの4ステップ:
- ドメインイベントの洗い出し: 「商品がカートに追加された」「注文が作成された」「決済が完了した」など、ビジネス上の出来事をオレンジの付箋に書き出す
- 時系列への配置: イベントを時間順に並べ、ビジネスプロセスの流れを可視化する
- アクター・コマンド・エンティティの特定: 各イベントを引き起こす操作(コマンド)、操作者(アクター)、関連するデータ(エンティティ)を追加する
- コンテキスト境界の線引き: 「言語が変わる場所」を探す。同じ「商品」でもカート内の商品と在庫管理の商品では意味が異なる。この言語の違いが境界のヒントになる
境界判定の3つの指標
Event Stormingの結果から、以下の3つの指標でモジュール境界を判定します。
| 指標 | 判定方法 | 例 |
|---|---|---|
| ユビキタス言語の変化 | 同じ単語が異なる意味で使われる場所 | 「商品」がカートでは「カートアイテム」、在庫では「SKU」 |
| 変更頻度の違い | gitログで変更頻度を分析し、一緒に変更されるファイル群を特定 | 決済ロジックは月1回、商品表示は毎週変更 |
| チーム境界 | 異なるチーム・担当者が管理している領域 | 決済チームと商品チームの責任範囲 |
よくある間違い: 最初からクラス単位やファイル単位で分割しようとすると失敗します。まずはビジネスドメイン単位で大きく分け、後から必要に応じて細分化するのが正しいアプローチです。FOSDEM 2026の発表でも「パッケージの肥大化、制御不能な依存、不明確な境界」がモノリスの主要課題として挙げられています。
Step 2: パッケージ構成を再編する
ドメイン境界が明確になったら、コードをモジュール単位のパッケージ構成に再編します。ここでは3つの言語での実装パターンを紹介します。
Java/Spring Bootでの再編
ZOZOTOWNのカート・決済システムでは、以下のようなGradleマルチモジュール構成を採用しています(ZOZO TECH BLOG)。
project-root/
├── modules/
│ ├── cart/ # カートコンテキスト
│ │ ├── domain/ # ドメインモデル・ビジネスルール
│ │ ├── application/ # ユースケース
│ │ ├── infrastructure/ # DB・外部API
│ │ └── api/ # 他モジュールへの公開API
│ ├── order/ # 注文コンテキスト
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── api/
│ ├── payment/ # 決済コンテキスト
│ └── inventory/ # 在庫コンテキスト
├── core/ # 共通基盤(認証・ログ等)
└── orchestrator/ # モジュール間ワークフロー管理
Spring Modulith 1.4では、package-info.javaにアノテーションを記述することで、モジュール間の依存関係を宣言的に定義できます。
// modules/cart/package-info.java
@ApplicationModule(
allowedDependencies = {"core", "inventory::api"}
)
package com.example.shop.cart;
import org.springframework.modulith.ApplicationModule;
この設定により、cartモジュールはcoreとinventoryの公開APIのみ参照可能になります。paymentモジュールの内部クラスを直接importしようとすると、テスト時にエラーが発生します。
// テストコード: モジュール構造の検証
@Test
void verifyModularStructure() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
modules.verify(); // 依存違反があれば例外をスロー
}
Pythonでの再編
Python/FastAPIプロジェクトでは、import-linterを使ってモジュール境界を強制できます(Pythonモジュラモノリスの依存違反検知)。
src/
├── cart/
│ ├── __init__.py
│ ├── domain/
│ │ └── model.py # CartItem, Cart
│ ├── application/
│ │ └── service.py # AddToCartUseCase
│ └── port/
│ └── adapter/
│ └── repository.py # CartRepository
├── order/
│ ├── __init__.py
│ ├── domain/
│ ├── application/
│ └── port/
└── payment/
.importlinter設定ファイルで依存ルールを定義します。
[importlinter]
root_packages =
cart
order
payment
# カートモジュール内のレイヤー依存方向を強制
[importlinter:contract:cart-layers]
name = カートモジュールのヘキサゴナルアーキテクチャ依存方向
type = layers
layers =
port
application
domain
containers =
cart
exhaustive = true
# 注文モジュールからカートの内部実装への直接参照を禁止
[importlinter:contract:order-cannot-access-cart-internals]
name = 注文モジュールはカートの公開APIのみ参照可能
type = forbidden
source_modules =
order
forbidden_modules =
cart.domain
cart.application
cart.port
allow_indirect_imports = True
CIパイプラインに組み込む場合は以下のように実行します。
pip install import-linter
lint-imports --no-cache
# 違反があれば非ゼロの終了コードを返す
Goでの再編
GoではパッケージのexportルールがそのままモジュールAPIとして機能します。大文字始まりの識別子のみが外部パッケージから参照可能という言語仕様を活用します。
internal/
├── cart/
│ ├── api.go # 公開API(大文字始まりの型・関数)
│ ├── service.go # 非公開の内部ロジック
│ ├── repository.go # 非公開のDB操作
│ └── model.go # ドメインモデル
├── order/
│ ├── api.go
│ ├── service.go
│ └── model.go
└── shared/ # 共通ユーティリティ
└── event.go # イベントバス
// internal/cart/api.go
package cart
// AddItemRequest は他モジュールからのカート追加リクエスト(公開)
type AddItemRequest struct {
UserID string
ProductID string
Quantity int
}
// AddItemResult はカート追加の結果(公開)
type AddItemResult struct {
CartID string
TotalItems int
}
// Service はカートモジュールの公開インターフェース
type Service interface {
AddItem(ctx context.Context, req AddItemRequest) (AddItemResult, error)
GetCart(ctx context.Context, userID string) (CartView, error)
}
// internal/cart/service.go
package cart
// serviceImpl は非公開の内部実装
type serviceImpl struct {
repo repository // 非公開: 他モジュールからアクセス不可
}
func NewService(db *sql.DB) Service {
return &serviceImpl{repo: newRepository(db)}
}
func (s *serviceImpl) AddItem(ctx context.Context, req AddItemRequest) (AddItemResult, error) {
cart, err := s.repo.findByUserID(ctx, req.UserID)
if err != nil {
return AddItemResult{}, fmt.Errorf("cart lookup failed: %w", err)
}
cart.addItem(req.ProductID, req.Quantity)
if err := s.repo.save(ctx, cart); err != nil {
return AddItemResult{}, fmt.Errorf("cart save failed: %w", err)
}
return AddItemResult{CartID: cart.id, TotalItems: cart.totalItems()}, nil
}
Goではgo-cleanarchというツールを使ってCIで依存方向を検証できます。
なぜ3言語のパターンを紹介するのか: モジュラーモノリスはアーキテクチャパターンであり、特定のフレームワークに依存しません。チームの技術スタックに合わせて適用できることが重要で、どの言語でも「公開APIの明示」と「内部実装の隠蔽」という原則は共通しています。
Step 3: アーキテクチャ適合度テストで境界を強制する
パッケージを再編しただけでは、時間の経過とともに境界が侵食されます。コードレビューだけに頼ると人的ミスが避けられないため、CIで自動的に境界違反を検出する仕組みが必要です。
ArchUnit/Spring Modulithによる自動検証
ArchUnit 1.3では、以下の5種類のアーキテクチャルールをテストとして実行できます(Modular Monolith 2026 Complete Guide)。
// 1. ヘキサゴナルレイヤーの依存方向を強制
@ArchTest
static final ArchRule hexagonal_layers = layeredArchitecture()
.consideringAllDependencies()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Infrastructure").definedBy("..infrastructure..")
.layer("API").definedBy("..api..")
.whereLayer("Domain").mayNotAccessAnyLayer()
.whereLayer("Application").mayOnlyAccessLayers("Domain")
.whereLayer("Infrastructure").mayOnlyAccessLayers("Application", "Domain")
.whereLayer("API").mayOnlyAccessLayers("Application", "Domain");
// 2. コントローラがリポジトリを直接呼び出すことを禁止
@ArchTest
static final ArchRule controllers_should_not_access_repositories =
noClasses()
.that().resideInAPackage("..controller..")
.should().accessClassesThat()
.resideInAPackage("..repository..");
// 3. 循環依存の検出
@ArchTest
static final ArchRule no_cycles =
slices().matching("com.example.shop.(*)..")
.should().beFreeOfCycles();
Spring Modulith 1.4では、モジュール間通信をイベントベースに置き換えることも推奨しています。@ApplicationModuleListenerアノテーション1つで、@TransactionalEventListener(AFTER_COMMIT) + @Async + トランザクショナルOutboxの機能が実現されます。
// orderモジュール: 注文完了時にイベントを発行
@Service
public class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public Order completeOrder(OrderRequest request) {
Order order = createOrder(request);
events.publishEvent(new OrderCompleted(order.getId(), order.getTotalAmount()));
return order;
}
}
// paymentモジュール: イベントを受信して決済処理
@ApplicationModuleListener
class PaymentEventHandler {
void on(OrderCompleted event) {
processPayment(event.orderId(), event.totalAmount());
}
}
各言語のCI統合まとめ
| 言語 | ツール | CI設定例 | 検出対象 |
|---|---|---|---|
| Java | ArchUnit 1.3 + Spring Modulith 1.4 |
mvn test で自動実行 |
レイヤー違反・循環依存・モジュール境界 |
| Python | import-linter 2.0 | lint-imports --no-cache |
import違反・レイヤー逆転 |
| Go | go-cleanarch | go-cleanarch ./internal/... |
パッケージ間依存方向 |
| Ruby | packwerk 3 | bin/packwerk check |
パッケージ境界・deprecated参照 |
| TypeScript | Nx 20 / turbo 2 | nx lint |
ワークスペース境界 |
制約条件: ArchUnitやimport-linterはコンパイル時・静的解析による境界強制であり、リフレクションや動的プロキシによるアクセスは検出できません。Javaの場合、JPMS(Java Platform Module System)を併用することでランタイムレベルの強制も可能ですが、設定の複雑さが増すトレードオフがあります。
Step 4: データ所有権を分離する
モジュール境界の強制で最も難しいのがデータ層の分離です。レガシーモノリスでは複数のモジュールが同じテーブルを直接参照していることが多く、ここを放置するとモジュール化は形骸化します。
段階的なデータ分離の3段階
Phase 1: 論理的な所有権の宣言(即日実施可能)
まず各テーブルの「所有モジュール」を宣言し、他モジュールからの直接アクセスをモジュールAPI経由に書き換えます。
# 悪い例: 注文モジュールがカートのテーブルを直接参照
class OrderService:
def create_order(self, user_id: str) -> Order:
# cart_itemsテーブルを直接クエリ ← 境界違反
cart_items = db.execute(
"SELECT * FROM cart_items WHERE user_id = :uid", {"uid": user_id}
)
return self._build_order(cart_items)
# 良い例: カートモジュールの公開APIを経由
class OrderService:
def __init__(self, cart_service: CartService):
self._cart = cart_service
def create_order(self, user_id: str) -> Order:
cart = self._cart.get_cart(user_id) # 公開APIを経由
return self._build_order(cart.items)
Phase 2: スキーマレベルの分離
各モジュールに専用スキーマを割り当て、クロススキーマのJOINを禁止します。モジュール間のデータ参照はAPI経由に限定します。
Phase 3: 物理的なDB分離
スケーリング要件やセキュリティ要件で必要な場合のみ実施します。大多数のケースではPhase 2で十分です。
ハマりポイント: Techtouchの事例(Techtouch Tech Blog)では、DB統合はコスト対効果を考慮して延期し、別DB間の分散トランザクションの複雑さが残ったと報告されています。データ分離は無理に進めず、ビジネス上の必要性を慎重に見極めることが重要です。
データ重複の許容
ZOZOTOWNのモジュラーモノリス設計では、モジュール間でValue Object(値オブジェクト)を意図的に重複して定義しています(ZOZO TECH BLOG)。これは将来のマイクロサービス化を見据えた設計判断です。
// cart モジュール内の「商品」表現
public record CartProduct(String productId, String name, int price) {}
// order モジュール内の「商品」表現(別定義)
public record OrderLineItem(String productId, String productName,
int unitPrice, int quantity) {}
MLエンジニアにとっては、学習データと推論データで同じ「ユーザー」を異なるスキーマ(特徴量セット)で管理するのに似た考え方です。共有モデルを避けることで、各モジュールが独立して進化できます。
Step 5: 選択的サービス抽出の判断基準を設計する
モジュラーモノリスは「マイクロサービスへの中間地点」にも「最終形」にもなりえます。From Monolith to Modular Monolith to Microservicesでは、以下の判断基準に基づく選択的抽出を推奨しています。
サービス抽出の判断フレームワーク
抽出すべき場合:
| 条件 | 具体例 |
|---|---|
| スケーリング要件が他と異なる | 画像処理モジュールがCPUバウンド、他はIOバウンド |
| リリースサイクルが独立 | 決済モジュールは月1回、商品表示は毎日デプロイ |
| チーム規模の拡大 | 30名を超え、モジュール専任チームが成立 |
| 規制・コンプライアンス要件 | PCI DSS準拠で決済処理の分離が必要 |
モジュラーモノリスのまま維持すべき場合:
| 条件 | 理由 |
|---|---|
| チーム規模が30名以下 | 分散システムの運用コストに見合わない |
| モジュール間の頻繁なデータ共有 | ネットワーク越しの通信で性能劣化 |
| 統一的なデプロイサイクルで問題がない | 独立デプロイのメリットが薄い |
| 運用基盤(CI/CD・監視)が未整備 | 分散システムの前提条件が不足 |
抽出前チェックリスト
サービス抽出を決定した場合でも、以下の準備が整ってから実施します。
- モジュールのドメイン境界がドキュメント化されている
- 公開APIにテストカバレッジがある
- メトリクス収集とコントラクトテストが導入済み
- フィーチャーフラグとシャドートラフィックの基盤がある
- データ所有権の移行計画とロールバック手順が定義済み
- ランブックとエスカレーション手順が準備済み
抽出の現実的なタイムライン
段階的移行パターンで示されている現実的なタイムラインは以下の通りです。
| 期間 | 内容 |
|---|---|
| 0〜2ヶ月 | ドメイン分析・Event Storming |
| 2〜6ヶ月 | コード再編・境界テスト導入 |
| 6〜8ヶ月 | 最初のサービス抽出(シャドートラフィック検証込み) |
| 9〜12ヶ月 | 追加の選択的抽出 |
| 13ヶ月〜 | 継続的な評価(多くのモジュールはモノリス内に残る) |
トレードオフ: 抽出はモジュールの独立デプロイ・独立スケーリングを可能にしますが、分散トランザクション・ネットワークレイテンシ・運用複雑性というコストが発生します。メルカリの事例(Mercari Engineering Blog)でも、取引ドメインの高いビジネス重要性から「新旧実装の並行運用と差分比較」による慎重な移行が行われています。
国内企業の実践事例から学ぶ教訓
成功パターンと失敗パターン
| 企業 | アプローチ | 成果 | 教訓 |
|---|---|---|---|
| ZOZO | Event Storming→Gradle マルチモジュール | 機能リリースと並行して段階的移行を実現 | orchestratorモジュールの設計がマイクロサービス化の布石に |
| メルカリ | PHP巨大モノリスのDDD分析→API層中心設計 | 新旧並行運用で既存バグも発見・修正 | テスト充実性の事前確保が重要 |
| hacomono | Rails + packwerk | 基盤系機能で疎結合化・AI開発との親和性向上 | コアドメインの未モジュール化が課題として残存 |
| Techtouch | マイクロサービス→モジュラーモノリス→再分割 | 分散モノリス問題の解消 | DB統合は延期してもよい |
hacomonoの2年間の運用レポート(hacomono TECH BLOG)からは、以下の「罪」(失敗パターン)が報告されています。
- 既存コア機能の未モジュール化: 新機能がコアに依存する場合、密結合が再発生
- コードオーナー制度の形骸化: 運用ルールなしに導入したため、変更検知が機能しなかった
- モジュール内ルールの不在: CRUD APIのみという画一的実装が増加し、複雑な機能で使いづらさが発生
- プラットフォームチームの伴走不足: 開発者へのサポートがミッションに含まれず、推進優先度が低下
ポイント: FOSDEM 2026での発表(Modularizing a 10-Year Monolith)では「アーキテクチャは、人々が一緒に支えることに同意して初めて機能する」と強調されています。技術的な境界強制だけでなく、チームの合意形成と継続的なサポートが成功の鍵です。
よくある問題と解決方法
| 問題 | 原因 | 解決方法 |
|---|---|---|
| モジュール間の循環依存が検出される | ドメイン境界が曖昧、責務が混在 | Event Stormingで境界を見直し、イベントベース通信に切り替え |
| モジュールAPIの肥大化 | 内部実装が漏洩している | ファサードパターンで公開APIを最小限に絞る |
| テスト実行時間の増大 | モジュール間の結合テストが増加 | モジュール単位のテスト分割、CI並列実行 |
| データ分離後のパフォーマンス低下 | API経由のデータ取得がJOINより遅い | 読み取り専用のProjection/CQRSパターンの導入 |
| チームメンバーが境界を理解しない | ドキュメント不足、オンボーディング未整備 | ADR(Architecture Decision Records)の記録、モジュールマップの可視化 |
まとめと次のステップ
まとめ:
- モジュラーモノリスへの移行は「Event Storming→パッケージ再編→境界テスト→データ分離→選択的抽出」の5ステップで段階的に進める
- 境界強制ツール(ArchUnit・import-linter・go-cleanarch等)をCIに組み込むことで、人的ミスに依存しないアーキテクチャ維持が可能になる
- マイクロサービスへの抽出は「スケーリング要件」「リリースサイクル」「チーム規模」「規制要件」の4条件で判断し、不要な抽出は避ける
- 国内事例からは、技術的な境界強制だけでなく「チームの合意形成」「コードオーナー運用」「プラットフォームチームの伴走」が成功の鍵であることがわかる
- 2026年時点ではマイクロサービスからの揺り戻しが加速しており、モジュラーモノリスは「中間地点」ではなく「正当な最終形」として選択できる
次にやるべきこと:
- 自チームのモノリスで最も変更頻度の高い領域を
git log --format=format: --name-only | sort | uniq -c | sort -rg | head -30で特定し、Event Stormingの対象ドメインを選定する - 1つのドメインを対象にパッケージ再編と境界テスト(ArchUnit / import-linter)の導入をPoCとして実施する
- Spring Modulith公式ドキュメントまたはチームの技術スタックに対応するツールのREADMEを読み、最小構成で導入する
参考
- The Modular Monolith 2026 Complete Guide — Spring Modulith, ArchUnit Fitness Functions, and Lessons from Shopify
- From Monolith to Modular Monolith to Microservices: Realistic Migration Patterns
- ZOZOTOWNカート・決済システムの大規模リプレイス — モジュラモノリス設計で進めた現実的リプレイス戦略
- メルカリの取引ドメインにおけるモジュラーモノリス化の取り組み
- マイクロサービスからモジュラーモノリスを経て新マイクロサービスへ — Techtouch Developers Blog
- モジュラーモノリス導入がもたらした功罪 — hacomono TECH BLOG
- Pythonで実装されたモジュラモノリスで依存違反を検知する
- Spring Modulith — 公式プロジェクトページ
- FOSDEM 2026 — Modularizing a 10-Year Monolith: The Architecture, the People, and the Pain
- Migrating to Modular Monolith using Spring Modulith and IntelliJ IDEA — JetBrains Blog
注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。