Skip to content
Published on

プラットフォームアーキテクチャ意思決定ガイド:モノリスとマイクロサービスの間

Authors
プラットフォームアーキテクチャ意思決定ガイド:モノリスとマイクロサービスの間

アーキテクチャの決定はなぜ難しいのか

「マイクロサービスに移行すべきか、モノリスを維持すべきか?」

この質問は技術的な質問に見えるが、実際には組織構造、チーム能力、ビジネスステージ、運用成熟度をすべて含む経営判断だ。技術的にマイクロサービスが「より良い」アーキテクチャだとしても、3人で構成された初期スタートアップが12個のサービスに分離すると、開発速度はかえって遅くなり運用コストが爆発する。

Martin Fowlerはこれを「Microservice Premium」と呼んだ。マイクロサービスは一定規模以上では生産性がモノリスを上回るが、それ以下では分散システムの複雑さが純粋なコストとして作用する。

この記事ではアーキテクチャ選択を二者択一ではなくスペクトラムとして捉え、自分の状況に合った位置を見つける意思決定フレームワークを提示する。

アーキテクチャスペクトラム:二項対立を超えて

実務での選択肢は「モノリス vs マイクロサービス」の二項対立ではない。その間にいくつかの段階がある。

[モノリス] -----> [モジュラーモノリス] -----> [マクロサービス] -----> [マイクロサービス]

  単一デプロイ単位      モジュール境界分離        2-5個の大きなサービス      ドメイン別独立サービス
  単一DB               モジュール別スキーマ分離   サービス別DB               サービス別DB
  チーム1個             チーム1-2個              チーム2-5個               チーム5個以上
  運用シンプル           運用シンプル             運用普通                   運用複雑

モノリス:すべてのコードが1つのデプロイ単位。内部モジュール間の関数呼び出し。1つのDBトランザクションで整合性を保証。

モジュラーモノリス:デプロイは1つだが、内部的にモジュール(パッケージ)境界が明確に分離されている。モジュール間通信は明示的なインターフェースを通じてのみ可能で、直接的なDBテーブル参照を禁止する。後で分離が必要になればモジュール単位で抽出する。

マクロサービス:2-5個の大きなサービスに分ける。「注文/決済サービス」と「ユーザー/認証サービス」のように大きなドメイン単位で分離。マイクロサービスの複雑さなしに独立デプロイの利点を得る。

マイクロサービス:ドメイン概念1つが1つのサービス。数十から数百のサービス。各サービスが独立デプロイ、独立DB、独立スケーリング。

意思決定マトリクス

各軸について自分の状況をスコアリングすると適切な位置が見える。

評価軸モノリス(1点)モジュラーモノリス(2点)マクロサービス(3点)マイクロサービス(4点)
チーム規模1-5名5-15名15-40名40名以上
デプロイ頻度要件週1-2回日1-2回日3-10回日10回以上またはサービス別独立
ドメイン変更頻度高い(境界が頻繁に変動)中程度低い(境界が安定)非常に低い
運用能力サーバー1-2台運用CI/CDパイプライン運用コンテナオーケストレーションサービスメッシュ、分散トレーシング
整合性要件強い整合性必須モジュール間eventual OK結果整合性結果整合性
スケーラビリティ要件垂直スケーリングで十分垂直+一部水平サービス別水平スケーリングサービス別独立スケーリング必須

合計スコアの解釈

  • 6-10点:モノリスまたはモジュラーモノリス
  • 11-16点:モジュラーモノリスまたはマクロサービス
  • 17-24点:マクロサービスまたはマイクロサービス

モジュラーモノリス:実務で最も見過ごされている選択肢

モジュラーモノリスは「モノリスの運用シンプルさ」と「マイクロサービスのモジュール独立性」を組み合わせる。特にチームが5-15名で、ドメイン境界がまだ確定していない場合に最適だ。

核心原則は3つだ。

  1. モジュール間の直接DB参照禁止:他モジュールのテーブルを直接JOINしない。
  2. 明示的インターフェースによる通信:モジュール間呼び出しはpublic API(Pythonならfacadeクラス、JavaならInterface)を通じてのみ行う。
  3. モジュール別スキーマ分離:同一DBサーバー内でスキーマを分離し、物理的分離なしに論理的分離を確保する。
