1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DDD境界設計とデータ分離で実装するモジュラーモノリス実践ガイド

1
Last updated at Posted at 2026-03-28

DDD境界設計とデータ分離で実装するモジュラーモノリス実践ガイド

マイクロサービスを採用した組織の42%がサービスをより大きなデプロイ単位に統合し直している——2025年のCNCF調査が示すこの数字は、「とりあえずマイクロサービス」の時代の終焉を象徴しています(出典)。しかし、従来の密結合モノリスに戻るわけではありません。いま注目されているのは、DDDの境界付きコンテキストに基づいてモジュールを分離しつつ、単一デプロイの運用効率を維持するモジュラーモノリスというアーキテクチャです。

この記事では、モジュラーモノリスの「設計思想」ではなく「実装パターン」にフォーカスします。モジュール境界の定義方法、モジュール間通信の設計、データ分離戦略、そしてテスト戦略まで、実際のコード例を交えて解説していきます。

この記事でわかること

  • DDDの境界付きコンテキストに基づくモジュール境界の定義方法と、イベントストーミングによる境界発見プロセス
  • モジュール間通信の3つのパターン(Direct Call・Messaging・Outbox)と使い分けの判断基準
  • 4段階のデータ分離戦略(共有テーブル→スキーマ分離→DB分離→Polyglot Persistence)の実装手順
  • Spring Modulithを用いたモジュール境界の自動検証とモジュール単位テストの書き方
  • ZOZO・hacomonoなど日本企業の実導入事例から学ぶ教訓と落とし穴

対象読者

  • 想定読者: モノリスの肥大化に課題を感じている中級者のバックエンド開発者
  • 必要な前提知識:
    • Java / Spring Bootの基本的な使い方(Python/FastAPIからの類推コメントあり)
    • DDDの基本概念(エンティティ、値オブジェクト、集約の理解)
    • RDBMSの基本操作(テーブル設計、SQL)
    • Dockerの基礎知識

結論・成果

