mybook

サービス境界の見つけ方 — ドメインで切る

「どこで切るかが一番難しい」

ミサキは3回目のホワイトボードセッションで、消したり書いたりを繰り返しながらつぶやいた。サービス境界の設計は、マイクロサービスアーキテクチャで最も重要かつ最も難しい決断だ。


間違った境界の引き方

まず、よくある間違いから見ていこう。

技術レイヤーで切る(アンチパターン)

❌ 悪い例: 技術的関心事で分割
  - DatabaseService(DBアクセス担当)
  - CacheService(キャッシュ担当)
  - APIService(HTTP処理担当)
  - EmailService(メール送信担当)

問題: ビジネス機能を1つ追加するたびに
     すべてのサービスを変更しなければならない

データモデルで切る(アンチパターン)

❌ 悪い例: テーブルごとに分割
  - UserService(usersテーブル)
  - ProductService(productsテーブル)
  - OrderService(ordersテーブル)
  - ReviewService(reviewsテーブル)

問題: 同じ概念(例: 「商品」)が
     複数のサービスに分散してしまうことがある

DDD の境界づけられたコンテキスト

正しい分割の鍵は **ドメイン駆動設計(DDD)の「境界づけられたコンテキスト(Bounded Context)」**だ。

境界づけられたコンテキストとは、「特定のモデルが有効な範囲」のこと。同じ「商品」という言葉でも、文脈によって意味が違う。

# 商品サービスでの「商品」
module ProductContext
  class Product
    attr_accessor :id, :name, :description, :category
    attr_accessor :images, :specifications, :seo_keywords
    # 商品カタログ担当: 見た目・スペック・SEOが重要
  end
end
 
# 在庫サービスでの「商品」
module InventoryContext
  class StockItem
    attr_accessor :product_id, :quantity, :warehouse_location
    attr_accessor :reorder_threshold, :supplier_id
    # 在庫担当: 数量・場所・発注が重要
  end
end
 
# 注文サービスでの「商品」
module OrderContext
  class OrderLineItem
    attr_accessor :product_id, :product_name_snapshot  # 注文時点の名前を保存
    attr_accessor :unit_price_snapshot  # 注文時点の価格を保存
    attr_accessor :quantity
    # 注文担当: 注文時点の情報を不変に保つことが重要
  end
end

INFO

「同じ言葉でも、コンテキストが違えばモデルが違う」 — これが境界づけられたコンテキストの核心。「商品」という言葉をすべてのサービスで共有しようとすると、どのコンテキストにも合わない歪なモデルになる。


イベントストーミング: 境界を発見する手法

ミサキのチームは イベントストーミング というワークショップ手法を使って、ShopNovaのドメインイベントを洗い出した。

ステップ1: ドメインイベントを付箋に書き出す

ドメインイベント(オレンジの付箋):
  - ユーザーが登録された
  - 商品が出品された
  - 商品在庫が更新された
  - カートに商品が追加された
  - 注文が作成された
  - 支払いが完了した
  - 注文が発送された
  - レビューが投稿された
  - ポイントが付与された
  - 商品が検索された

ステップ2: コマンドとアクターを追加する

アクター(黄色)→ コマンド(青)→ ドメインイベント(オレンジ)

ユーザー → 商品を注文する → 注文が作成された
システム → 支払いを処理する → 支払いが完了した
倉庫スタッフ → 商品を発送する → 注文が発送された

ステップ3: 集約とコンテキスト境界を引く

Loading diagram...

ShopNova の境界づけられたコンテキスト

イベントストーミングの結果、ミサキたちは6つのコンテキストを特定した。

Loading diagram...

各コンテキストの責任範囲:

# コンテキスト定義
contexts:
  user:
    owns:
      - ユーザーアカウント
      - 認証・認可
      - プロフィール情報
      - ポイント残高
    does_not_own:
      - 注文履歴(注文コンテキストが所有)
 
  product:
    owns:
      - 商品カタログ
      - カテゴリ・タグ
      - 商品画像
      - SEOメタデータ
    does_not_own:
      - 在庫数(在庫コンテキストが所有)
 
  order:
    owns:
      - 注文ライフサイクル
      - 注文明細(価格・数量のスナップショット)
      - 配送先情報
    does_not_own:
      - 決済詳細(決済コンテキストが所有)

コンテキストマップ: サービス間の関係を可視化

コンテキスト間の関係にはパターンがある。

上流・下流(Upstream / Downstream)

# 注文サービスはユーザーサービスの「下流」
# ユーザーサービスが変更されると、注文サービスが影響を受ける可能性がある
 