"""
モジュラーモノリスのモジュール間通信例。

orderモジュールがpaymentモジュールにアクセスする際、
paymentの内部実装ではなくpublic facadeを通じる。
"""

# === payment/facade.py (paymentモジュールの公開インターフェース) ===
from dataclasses import dataclass
from typing import Optional


@dataclass
class PaymentResult:
    reservation_id: str
    status: str
    amount: int
    currency: str


class PaymentFacade:
    """Paymentモジュールの公開インターフェース。

    他のモジュールはこのクラスを通じてのみpayment機能にアクセスする。
    内部実装(repository、service、domain model)への直接アクセスは禁止。
    """

    def __init__(self, payment_service):
        self._service = payment_service

    def reserve_payment(
        self,
        customer_id: str,
        amount: int,
        currency: str = "KRW",
        idempotency_key: Optional[str] = None,
    ) -> PaymentResult:
        """決済を予約する。実際の課金はconfirm時に発生。"""
        reservation = self._service.create_reservation(
            customer_id=customer_id,
            amount=amount,
            currency=currency,
            idempotency_key=idempotency_key,
        )
        return PaymentResult(
            reservation_id=reservation.id,
            status=reservation.status.value,
            amount=reservation.amount,
            currency=reservation.currency,
        )

    def confirm_payment(self, reservation_id: str) -> PaymentResult:
        """予約された決済を確定する。"""
        result = self._service.confirm(reservation_id)
        return PaymentResult(
            reservation_id=result.id,
            status=result.status.value,
            amount=result.amount,
            currency=result.currency,
        )

    def cancel_payment(self, reservation_id: str) -> None:
        """予約された決済をキャンセルする。"""
        self._service.cancel(reservation_id)


# === order/service.py (orderモジュールでpayment facade使用) ===

class OrderService:
    """注文サービス。

    paymentモジュールの内部実装に依存せず、
    PaymentFacadeを通じてのみ決済機能にアクセスする。
    """

    def __init__(self, order_repo, payment_facade: PaymentFacade):
        self.order_repo = order_repo
        self.payment = payment_facade

    def create_order(self, customer_id: str, items: list, total: int) -> dict:
        # 1. 注文作成
        order = self.order_repo.create(
            customer_id=customer_id,
            items=items,
            total=total,
        )

        # 2. 決済予約(payment facadeを通じて)
        try:
            payment_result = self.payment.reserve_payment(
                customer_id=customer_id,
                amount=total,
                idempotency_key=f"order-{order.id}",
            )
            order.payment_reservation_id = payment_result.reservation_id
            self.order_repo.save(order)
        except Exception as e:
            order.status = "payment_failed"
            self.order_repo.save(order)
            raise

        return {"order_id": order.id, "status": order.status}

モジュール境界違反検出の自動化

モジュラーモノリスの最大のリスクは、時間の経過とともにモジュール境界が崩壊することだ。「急ぎだから直接importしよう」が繰り返されると、再びビッグボールオブマッド(big ball of mud)に戻る。これをCIで自動検出する必要がある。

"""
モジュール境界違反検出スクリプト。

各モジュールは他モジュールのfacadeのみimportできる。
内部パッケージ(service、repository、domain)を直接importすると違反。
"""
import ast
import sys
from pathlib import Path
from dataclasses import dataclass


@dataclass
class Violation:
    file: str
    line: int
    importing_module: str
    imported_module: str
    imported_path: str
    reason: str


# モジュール一覧と許可された公開パッケージ
MODULE_CONFIG = {
    "order": {"public": ["order.facade"]},
    "payment": {"public": ["payment.facade"]},
    "inventory": {"public": ["inventory.facade"]},
    "shipping": {"public": ["shipping.facade"]},
    "user": {"public": ["user.facade"]},
}


def get_module_name(file_path: str) -> str | None:
    """ファイルパスからモジュール名を抽出する。"""
    parts = Path(file_path).parts
    for module_name in MODULE_CONFIG:
        if module_name in parts:
            return module_name
    return None