モジュラーモノリスの実装において、以下の成果が報告されています。

  • Amazon Prime Videoの事例では、分散マイクロサービスから単一プロセスへの移行でインフラコスト90%削減が報告されている(出典
  • ZOZOTOWNでは、カート・決済システムのリプレイスにモジュラーモノリス設計を採用し、イベントストーミングによるモジュール境界定義とGradleによる依存制御で段階的移行を実現出典
  • 10人以下のチームではマイクロサービスと比較してインフラコストが月額**$15,000 vs $40,000-$65,000**と、約60-75%のコスト差が生じるとの分析もある(出典

DDDの境界付きコンテキストでモジュール境界を定義する

モジュラーモノリスの成否はモジュール境界の設計品質で決まります。技術レイヤー(Controller / Service / Repository)で分割するのではなく、ビジネスドメインの境界に沿って分割することが重要です。MLパイプラインで例えると、「前処理」「学習」「推論」「モニタリング」のように、それぞれが独立した責務を持つ単位でモジュールを切ります。

イベントストーミングでモジュール境界を発見する

ZOZOTOWNのカート・決済システムリプレイスでは、イベントストーミングワークショップを活用してコンテキスト境界を定義しました(出典)。イベントストーミングとは、ドメインイベント(「注文が作成された」「決済が完了した」等)を時系列に並べ、コマンドと集約を特定することでモジュール境界を可視化する手法です。

このプロセスで得られた境界をもとに、パッケージ構造を設計します。

パッケージ構造の設計パターン

モジュラーモノリスのパッケージ構造は「パッケージ・バイ・フィーチャー」が基本です。技術レイヤーではなくビジネス機能でトップレベルを分割します。

// ディレクトリ構成例(Spring Boot)
// Pythonでいうと、各モジュールが独立したパッケージに相当します
//
// src/main/java/com/example/shop/
// ├── order/                    # 注文モジュール(= 境界付きコンテキスト)
// │   ├── OrderApplication.java # モジュール公開API
// │   ├── domain/               # ドメインモデル(外部非公開)
// │   │   ├── Order.java
// │   │   ├── OrderItem.java
// │   │   └── OrderStatus.java
// │   ├── application/          # ユースケース(外部非公開)
// │   │   └── OrderService.java
// │   └── infrastructure/       # DB/外部連携(外部非公開)
// │       └── JpaOrderRepository.java
// ├── payment/                  # 決済モジュール
// │   ├── PaymentApplication.java
// │   ├── domain/
// │   ├── application/
// │   └── infrastructure/
// └── inventory/                # 在庫モジュール
//     ├── InventoryApplication.java
//     ├── domain/
//     ├── application/
//     └── infrastructure/

// --- モジュール公開API(他モジュールはこのインターフェースのみ参照可能)---

// order/OrderApplication.java
// Pythonでいうと __init__.py で公開するクラスに相当
package com.example.shop.order;

public interface OrderApplication {
    OrderDto createOrder(CreateOrderCommand command);
    OrderDto getOrder(String orderId);
    // 内部実装の詳細は非公開
}

// --- ドメインモデル(モジュール外からアクセス不可)---

// order/domain/Order.java
package com.example.shop.order.domain;

// Pythonの dataclass + ビジネスロジックに相当
public class Order {
    private final String id;
    private final List<OrderItem> items;
    private OrderStatus status;

    // ファクトリメソッド: 不正な状態の生成を防ぐ
    public static Order create(String customerId, List<OrderItem> items) {
        if (items.isEmpty()) {
            throw new IllegalArgumentException("注文には最低1つの商品が必要です");
        }
        return new Order(
            UUID.randomUUID().toString(),
            items,
            OrderStatus.CREATED
        );
    }

    // ドメインイベントを返すメソッド
    public OrderCreatedEvent confirm() {
        this.status = OrderStatus.CONFIRMED;
        return new OrderCreatedEvent(this.id, this.totalAmount());
    }

    private BigDecimal totalAmount() {
        return items.stream()
            .map(OrderItem::subtotal)
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

なぜこの構造を選んだか:

  • 技術レイヤー分割の問題: Controller/Service/Repositoryで分割すると、1つの機能変更で3レイヤーすべてを横断的に修正する必要がある。モジュラーモノリスの利点である「独立した変更」が実現できない
  • パッケージ・バイ・フィーチャーの利点: 1つの機能変更はそのモジュール内で完結する。これはMLパイプラインで「前処理の変更が推論に影響しない」のと同じ原理

注意: モジュール内部のサブパッケージ(domain/, application/, infrastructure/)は外部に公開しないでください。Javaではパッケージプライベート(修飾子なし)やモジュールシステム(JPMS)で強制できますが、言語によっては命名規則やビルドツールでの制御が必要です。

Gradleによるモジュール依存関係の強制

ZOZOTOWNの事例では、build.gradle で依存関係を厳密に制御し、意図しないモジュール間依存を防いでいます(出典)。

// settings.gradle
// Pythonでいうと pyproject.toml の依存関係定義に相当
rootProject.name = 'modular-shop'

include 'modules:order'
include 'modules:payment'
include 'modules:inventory'
include 'modules:shared-kernel'  // 共有ドメイン概念

// modules/order/build.gradle
dependencies {
    // 共有カーネルのみ依存可能
    implementation project(':modules:shared-kernel')

    // ❌ paymentモジュールへの直接依存は禁止
    // implementation project(':modules:payment')  // コンパイルエラーになる

    // Spring Modulithによるイベント駆動通信を使う
    implementation 'org.springframework.modulith:spring-modulith-events-api'
}

// modules/payment/build.gradle
dependencies {
    implementation project(':modules:shared-kernel')
    implementation 'org.springframework.modulith:spring-modulith-events-api'
}

ハマりポイント: shared-kernel(共有カーネル)を安易に拡大すると、全モジュールが暗黙的に結合します。ZOZOTOWNの事例でも「同じ構造のValue Objectが複数モジュールに重複定義される」という課題が報告されていますが、これはモジュール間の独立性を維持するためのトレードオフです。共有カーネルには、本当に全モジュールで共通のドメイン概念(通貨型、日時型など)のみを配置してください。

モジュール間通信パターンを実装する

モジュール境界を定義したら、次はモジュール間の通信方法を設計します。Kamil Grzybek氏の分析によると、Direct Call(同期)とMessaging(非同期)の併用がベストプラクティスとされています(出典)。

3つの通信パターンの比較

パターン 結合度 実装複雑度 データ即時性 適用場面
Direct Call(同期) 高(即時) 読み取り系クエリ、強い一貫性が必要な操作
Messaging(非同期) 中(結果整合) 副作用を伴う書き込み、クロスモジュールワークフロー
Outbox + Messaging 中(結果整合) 信頼性が求められるイベント配信

Direct Call: インターフェースを介した同期通信

読み取り系の操作や、即時のレスポンスが必要な場面ではDirect Callが適しています。重要なのは、呼び出し先モジュールの公開インターフェースのみを参照し、内部実装には依存しないことです。

// --- 反腐敗層(Anti-Corruption Layer)の実装 ---
// Python で例えると、adapter パターンのラッパー関数に相当

// payment/application/PaymentService.java
package com.example.shop.payment.application;

@Service
public class PaymentService {

    // 注文モジュールの公開APIのみ参照(内部実装には依存しない)
    private final OrderApplication orderApplication;

    public PaymentService(OrderApplication orderApplication) {
        this.orderApplication = orderApplication;
    }

    public PaymentResult processPayment(String orderId) {
        // Direct Call: 注文情報を同期的に取得
        OrderDto order = orderApplication.getOrder(orderId);

        // 決済モジュール内のドメイン概念に変換(反腐敗層)
        // → 注文モジュールのデータ構造に依存しない
        PaymentAmount amount = PaymentAmount.from(order.totalAmount());

        return executePayment(amount);
    }
}

Messaging: Spring Modulithのイベント駆動通信

書き込み系の操作や、副作用を伴うクロスモジュールのワークフローには、イベント駆動の非同期通信が適しています。Spring Modulithを使うと、アプリケーションイベントの発行・購読が簡潔に実装できます。

// --- ドメインイベントの定義 ---
// Pythonの dataclass に相当するレコード型

// order/domain/OrderCreatedEvent.java
package com.example.shop.order.domain;

// 統合イベント: モジュール間の公開契約
// 必要最小限の情報のみ含める(内部のドメインモデルは露出しない)
public record OrderCreatedEvent(
    String orderId,
    BigDecimal totalAmount,
    Instant occurredAt
) {
    public OrderCreatedEvent(String orderId, BigDecimal totalAmount) {
        this(orderId, totalAmount, Instant.now());
    }
}

// --- イベント発行側(注文モジュール)---

// order/application/OrderService.java
package com.example.shop.order.application;

@Service
@Transactional
public class OrderService implements OrderApplication {

    private final OrderRepository orderRepository;
    // Spring の ApplicationEventPublisher を使用
    private final ApplicationEventPublisher eventPublisher;

    @Override
    public OrderDto createOrder(CreateOrderCommand command) {
        // 1. ドメインロジックの実行
        Order order = Order.create(command.customerId(), command.items());
        orderRepository.save(order);

        // 2. ドメインイベントを発行
        // → 決済モジュール・在庫モジュールが非同期で反応
        eventPublisher.publishEvent(
            new OrderCreatedEvent(order.getId(), order.totalAmount())
        );

        return OrderDto.from(order);
    }
}

// --- イベント購読側(決済モジュール)---

// payment/application/PaymentEventHandler.java
package com.example.shop.payment.application;

@Service
public class PaymentEventHandler {

    private final PaymentProcessor paymentProcessor;

    // Spring Modulith: @ApplicationModuleListener でモジュール間イベントを購読
    // → @EventListener + @Async + @Transactional の組み合わせに相当
    @ApplicationModuleListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // 決済モジュールのドメイン概念に変換して処理
        paymentProcessor.initiatePayment(
            event.orderId(),
            PaymentAmount.from(event.totalAmount())
        );
    }
}

Outboxパターン: 信頼性のあるイベント配信

イベント発行とDBトランザクションの間に不整合が生じるリスクがあります。たとえば、注文がDBに保存された後、イベント発行前にアプリケーションがクラッシュすると、決済モジュールに通知が届きません。

Spring Modulith 1.3以降では、トランザクショナルOutboxパターンが自動実装されています(出典)。イベントはDB内のEVENT_PUBLICATIONテーブルに同一トランザクションで保存され、バックグラウンドワーカーが非同期で配信します。

# Spring Modulith の Outbox パターン設定
# application.properties

# イベント発行の永続化を有効化(Outboxテーブルに自動保存)
spring.modulith.events.jdbc.schema-initialization.enabled=true

# 未配信イベントの再送間隔(デフォルト: 1分)
spring.modulith.republish-outstanding-events-on-restart=true
// Outbox パターン適用後のイベントハンドラ
// → フレームワークが At-Least-Once 配信を保証

@ApplicationModuleListener
public void onOrderCreated(OrderCreatedEvent event) {
    // このハンドラが例外を投げた場合:
    //   1. イベントは EVENT_PUBLICATION テーブルに残る
    //   2. バックグラウンドワーカーが再配信する
    //   3. At-Least-Once のセマンティクスが保証される
    //
    // 注意: 冪等性(idempotency)の担保は開発者の責務
    paymentProcessor.initiatePayment(
        event.orderId(),
        PaymentAmount.from(event.totalAmount())
    );
}

注意: Outboxパターンは「At-Least-Once(最低1回)」配信を保証しますが、「Exactly-Once(正確に1回)」ではありません。イベントハンドラは冪等に実装する必要があります。たとえば、決済処理では注文IDで重複チェックを行うなどの対策が必要です。これはMLパイプラインの再実行時に重複学習を防ぐのと同じ考え方です。

4段階のデータ分離戦略を選定する

モジュール間のデータ分離は、モジュラーモノリスの独立性を維持するために不可欠です。Milan Jovanovic氏は4段階のデータ分離レベルを提唱しています(出典)。

データ分離の3つのルール

どのレベルを選んでも、以下の3つのルールは守る必要があります。

  1. 各モジュールは自身のテーブルのみアクセスできる
  2. モジュール間でテーブルを共有しない
  3. JOIN は同一モジュール内のテーブル間でのみ許可

4レベルの比較

レベル 方式 分離度 運用コスト 推奨チーム規模
1 個別テーブル(同一スキーマ) 5人以下
2 スキーマ分離 低〜中 5-30人
3 データベース分離 中〜高 30人以上
4 Polyglot Persistence 非常に高 特定要件あり

レベル2: スキーマ分離の実装(推奨)

Milan Jovanovic氏は「モジュラーモノリスを構築する際は、常にスキーマ分離から始める」と述べています(出典)。スキーマ分離は、分離度と管理コストのバランスが取れた、多くのプロジェクトに適したアプローチです。

-- PostgreSQL でのスキーマ分離例
-- Python の SQLAlchemy でも schema パラメータで同様の設定が可能

-- 各モジュール用のスキーマを作成
CREATE SCHEMA orders;    -- 注文モジュール専用
CREATE SCHEMA payments;  -- 決済モジュール専用
CREATE SCHEMA inventory; -- 在庫モジュール専用

-- 注文モジュールのテーブル
CREATE TABLE orders.orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id UUID NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
    total_amount DECIMAL(12,2) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE TABLE orders.order_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL REFERENCES orders.orders(id),
    product_id UUID NOT NULL,  -- 在庫モジュールのIDを参照するが、FK制約は設けない
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2) NOT NULL
);

-- 決済モジュールのテーブル
CREATE TABLE payments.payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID NOT NULL,  -- orders.orders への FK制約は設けない(モジュール境界)
    amount DECIMAL(12,2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    idempotency_key VARCHAR(64) UNIQUE NOT NULL,  -- 冪等性キー
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- ❌ アンチパターン: モジュール間の外部キー制約
-- ALTER TABLE payments.payments
--     ADD FOREIGN KEY (order_id) REFERENCES orders.orders(id);
-- → これをやるとモジュール間が密結合になり、独立したスキーマ変更が不可能になる
// Spring Boot での複数スキーマ設定
// 各モジュールが独自の EntityManager を持つ

// order/infrastructure/OrderJpaConfig.java
@Configuration
@EnableJpaRepositories(
    basePackages = "com.example.shop.order.infrastructure",
    entityManagerFactoryRef = "orderEntityManagerFactory"
)
public class OrderJpaConfig {

    @Bean
    public LocalContainerEntityManagerFactoryBean orderEntityManagerFactory(
            DataSource dataSource) {
        LocalContainerEntityManagerFactoryBean em =
            new LocalContainerEntityManagerFactoryBean();
        em.setDataSource(dataSource);
        em.setPackagesToScan("com.example.shop.order.domain");

        // スキーマを指定
        Map<String, Object> properties = new HashMap<>();
        properties.put("hibernate.default_schema", "orders");
        em.setJpaPropertyMap(properties);

        return em;
    }
}

よくある間違い: 最初は「同じデータベースだから外部キー制約を設ければ安全」と考えがちですが、モジュール間にFK制約を設けると、スキーマ変更時に他モジュールとの調整が必要になり、独立したデプロイ・マイグレーションが不可能になります。代わりに、アプリケーションレベルでの整合性チェック(イベント駆動の結果整合性)を採用してください。

レベル3以上への段階的移行

将来的にマイクロサービスへ移行する可能性がある場合、レベル2から始めてレベル3(DB分離)へ段階的に移行できます。スキーマ分離の段階でモジュール間FK制約を排除しておけば、スキーマをそのまま別データベースに移植するだけで済みます。

制約条件: レベル3以上では、モジュール間のJOINクエリが使えなくなります。「注文一覧に決済状況を表示する」といった画面要件がある場合、CQRS(Command Query Responsibility Segregation)パターンで読み取り専用のプロジェクションを作成するか、API Compositionパターンで各モジュールのデータを結合する設計が必要です。

Spring Modulithでモジュール境界を検証・テストする

モジュール境界を「コードで強制」することは、モジュラーモノリスの品質維持に不可欠です。Spring Modulith(2026年3月時点の最新バージョン: 1.3.x)は、モジュール境界の自動検証とモジュール単位テストの機能を提供しています(出典)。

モジュール境界の自動検証

// アーキテクチャテスト: モジュール間の不正な依存を検出
// Pythonで例えると、importlinter のようなツールに相当

// test/ModularArchitectureTest.java
@Test
void verifyModularStructure() {
    // プロジェクト内の全モジュールを解析
    ApplicationModules modules = ApplicationModules.of(ShopApplication.class);

    // 以下を自動検証:
    // 1. 循環依存がないこと
    // 2. モジュール内部パッケージへの外部アクセスがないこと
    // 3. 許可リスト外のモジュール参照がないこと
    modules.verify();

    // モジュール構造をドキュメントとして出力(PlantUML/Asciidoc形式)
    new Documenter(modules).writeDocumentation();
}

このテストが失敗する典型的なケースは以下の通りです。

検出内容 対処法
循環依存 A→B→A イベント駆動に変更して一方向にする
内部パッケージアクセス payment → order.domain.Order 公開API(OrderApplication)経由にする
未宣言の依存 暗黙的なBean注入 依存をGradleに明示するかイベント化

モジュール単位テスト(@ApplicationModuleTest

// モジュール単位テスト: 他モジュールを読み込まずにテスト実行
// Pythonの pytest で特定パッケージのみをテスト対象にするのと同じ考え方

// order/OrderModuleIntegrationTest.java
@ApplicationModuleTest  // 注文モジュールのみをブートストラップ
class OrderModuleIntegrationTest {

    @Autowired
    private OrderApplication orderApplication;

    @Autowired
    private AssertablePublishedEvents events;  // 発行されたイベントを検証

    @Test
    void 注文作成時にOrderCreatedEventが発行される() {
        // Given
        CreateOrderCommand command = new CreateOrderCommand(
            "customer-1",
            List.of(new OrderItemDto("product-1", 2, new BigDecimal("1500")))
        );

        // When
        OrderDto result = orderApplication.createOrder(command);

        // Then
        assertThat(result.status()).isEqualTo("CREATED");

        // イベント発行の検証
        // → 決済モジュールが正しく反応するための契約テスト
        events.assertThat()
            .contains(OrderCreatedEvent.class)
            .matching(e -> e.orderId().equals(result.id()));
    }
}

トレードオフ: Spring ModulithのモジュールテストはSTANDALONEモード(対象モジュールのみ読み込み)が高速ですが、モジュール間のイベント連携は検証できません。エンドツーエンドのフロー検証にはALL_DEPENDENCIESモードか、統合テストが別途必要です。

他言語・フレームワークでの代替手段

Spring Modulithは Java/Spring Boot 固有ですが、同様のモジュール境界検証は他の技術スタックでも実現できます。

言語/FW ツール 機能
Ruby on Rails packwerk パッケージ間の依存関係チェック
Go Goパッケージ + internal/ 言語レベルでのアクセス制御
TypeScript Nx / ESLint boundaries モジュール間importルール
Rust Cargoワークスペース + pub/pub(crate) コンパイル時の可視性制御
Python import-linter レイヤー間import制約

hacomonoの事例では、RailsアプリケーションにShopifyのpackwerkを導入し、20モジュールの境界管理を実現しています。ただし、「コアモジュールのモジュール化が遅れている」「モジュール粒度の見直しが困難」といった課題も報告されています(出典)。

よくある問題と解決方法

問題 原因 解決方法
shared-kernelが肥大化する 「共通」と判断する基準が曖昧 通貨型・日時型など本当にユビキタスな概念のみに限定。Value Objectの重複はトレードオフとして許容
モジュール間のJOINクエリが必要 画面表示で複数モジュールのデータを結合 CQRSパターンで読み取り専用プロジェクションを作成、またはAPI Composition
イベントハンドラの冪等性が保証できない Outboxパターンの再送メカニズム 冪等性キー(idempotency_key)をDB制約で管理。処理済みイベントIDをInboxテーブルで追跡
テスト実行が遅い 全モジュールをブートストラップ Spring Modulithの@ApplicationModuleTest(STANDALONE)で対象モジュールのみ起動
モジュール境界を跨ぐトランザクション 「注文作成+在庫引当」を1トランザクションにしたい Sagaパターンで補償トランザクションを実装。モジュール境界を跨ぐトランザクションは設計上避ける
既存モノリスからの移行が困難 コード全体が密結合 Strangler Figパターンで1モジュールずつ段階的に分離。ZOZOTOWNの事例が参考になる

まとめと次のステップ

まとめ:

  • モジュール境界はDDDの境界付きコンテキストに基づいて定義し、イベントストーミングで発見する。技術レイヤーではなくビジネス機能で分割することが成否を分ける
  • モジュール間通信はDirect Call(同期・読み取り系)とMessaging(非同期・書き込み系)を併用する。信頼性が必要な場面ではOutboxパターンを適用する
  • データ分離はスキーマ分離(レベル2)から始めるのが推奨。モジュール間のFK制約は設けず、アプリケーションレベルの結果整合性で管理する
  • Spring Modulith、packwerk、import-linterなどのツールでモジュール境界を自動検証し、境界違反をCIで検出する
  • ZOZO・hacomonoなどの日本企業事例から、「完璧な設計より段階的な導入」が成功の鍵であることが確認されている

次にやるべきこと:

  • 既存のモノリスに対してイベントストーミングを実施し、境界付きコンテキストを可視化する
  • 1つの明確なモジュール(Webhook通知やメール送信など、責務が明確なもの)から分離を開始する
  • Spring ModulithのApplicationModules.verify()をCIパイプラインに組み込み、境界違反を継続的に検出する

参考


注意: この記事はAI(Claude Code)により自動生成されました。内容の正確性については複数の情報源で検証していますが、実際の利用時は公式ドキュメントもご確認ください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?