# 注文サービス側: ACL(腐敗防止層)で変換
module OrderContext
  class UserAdapter
    def self.from_user_service(response)
      # UserServiceのAPIレスポンスをOrderContextのモデルに変換
      {
        user_id: response['id'],
        display_name: response['full_name'] || response['username'],
        shipping_address: format_address(response['addresses']&.first)
      }
    end
 
    private
 
    def self.format_address(addr)
      return nil unless addr
      "#{addr['postal_code']} #{addr['prefecture']}#{addr['city']}#{addr['street']}"
    end
  end
end

共有カーネル(Shared Kernel)

複数のコンテキストが共有する小さなモデル。

# shared/money.rb — 共有カーネル: 金額の扱い
module Shared
  class Money
    attr_reader :amount, :currency
 
    def initialize(amount, currency: 'JPY')
      @amount = amount.round
      @currency = currency
    end
 
    def +(other)
      raise ArgumentError, "通貨が一致しません" unless currency == other.currency
      Money.new(amount + other.amount, currency: currency)
    end
 
    def to_h
      { amount: amount, currency: currency }
    end
  end
end

実践: ShopNovaのサービス分割計画

ミサキはコンテキストをサービスにマッピングした。

# サービス境界の決定基準チェックリスト
class ServiceBoundaryChecker
  CRITERIA = [
    "ビジネスドメインが独立している",
    "変更の頻度が他と大きく異なる",
    "スケール要件が他と大きく異なる",
    "データの所有権が明確",
    "チームの所有権が明確",
    "外部システムとの結合点になっている"
  ].freeze
end

ShopNovaの分割計画:

サービス対応コンテキスト分割優先度理由
user-serviceユーザー認証は全サービスの基盤
product-service商品変更頻度が高い
search-service検索最高スケール要件が突出
order-service注文複雑だが重要
payment-service決済外部決済サービスとの結合
notification-service通知独立性が高く後回しでOK

分割順序の決め方

WARNING

一度にすべてを分割しようとしてはいけない。「絞り出しパターン(Strangler Fig Pattern)」で段階的に移行する。

Loading diagram...

Strangler Fig パターンの実装

既存のRailsモノリスからサービスを「絞り出す」方法。

# config/routes.rb — プロキシで段階的に移行
Rails.application.routes.draw do
  # 移行済み: 検索APIをSearchServiceに転送
  scope '/api/v1' do
    match '/search/*path', to: proc { |env|
      proxy_to('http://search-service', env)
    }, via: :all
 
    # まだモノリスで処理: 注文
    resources :orders
    resources :products
    resources :users
  end
end
# app/middleware/service_proxy.rb
class ServiceProxy
  def initialize(app, service_url:, path_prefix:)
    @app = app
    @service_url = service_url
    @path_prefix = path_prefix
  end
 
  def call(env)
    request = Rack::Request.new(env)
    if request.path.start_with?(@path_prefix)
      forward_to_service(request)
    else
      @app.call(env)
    end
  end
 
  private
 
  def forward_to_service(request)
    response = HTTP
      .headers(extract_headers(request))
      .public_send(request.request_method.downcase, "#{@service_url}#{request.path}")
    [response.status, response.headers.to_h, [response.body.to_s]]
  end
end

コンテキスト境界を守るRailsの実装パターン

# app/services/product_context/
# └── product.rb
# └── product_repository.rb
# └── product_catalog_service.rb
 
# モノリス内でも「コンテキスト」を意識した構造にしておくと
# 後でサービスとして切り出しやすい
 
module ProductContext
  class ProductCatalogService
    def initialize(repository: ProductRepository.new)
      @repository = repository
    end
 
    def find_by_id(id)
      @repository.find(id)
    end
 
    def search(query, filters: {})
      @repository.search(query, filters)
    end
  end
 
  class ProductRepository
    def find(id)
      record = Product.find(id)
      to_domain(record)
    end
 
    private
 
    def to_domain(record)
      ProductContext::Product.new(
        id: record.id,
        name: record.name,
        price: Shared::Money.new(record.price_cents)
      )
    end
  end
end

まとめ: サービス境界の鉄則

「わかった」とケンジが言った。「ビジネスの言葉で切るってことですね」

「そう。技術的な都合じゃなくて、ビジネスの変化に追随できる単位で切る」

ミサキはホワイトボードに書いた:

サービス境界の鉄則:

1. 同じビジネスドメインはまとめる
2. 変化の速度が違うものは分ける
3. データの所有権を明確にする
4. チームが所有できる大きさにする
5. 疑わしいときはまとめておく(後で分割できる)

次は、分割したサービスをどうやって連携させるか。サービス間通信の設計に入ろう。