def check_file(file_path: str) -> list[Violation]:
    """単一ファイルのimportを検査し違反リストを返す。"""
    violations = []
    source_module = get_module_name(file_path)
    if source_module is None:
        return violations

    with open(file_path) as f:
        try:
            tree = ast.parse(f.read())
        except SyntaxError:
            return violations

    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            for alias in node.names:
                _check_import(file_path, source_module, alias.name, node.lineno, violations)
        elif isinstance(node, ast.ImportFrom) and node.module:
            _check_import(file_path, source_module, node.module, node.lineno, violations)

    return violations


def _check_import(
    file_path: str,
    source_module: str,
    imported_path: str,
    line: int,
    violations: list[Violation],
):
    """個別のimport文がモジュール境界に違反しているかチェックする。"""
    for target_module, config in MODULE_CONFIG.items():
        if target_module == source_module:
            continue  # 同一モジュール内のimportはOK

        if imported_path.startswith(target_module + "."):
            # 他モジュールをimportしている -> publicパッケージか確認
            is_public = any(
                imported_path.startswith(pub)
                for pub in config["public"]
            )
            if not is_public:
                violations.append(Violation(
                    file=file_path,
                    line=line,
                    importing_module=source_module,
                    imported_module=target_module,
                    imported_path=imported_path,
                    reason=f"Direct import of '{target_module}' internals. "
                           f"Use {config['public']} instead.",
                ))


def main():
    """プロジェクト全ファイルを検査し違反を報告する。"""
    project_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(".")
    all_violations = []

    for py_file in project_root.rglob("*.py"):
        violations = check_file(str(py_file))
        all_violations.extend(violations)

    if all_violations:
        print(f"\n{'='*60}")
        print(f"Module boundary violations found: {len(all_violations)}")
        print(f"{'='*60}\n")
        for v in all_violations:
            print(f"  {v.file}:{v.line}")
            print(f"    {v.importing_module} -> {v.imported_module} ({v.imported_path})")
            print(f"    {v.reason}\n")
        sys.exit(1)
    else:
        print("No module boundary violations found.")
        sys.exit(0)


if __name__ == "__main__":
    main()

このスクリプトをCIに追加すれば、マージ前にモジュール境界違反がブロックされる。

