mybook

境界づけられたコンテキスト — ドメインを分割する

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)とは、特定のユビキタス言語が一貫して使われる明示的な境界のことだ。

境界の中では:

  • 同じ言語(ユビキタス言語)が使われる
  • 概念の意味が明確に定義される
  • モデルが一貫している
Loading diagram...

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
end

FreshCartのコンテキスト分割

リナはカズキとともに、FreshCartのドメインを分析してコンテキストを特定した。

Loading diagram...

コンテキスト間に矢印があるのは「依存関係」を示す。注文処理コンテキストは商品カタログ、在庫管理、決済処理、配送管理、通知の各コンテキストに依存している。

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
end

INFO

ACLのメリット: カタログコンテキストが display_nameproduct_name に変更しても、影響を受けるのはACLの1箇所だけだ。注文処理コンテキストは name のまま動き続ける。外部変更からドメインを守る防波堤だ。

AWS上でのコンテキスト分割(将来のマイクロサービス化)

FreshCartが成長し、チームが増えてきたら、コンテキストをマイクロサービスとして分離できる。Railsモノリスで論理的に分割しておいたコンテキストが、そのままサービスになる。

Loading diagram...

マイクロサービス化する際に変更が必要なのは主に「インフラ設定」と「サービス間通信の実装」だ。ドメインロジック自体はほとんど変えずに済む。これが論理的なコンテキスト分割を先に行う価値だ。

# モノリス時のコンテキスト間通信(同一プロセス内)
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 がいかに多くの問題を解決するかを見ていこう。