Monolith か マイクロサービス: 正しい選択方法とは

1. 概要

「Monolith か マイクロサービスか、どちらを選ぶべきか」という問いは、ほぼすべてのシステム設計の場に登場します。本記事は万能な答えを提供するのではなく、具体的な基準をみていきます:チームサイズ、リリース速度、運用の複雑さ、そしてサービス間通信のコスト。読み終わった後、あなたは自分たちのシステムが Monolith - Modular Monolith - マイクロサービスの「軸」上のどこに位置すべきかを判断するためのチェックリストを得られるでしょう。

2. Monolith: シンプルだが過小評価されやすい

Monolith とは、すべてのビジネスロジック(Order、Customer、Payment、Inventory…)が1つのプロセス、1つのコードベース、1回のデプロイで動作することを意味します。

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

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        Order order = orderService.create(request);
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

@Service
public class OrderService {

    private final InventoryService inventoryService;
    private final PaymentService paymentService;

    public OrderService(InventoryService inventoryService, PaymentService paymentService) {
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
    }

    public Order create(OrderRequest request) {
        inventoryService.reserve(request.productId(), request.quantity());
        paymentService.charge(request.customerId(), request.amount());
        return new Order(request); // 仮にコンストラクタが DB に保存するとします
    }
}

OrderService は同じ JVM 内で直接メソッド呼び出しを使って InventoryService と PaymentService を呼び出しています。ネットワークはなく、シリアライゼーションもなく、トランザクションは単一の @Transactional で囲むことができます。これが Monolith の最大の利点です:一貫性が保証しやすく、デバッグが容易で、1回のデプロイで完了できる。

欠点はチームが大きくなったときに現れます:全員が同じコードベースを修正し、ビルド時間が増加し、小さなモジュールをデプロイするリスクでも app 全体の再ビルドが必要になります。

注記: Monolith は「コードが混乱している」という意味ではありません。ドメイン別にパッケージ化され、循環依存がない、良く書かれた Monolith は、数百万ユーザーのシステムでも正常に動作します。

3. マイクロサービス: 独立したスケーリングと引き換えに運用の複雑さが増す

マイクロサービスは Order、Inventory、Payment を別々のサービスに分割し、ネットワーク(REST、gRPC、メッセージキュー)を介して通信します。

@Service
public class OrderService {

    private final RestClient restClient;

    public OrderService(RestClient restClient) {
        this.restClient = restClient;
    }

    public Order create(OrderRequest request) {
        // Inventory Service に HTTP 経由で呼び出し
        restClient.post()
                .uri("http://inventory-service/reservations")
                .body(new ReservationRequest(request.productId(), request.quantity()))
                .retrieve()
                .toBodilessEntity();

        // Payment Service に HTTP 経由で呼び出し
        restClient.post()
                .uri("http://payment-service/charges")
                .body(new ChargeRequest(request.customerId(), request.amount()))
                .retrieve()
                .toBodilessEntity();

        return new Order(request);
    }
}

第2節の Monolith 版と比較して、restClient.post(...) が違いです:プロセス内メソッド呼び出しが、ネットワーク経由の HTTP 呼び出しに変わりました。その結果:

  • 各呼び出しはネットワークタイムアウト、サービスダウン、DNS エラーが原因で失敗する可能性があります — これらのエラーは Monolith には存在しません。
  • サービス間の ACID トランザクションはありません。saga pattern または eventual consistency で処理する必要があります。
  • エラーをデバッグするには、service discovery、load balancer、distributed tracing が必要です。

3.1. 隠れたコスト: 分散トランザクション

@Service
public class OrderSagaOrchestrator {

    public void createOrder(OrderRequest request) {
        try {
            inventoryClient.reserve(request.productId(), request.quantity());
            paymentClient.charge(request.customerId(), request.amount());
        } catch (PaymentFailedException e) {
            // Payment が失敗した場合、compensate ステップを自分で書いてロールバック処理をする必要がある
            inventoryClient.release(request.productId(), request.quantity());
            throw e;
        }
    }
}

Monolith では、ロールバックは @Transactional が自動的に行います。マイクロサービスでは、compensate ロジック(reserve 失敗後の release)を自分で書く必要があります。このコードは Monolith には存在しませんが、マイクロサービスではデータの不整合を避けるために必須です。

4. 決定基準: 「大企業は何をしているか」だけでは判断しない

4.1. チームサイズと Conway の法則

Conway の法則:システムの構造は組織の構造を反映します。5 人の開発者がいて 5 つのサービスに分割する場合、各人が 1 つのサービスを担当することになり、誰もコードレビューをする人がなく、「複数の人が同じバグを見ることの利点」を失います。

実践的な目安:

1–15 dev, 1 team         -> Monolith (modular)
15–50 dev, 2–5 team      -> Modular monolith またはスケーリング用に 2-3 の大きなサービスに分割
50+ dev, 複数チーム      -> Bounded context に基づくマイクロサービス

4.2. リリース速度は本当にボトルネックか?

あなたのチームが Monolith を2週間ごとにリリースし、重大なマージの競合がない場合、「独立したリリース」のためにサービスを分割することは価値をもたらしません — 運用コスト(個別の CI/CD、個別の監視、API のバージョニング)を増やすだけです。

