サービス境界の見つけ方 — ドメインで切る
「どこで切るかが一番難しい」
ミサキは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
endINFO
「同じ言葉でも、コンテキストが違えばモデルが違う」 — これが境界づけられたコンテキストの核心。「商品」という言葉をすべてのサービスで共有しようとすると、どのコンテキストにも合わない歪なモデルになる。
イベントストーミング: 境界を発見する手法
ミサキのチームは イベントストーミング というワークショップ手法を使って、ShopNovaのドメインイベントを洗い出した。
ステップ1: ドメインイベントを付箋に書き出す
ドメインイベント(オレンジの付箋):
- ユーザーが登録された
- 商品が出品された
- 商品在庫が更新された
- カートに商品が追加された
- 注文が作成された
- 支払いが完了した
- 注文が発送された
- レビューが投稿された
- ポイントが付与された
- 商品が検索された
ステップ2: コマンドとアクターを追加する
アクター(黄色)→ コマンド(青)→ ドメインイベント(オレンジ)
ユーザー → 商品を注文する → 注文が作成された
システム → 支払いを処理する → 支払いが完了した
倉庫スタッフ → 商品を発送する → 注文が発送された
ステップ3: 集約とコンテキスト境界を引く
ShopNova の境界づけられたコンテキスト
イベントストーミングの結果、ミサキたちは6つのコンテキストを特定した。
各コンテキストの責任範囲:
# コンテキスト定義
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
endShopNovaの分割計画:
| サービス | 対応コンテキスト | 分割優先度 | 理由 |
|---|---|---|---|
| user-service | ユーザー | 高 | 認証は全サービスの基盤 |
| product-service | 商品 | 高 | 変更頻度が高い |
| search-service | 検索 | 最高 | スケール要件が突出 |
| order-service | 注文 | 中 | 複雑だが重要 |
| payment-service | 決済 | 中 | 外部決済サービスとの結合 |
| notification-service | 通知 | 低 | 独立性が高く後回しでOK |
分割順序の決め方
WARNING
一度にすべてを分割しようとしてはいけない。「絞り出しパターン(Strangler Fig Pattern)」で段階的に移行する。
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. 疑わしいときはまとめておく(後で分割できる)
次は、分割したサービスをどうやって連携させるか。サービス間通信の設計に入ろう。