# .github/workflows/boundary-check.yml
name: Module Boundary Check
on: [pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - run: python scripts/check_module_boundaries.py src/

Architecture Decision Record(ADR)の書き方

アーキテクチャの決定は必ず記録しなければならない。6ヶ月後に「なぜこうしたのか?」と聞かれた時に答えられる必要がある。Michael Nygardが提案したADR形式をベースにチームに合わせて調整する。

# ADR-007: 決済モジュールを独立サービスとして分離

## ステータス: 承認済み (2026-02-15)

## コンテキスト

現在、決済モジュールはモノリス内部でfacadeを通じて呼び出されている。
PCI-DSS認証範囲を縮小するため、決済処理を別サービスに分離する必要がある。
また、決済サービスは注文サービスとは独立してスケーリングする必要がある
(ブラックフライデー期間中、決済トラフィックが10倍に増加)。

## 決定

決済モジュールを独立したgRPCサービスとして分離する。

- 通信: gRPC(内部)、REST(外部PG連携)
- DB: 別のPostgreSQLインスタンス
- 整合性: Sagaパターン(orchestration方式)
- デプロイ: 独立したKubernetes deployment

## 結果

### ポジティブ

- PCI-DSS認証範囲が決済サービスに限定される
- 決済サービスの独立スケーリングが可能
- 決済ロジック変更時に注文サービスの再デプロイ不要

### ネガティブ

- サービス間通信レイテンシの追加(約5ms)
- Saga補償トランザクションの実装と運用が必要
- 運用複雑度の増加:別のモニタリング、デプロイパイプライン

### 数値根拠

- 現在の決済p99レイテンシ: 150ms -> 分離後予想: 155ms(許容範囲)
- 現在のPCI認証範囲: インフラ全体 -> 分離後: 決済サービスのみ
- インフラコスト増加: 月額$200(別DB+サービスインスタンス)

## 検討した代替案

1. モノリス維持 + PCI範囲全体認証: コストと監査負担が大きい
2. サーバーレス(Lambda)で決済分離: コールドスタート問題によりp99レイテンシが不確実

マイグレーション戦略:段階的分離

モノリスからマイクロサービスへの移行はビッグバンではなく段階的に実行すべきだ。Strangler Figパターンに基づくステップバイステップ戦略を示す。

Phase 1: モジュラーモノリス化 (1-3ヶ月)
  - コード内のモジュール境界確立
  - facadeインターフェース導入
  - 境界違反検出CI構築
  - DBスキーマをモジュール別に論理分離

Phase 2: 最初のサービス抽出 (2-4ヶ月)
  - 最も独立したモジュール1つをサービスとして分離
  - 通信プロトコル決定(gRPC/REST/イベント)
  - Sagaまたはイベントベースの整合性実装
  - A/Bルーティングで新サービスと既存モジュールの並行運用

Phase 3: 安定化と拡張 (3-6ヶ月)
  - 最初のサービスの運用安定性確認
  - モニタリング、アラート、ランブック整備
  - 次の分離対象選定と繰り返し

Phase 4: プラットフォーム成熟 (継続的)
  - サービスメッシュ / APIゲートウェイ導入
  - 分散トレーシング体系構築
  - サービスカタログとオーナーシップ管理

各フェーズでの重要なゲート条件は以下の通り。

"""
マイグレーション段階の進行可否を判断するゲートチェック。

各phase完了後、次のphaseに進む前に
このチェックを通過する必要がある。
"""
from dataclasses import dataclass


@dataclass
class PhaseGateCheck:
    name: str
    passed: bool
    detail: str


def check_phase1_gate() -> list[PhaseGateCheck]:
    """Phase 1完了ゲート:モジュラーモノリス化が十分に行われたか。"""
    return [
        PhaseGateCheck(
            name="module_boundaries_defined",
            passed=True,  # 実際にはコード分析結果を確認
            detail="All modules have facade interfaces",
        ),
        PhaseGateCheck(
            name="no_boundary_violations",
            passed=True,  # CIで違反0件確認
            detail="0 boundary violations in last 30 days",
        ),
        PhaseGateCheck(
            name="schema_separated",
            passed=True,  # DBスキーマ分離確認
            detail="Each module uses its own schema prefix",
        ),
        PhaseGateCheck(
            name="integration_tests_exist",
            passed=True,
            detail="Module integration tests cover 85%+ of facade methods",
        ),
    ]


def check_phase2_gate() -> list[PhaseGateCheck]:
    """Phase 2完了ゲート:最初のサービス抽出が安定しているか。"""
    return [
        PhaseGateCheck(
            name="service_p99_latency",
            passed=True,   # 実際のメトリクスに基づく判断
            detail="p99 latency < 200ms for 14 consecutive days",
        ),
        PhaseGateCheck(
            name="error_rate",
            passed=True,
            detail="Error rate < 0.1% for 14 consecutive days",
        ),
        PhaseGateCheck(
            name="saga_compensation_tested",
            passed=True,
            detail="Compensation scenarios tested in staging 3+ times",
        ),
        PhaseGateCheck(
            name="runbook_documented",
            passed=True,
            detail="Incident runbook reviewed by on-call team",
        ),
        PhaseGateCheck(
            name="rollback_verified",
            passed=True,
            detail="Rollback to monolith path verified in staging",
        ),
    ]


def evaluate_gate(checks: list[PhaseGateCheck]) -> tuple[bool, str]:
    """ゲート通過可否を判断する。"""
    failed = [c for c in checks if not c.passed]
    if failed:
        details = "; ".join(f"{c.name}: {c.detail}" for c in failed)
        return False, f"Gate BLOCKED - {len(failed)} checks failed: {details}"
    return True, "Gate PASSED - all checks passed"

実践トラブルシューティング

分散トレーシングなしでマイクロサービスを開始した

症状:ユーザーが「注文ができない」と報告したが、どのサービスで問題が発生したか分からない。各サービスのログを1つずつ調べなければならない。

対応:OpenTelemetryを導入する。各サービスでtrace contextを伝搬し、JaegerやGrafana Tempoでリクエスト全体のフローを可視化する。既にサービスが運用中であればサイドカー方式で段階的に適用する。

サービス境界の設定ミスでサービス間呼び出しが爆発

症状:1つのユーザーリクエストが内部的に15サービス間で30回の呼び出しを発生させる。レイテンシが蓄積し障害伝搬範囲が広い。

原因:ドメイン境界ではなく技術レイヤー(フロントエンドサービス、データサービス、ロギングサービス)で分離したか、細かく分けすぎた。

対応:(1) サービス間呼び出しパターンを分析し、過度な結合があるサービスを統合する。(2) 分離基準を「技術レイヤー」から「ビジネスドメイン」に再定立する。(3) BFF(Backend For Frontend)パターンでフロントエンドリクエストを集約する。

共有DBから抜け出せない

症状:サービスを分離したが同じDBを共有している。あるサービスのスキーマ変更が他のサービスを壊す。事実上「分散モノリス」だ。

対応:(1) まずDBビュー(view)を通じて他サービスのテーブルアクセスを間接化する。(2) イベントベースのデータ同期を導入し、直接DB参照を排除する。(3) 最終的にサービス別DBを物理的に分離する。このプロセスは必ず段階的に、1テーブルずつ進める。

参考資料

クイズ
  1. 「Microservice Premium」とは何か? 正解:||マイクロサービスを導入すると分散システムの複雑さ(ネットワーク通信、サービスディスカバリ、分散トランザクション、モニタリング等)が追加コストとして作用するという概念。一定規模以上でのみこのコストを相殺する利点が生まれる。||

  2. モジュラーモノリスの3つの核心原則は? 正解:||(1) モジュール間の直接DB参照禁止、(2) 明示的なfacadeインターフェースによるモジュール間通信、(3) モジュール別DBスキーマの論理的分離。これによりモノリスの運用シンプルさとマイクロサービスのモジュール独立性を組み合わせる。||

  3. アーキテクチャの意思決定でチーム規模が重要な理由は? 正解:||マイクロサービスはサービスオーナーシップ、独立デプロイ、運用モニタリング等、サービスごとに一定水準の人員が必要。小規模チーム(5名以下)が多くのサービスに分離すると、1人が複数サービスを所有することになり運用負担が開発の利点を上回る。||

  4. ADR(Architecture Decision Record)に必ず含めるべき項目は? 正解:||コンテキスト(なぜこの決定が必要か)、決定(何を選んだか)、結果(ポジティブ/ネガティブな影響)、数値根拠(レイテンシ、コスト、範囲等の定量データ)、検討した代替案(他の選択肢と却下理由)。||

  5. Strangler Figパターンの核心戦略は? 正解:||既存システムを一度に置き換えるのではなく、新しいシステムを段階的に構築しながら既存システムの機能を1つずつ新システムにルーティングする。すべての機能が移行されたら既存システムを除去する。||

  6. CIでモジュール境界違反を自動検出する方法は? 正解:||Python ASTを分析して各モジュールのimportを検査する。他モジュールの内部パッケージ(service、repository、domain)を直接importすると違反と判定し、公開facadeのみ許可する。CIでPRごとにこのスクリプトを実行し、違反時にマージをブロックする。||

  7. サービスを分離したがDBを共有する「分散モノリス」を解消する段階は? 正解:||(1) DBビューを通じて他サービスのテーブルアクセスを間接化、(2) イベントベースのデータ同期導入で直接DB参照を排除、(3) サービス別DBの物理的分離。必ず1テーブルずつ段階的に進める。||

  8. Phase 2ゲートで「ロールバック経路の検証」が必要な理由は? 正解:||新しく分離したサービスに問題が発生した場合、既存のモノリス経路に即座に戻せなければならない。このロールバック経路がステージングで事前に検証されていないと、障害時の復旧時間が長くなる。||