境界づけられたコンテキスト — ドメインを分割する
「Product クラスに2000行のコードがある」
同僚のタカシが報告してきた。リナはそのファイルを開き、愕然とした。商品情報の管理、在庫の計算、検索インデックスの更新、価格計算、税率処理、レビュー集計、SEO用メタデータ、配送サイズ情報——全てが一つの Product クラスに詰め込まれていた。
class Product < ApplicationRecord
# カタログ情報
has_many :product_images
has_many :product_reviews
has_many :product_tags
validates :name, :description, presence: true
# 在庫情報
has_one :stock_info
has_many :stock_reservations
def available_quantity; end
def reserve_stock!(quantity); end
# 価格・税
has_many :price_histories
def current_price; end
def price_with_tax; end
def apply_discount(rate); end
# 配送情報
def shipping_weight; end
def can_ship_to?(address); end
def estimated_delivery_days; end
# SEO
def seo_title; end
def seo_description; end
def structured_data; end
# 検索
after_save :update_search_index
def search_keywords; end
# ... 2000行続く
end「なぜ Product がこんなに大きくなるんだ?」リナは思った。
なぜ「Product」が2000行になるのか
Product という概念は、コンテキストによって意味が全く変わる。
| コンテキスト | 「Product」が意味するもの |
|---|---|
| 在庫管理 | 倉庫内の物理的な商品。位置、数量、ロット番号、賞味期限が重要 |
| 注文処理 | 注文に含まれる品目。価格、数量、割引率が重要 |
| 商品カタログ | 顧客に見せる商品情報。説明文、画像、レビューが重要 |
| 配送 | 梱包する物。重量、サイズ、配送不可地域が重要 |
| SEO/マーケティング | 検索に表示する情報。タイトル、説明文、構造化データが重要 |
これらを全て一つの Product クラスに詰め込もうとするから、2000行になる。「Product」が「何でも入れていいクラス」になってしまっている。
境界づけられたコンテキストとは
境界づけられたコンテキスト(Bounded Context)とは、特定のユビキタス言語が一貫して使われる明示的な境界のことだ。
境界の中では:
- 同じ言語(ユビキタス言語)が使われる
- 概念の意味が明確に定義される
- モデルが一貫している
INFO
「Product」という単語は同じでも、コンテキストが違えば意味が変わる。境界づけられたコンテキストは、この曖昧さを解消するための仕組みだ。「在庫管理コンテキストでのProduct」と「注文処理コンテキストでのProduct」は、別のクラスとして実装する。
サブドメインの3種類
FreshCartのドメインを分析すると、3種類のサブドメインが見えてくる。
コア・ドメイン(Core Domain)
ビジネスの差別化要因となる最も重要な領域。競合との差別化はここにある。社内の最優秀エンジニアがここを担当すべきだ。
FreshCartのコア・ドメイン:
- 注文処理コンテキスト — 注文のライフサイクル管理(FreshCartの核心)
- 商品カタログコンテキスト — 商品の検索・閲覧体験(差別化ポイント)
# 注文処理コンテキスト — FreshCartの核心
module OrderContext
class Order
attr_reader :id, :customer_id, :status, :order_items
def initialize(id:, customer_id:, delivery_address:)
@id = id
@customer_id = customer_id
@delivery_address = delivery_address
@status = OrderStatus::PENDING
@order_items = []
@domain_events = []
end
def confirm!
validate_can_confirm!
@status = OrderStatus::CONFIRMED
@confirmed_at = Time.current
record_event(OrderConfirmed.new(
order_id: id,
customer_id: customer_id,
order_items: order_items,
confirmed_at: @confirmed_at
))
self
end
def total_amount
order_items.sum(&:subtotal)
end
private
def validate_can_confirm!
raise InvalidStateTransition, "#{status}状態からは確定できません" unless pending?
raise EmptyOrder, "注文商品が1つもありません" if order_items.empty?
end
end
endサポーティング・サブドメイン(Supporting Subdomain)
コアを支える重要な領域。自社で開発するが、差別化要因ではない。実用的な実装で十分。
FreshCartのサポーティング・サブドメイン:
- 在庫管理コンテキスト — 在庫追跡と引き当て
- 配送管理コンテキスト — 配送業者との連携
# 在庫管理コンテキスト — 注文処理を支える
module InventoryContext
class StockItem
attr_reader :sku, :product_name, :total_quantity, :warehouse_location
def initialize(sku:, product_name:, total_quantity:, warehouse_location:)
@sku = sku
@product_name = product_name
@total_quantity = total_quantity
@warehouse_location = warehouse_location
@reservations = []
end
def available_quantity
total_quantity - reserved_quantity
end
def reserve!(quantity:, order_id:)
raise InsufficientStock, "在庫不足: #{available_quantity}" if available_quantity < quantity
@reservations << StockReservation.new(
order_id: order_id,
quantity: quantity,
reserved_at: Time.current
)
self
end
def release_reservation!(order_id:)
reservation = @reservations.find { |r| r.order_id == order_id }
raise ReservationNotFound, "引き当てが見つかりません" unless reservation
@reservations.delete(reservation)
self
end
private
def reserved_quantity
@reservations.sum(&:quantity)
end
end
end汎用サブドメイン(Generic Subdomain)
どのシステムでも必要な汎用的な機能。外部サービスを使うか、シンプルに実装で十分。差別化要因でないため、コストをかけすぎない。
FreshCartの汎用サブドメイン:
- 通知コンテキスト — メール・プッシュ通知(SendGrid, FCMなど外部サービス)
- 認証コンテキスト — ログイン・セッション管理(Deviseなどgemで十分)
# 通知コンテキスト — 汎用的な通知機能(外部サービスに委譲)
module NotificationContext
class EmailNotification
def initialize(provider: SendGridProvider.new)
@provider = provider
end
def send(to:, template_id:, template_data:)
@provider.send_email(
to: to,
template_id: template_id,
data: template_data
)
end
end
class PushNotification
def initialize(provider: FirebaseProvider.new)
@provider = provider
end
def send(device_token:, title:, body:, data: {})
@provider.send_push(
token: device_token,
notification: { title: title, body: body },
data: data
)
end
end
endFreshCartのコンテキスト分割
リナはカズキとともに、FreshCartのドメインを分析してコンテキストを特定した。
コンテキスト間に矢印があるのは「依存関係」を示す。注文処理コンテキストは商品カタログ、在庫管理、決済処理、配送管理、通知の各コンテキストに依存している。
Railsでのコンテキスト分割の実装
Railsのモノリスでコンテキストを分割する現実的な方法を見ていこう。
ディレクトリ構造でコンテキストを表現
app/
├── domains/ # ドメインオブジェクト
│ ├── order_context/
│ │ ├── order.rb # 集約ルート
│ │ ├── order_item.rb
│ │ ├── order_status.rb # 値オブジェクト
│ │ ├── delivery_address.rb # 値オブジェクト
│ │ ├── events/
│ │ │ ├── order_confirmed.rb
│ │ │ └── order_cancelled.rb
│ │ └── services/
│ │ └── discount_calculation_service.rb
│ ├── inventory_context/
│ │ ├── stock_item.rb
│ │ ├── stock_reservation.rb
│ │ └── services/
│ │ └── stock_reservation_service.rb
│ ├── catalog_context/
│ │ ├── product.rb
│ │ ├── product_variant.rb
│ │ └── adapters/
│ │ └── catalog_to_order_adapter.rb
│ └── payment_context/
│ ├── payment.rb
│ └── adapters/
│ └── stripe_payment_adapter.rb
├── repositories/ # リポジトリ(データアクセス層)
│ ├── order_repository.rb
│ ├── stock_item_repository.rb
│ └── product_repository.rb
├── use_cases/ # アプリケーションサービス
│ ├── confirm_order_use_case.rb
│ └── place_order_use_case.rb
└── models/ # ActiveRecord(データ層のみ)
├── order_record.rb
├── order_item_record.rb
├── stock_item_record.rb
└── product_record.rb
WARNING
ディレクトリ構造でコンテキストを表現しても、Rubyには名前空間の強制機能がない。RuboCopのカスタムルールや、コードレビューでのチェックリストを使って「コンテキスト間の直接参照禁止」を維持する必要がある。
コンテキスト間の参照を制御する
# NG: 注文処理コンテキストが在庫コンテキストのモデルを直接参照
module OrderContext
class Order
def confirm!
# これはNG: 別コンテキストのオブジェクトを直接操作
InventoryContext::StockItem.find_by(sku: product_sku).reserve!(quantity)
end
end
end
# OK: コンテキスト間の依存はサービス層を通じて行う
module OrderContext
class ConfirmOrderUseCase
def initialize(order_repository:, stock_reservation_service:)
@order_repository = order_repository
@stock_reservation_service = stock_reservation_service
end
def call(order_id:)
order = @order_repository.find(order_id)
order.confirm!
# 別コンテキストへの操作はユースケース層で調整
@stock_reservation_service.reserve_for_order(order)
@order_repository.save(order)
end
end
end腐敗防止層(Anti-Corruption Layer)の実装
外部システムや他コンテキストのモデルを自分のコンテキストに取り込む際に使う翻訳層だ。
# 注文処理コンテキストが商品カタログコンテキストのデータを使う際のACL
module OrderContext
class CatalogAdapter
def initialize(catalog_service: CatalogContext::ProductCatalogService.new)
@catalog_service = catalog_service
end
# 外部の "CatalogContext::Product" を OrderContext の "OrderableProduct" に変換
def find_orderable_product(product_id)
catalog_product = @catalog_service.find_active_product(product_id)
raise ProductNotAvailable, "商品が現在注文できません" unless catalog_product
# 翻訳: カタログコンテキストの語彙 → 注文コンテキストの語彙
OrderableProduct.new(
id: catalog_product.id,
name: catalog_product.display_name, # display_name → name
unit_price: Money.new(
amount: catalog_product.price_jpy, # price_jpy → unit_price
currency: :jpy
),
taxable: catalog_product.tax_category != 'food_exempt',
available: catalog_product.currently_available?
)
end
end
# 注文コンテキスト内の語彙で定義された商品表現
class OrderableProduct
attr_reader :id, :name, :unit_price, :taxable
def initialize(id:, name:, unit_price:, taxable:, available:)
@id = id
@name = name
@unit_price = unit_price
@taxable = taxable
@available = available
freeze
end
def price_with_tax
taxable ? unit_price.multiply(1.1) : unit_price
end
def available?
@available
end
end
endINFO
ACLのメリット: カタログコンテキストが display_name を product_name に変更しても、影響を受けるのはACLの1箇所だけだ。注文処理コンテキストは name のまま動き続ける。外部変更からドメインを守る防波堤だ。
AWS上でのコンテキスト分割(将来のマイクロサービス化)
FreshCartが成長し、チームが増えてきたら、コンテキストをマイクロサービスとして分離できる。Railsモノリスで論理的に分割しておいたコンテキストが、そのままサービスになる。
マイクロサービス化する際に変更が必要なのは主に「インフラ設定」と「サービス間通信の実装」だ。ドメインロジック自体はほとんど変えずに済む。これが論理的なコンテキスト分割を先に行う価値だ。
# モノリス時のコンテキスト間通信(同一プロセス内)
module OrderContext
class CatalogAdapter
def initialize(catalog_service: CatalogContext::ProductCatalogService.new)
@catalog_service = catalog_service # 同一プロセス内のRubyオブジェクト
end
end
end
# マイクロサービス化後(HTTP通信に変わる)
module OrderContext
class CatalogAdapter
def initialize(catalog_client: CatalogServiceHttpClient.new)
@catalog_client = catalog_client # HTTP通信クライアントに差し替え
end
# インターフェースは同じ。ドメインロジックは変更なし
end
end
# HTTP通信の実装のみが変わる
class CatalogServiceHttpClient
def find_active_product(product_id)
response = Faraday.get("#{ENV['CATALOG_SERVICE_URL']}/products/#{product_id}")
return nil if response.status == 404
CatalogProductDto.from_json(response.body)
end
endコンテキストマップ
複数のコンテキストが存在する場合、それらの関係を可視化するのがコンテキストマップだ。
FreshCartのコンテキストマップには、以下の関係パターンが含まれる。
コンテキスト間の関係パターン
| パターン | 説明 | FreshCartでの例 |
|---|---|---|
| 顧客/供給者(Customer/Supplier) | Upstreamがデータを提供、DownstreamがACLで変換 | 商品カタログ → 注文処理 |
| 準拠者(Conformist) | Downstreamが完全にUpstreamのモデルに従う | 外部APIを直接使う場合 |
| 腐敗防止層(ACL) | Downstreamが翻訳層を設けてUpstreamから独立 | Stripe API → 決済コンテキスト |
| 共有カーネル(Shared Kernel) | 複数コンテキストで共有する小さなコード片 | Money 値オブジェクト |
# 共有カーネルの例: Moneyは全コンテキストで使う
module SharedKernel
class Money
attr_reader :amount, :currency
def initialize(amount:, currency: :jpy)
@amount = Integer(amount)
@currency = currency.to_sym
freeze
end
def add(other)
raise CurrencyMismatch unless same_currency?(other)
Money.new(amount: amount + other.amount, currency: currency)
end
def multiply(factor)
Money.new(amount: (amount * factor).ceil, currency: currency)
end
def ==(other)
other.is_a?(Money) && amount == other.amount && currency == other.currency
end
def to_s
"#{amount}#{currency.upcase}"
end
private
def same_currency?(other)
currency == other.currency
end
end
endリナの学び
「Product クラスが2000行になった謎が解けた」
在庫管理における Product と、注文における Product と、カタログにおける Product は、別物だった。それぞれのコンテキストで独自のモデルを持ち、必要な時だけACLで翻訳する。
「完璧な単一モデルを作ろうとすると、誰の役にも立たないモデルができる」
Product クラスを「すべてのコンテキストに対応する汎用クラス」にしようとしたから、2000行になった。コンテキストごとに「そのコンテキストに必要な情報だけを持つモデル」を作れば、各クラスは50〜200行で済む。
# コンテキストごとに異なるProductの表現
module CatalogContext
class Product # 約150行: 商品説明、画像、レビューのみ
attr_reader :id, :name, :description, :images, :review_score
end
end
module InventoryContext
class StockItem # 約120行: 在庫数、ロット、賞味期限のみ
attr_reader :sku, :product_id, :quantity, :lot_number, :expiry_date
end
end
module OrderContext
class OrderableProduct # 約80行: 注文に必要な情報のみ
attr_reader :id, :name, :unit_price, :taxable
end
end
module ShippingContext
class ShippableItem # 約100行: 配送に必要な情報のみ
attr_reader :product_id, :weight_grams, :dimensions, :shipping_restrictions
end
end各クラスが小さく、理解しやすく、テストしやすい。これがBounded Contextの力だ。
まとめ
- 境界づけられたコンテキスト = 一貫したモデルが使われる明示的な境界
- コア/サポーティング/汎用 = サブドメインの種類で優先度と実装コストを決める
- コンテキストごとの独自モデル = 同じ「Product」でも文脈によって別クラス
- 腐敗防止層(ACL) = 外部コンテキストからの独立を守る翻訳層
- 段階的マイクロサービス化 = 論理的な分割が先、物理的な分割はその後
次の章では、コンテキスト内のドメインモデルの基本構成要素「エンティティ」と「値オブジェクト」を学ぶ。FreshCartの Money がいかに多くの問題を解決するかを見ていこう。