マイクロサービスが価値をもたらす場合:チーム A が Payment 機能に対して1日10回デプロイ必要で、チーム B は Inventory を週1回だけデプロイする。分割することで、A は B を待つ必要がなくなります。

4.3. インフラの障害耐性

# docker-compose.yml - マイクロサービスに必要なインフラ複雑性の最小例
services:
  order-service:
    image: order-service:1.0
    depends_on:
      - inventory-service
      - payment-service
      - service-registry
  inventory-service:
    image: inventory-service:1.0
  payment-service:
    image: payment-service:1.0
  service-registry:
    image: eureka-server:1.0
  api-gateway:
    image: spring-cloud-gateway:1.0

わずか3つのビジネスサービスで、service-registry と api-gateway を追加する必要があります。実際に20のサービスがある場合、moving parts の数は指数関数的に増加します — Kubernetes、分散ログ(ELK)、トレーシング(Jaeger/Zipkin)を運用できる十分に強い DevOps チームが必要になります。

注記: 会社にコンテナオーケストレーションと observability スタックに精通した人がいない場合、マイクロサービスはすべてのインシデントを複数のサービスログを通じて「大海の中から針を探す」ような作業に変えます。

5. Modular monolith: 見落とされがちな中間の選択肢

Modular monolith は Monolith ですが、コードは独立したモジュールに編成されており、明確なインターフェースを通じて通信し、データベーステーブル間に循環依存がありません。

// Package: com.nonladev.shop.order
public interface InventoryPort {
    void reserve(String productId, int quantity);
}

// Package: com.nonladev.shop.inventory
@Service
public class InventoryService implements InventoryPort {
    @Override
    public void reserve(String productId, int quantity) {
        // ビジネスロジック、inventory モジュール専用の DB にアクセス
    }
}

OrderService は InventoryPort インターフェースだけに依存し、inventory モジュールの実装詳細を知りません。ですから、後で inventory を別のサービスに分割する必要がある場合、InventoryPort の実装を HTTP クライアントに変更するだけで済みます — OrderService の呼び出しコードは変わりません。

これは、多くの大規模システム(Shopify、初期段階の GitHub)が成功裏に採用している「Monolith で始めて、必要に応じてだんだん分割する」戦略そのものです。

6. テスト: プロセス内呼び出しからネットワーク呼び出しへの移行時のレイテンシコストを測定

以下のテスト例は、レイテンシの違いを示しています — これはサービスを分割することを決定する前に実測する必要がある要素です。

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class OrderServiceLatencyTest {

    @Test
    void inProcessCallShouldBeFasterThanRemoteCall() {
        InventoryPort inProcess = (productId, qty) -> { /* no-op */ };
        long startInProcess = System.nanoTime();
        inProcess.reserve("P-1", 1);
        long inProcessDuration = System.nanoTime() - startInProcess;

        InventoryPort remoteSimulated = (productId, qty) -> {
            try {
                Thread.sleep(20); // ネットワークラウンドトリップを ~20ms でシミュレート
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };
        long startRemote = System.nanoTime();
        remoteSimulated.reserve("P-1", 1);
        long remoteDuration = System.nanoTime() - startRemote;

        assertThat(remoteDuration).isGreaterThan(inProcessDuration);
    }
}

このテストはあなたのインフラの実際のパフォーマンスは測定しませんが、本質を示しています:Order が Inventory をネットワーク経由で呼び出すたびに、数ミリ秒~数十ミリ秒が追加されます。1つのリクエストが5つのサービスのチェーンを呼び出す必要がある場合、累積レイテンシは SLA を超える可能性があります — これは紙にアーキテクチャ図を描くときに見落とされるコストです。

7. マイクロサービスを選ぶべきではない場合

  • チームが10人未満: 複数のサービスの運用コスト(CI/CD、監視、オンコール)が分割の利点を上回ります。
  • ドメインがまだ不安定: ビジネスが絶えず変わっている場合(スタートアップが product-market fit を探している段階)、サービスの境界は常に間違っていて、分割してからまたマージする必要があります — 2倍の手間がかかります。
  • Observability がない: distributed tracing と centralized logging がないと、サービス間のエラーをデバッグするのはほぼ不可能です。
  • 強い一貫性が必要な取引(例:コアバンキング):複雑な saga を実装するより、1つのトランザクション境界(Monolith)で保つ方が安全です。

逆に、マイクロサービスへの投資が価値をもたらす場合:複数の独立したチーム、各部分を個別にスケーリングする必要がある(例えば、search サービスは order サービスより多くの CPU が必要)、または各ドメインに異なるテクノロジーが必要(real-time chat には Node.js、billing には Java)。

8. 結論

「正しい」アーキテクチャはありません — あなたのチームサイズ、リリース速度、現在の運用能力に合ったアーキテクチャだけがあります。確実でない場合は、modular monolith で始めてください:ドメイン間に明確なインターフェースを保ち、データベースの循環依存を避けるようにします。本当に分割が必要になった場合(スケーリングまたはチーム拡大のため)、実装を変更するだけで済み、システム全体を書き直す必要はありません。マイクロサービスは、組織とスケーリングの問題を解決するためのツールであり、それ自体がアーキテクチャの目的ではありません。

microservicesmonolithsystem-designspring-bootアーキテクチャ