mybook

コンテキストマップの実践 — サービス間の関係を定義する

「外部の決済APIのレスポンスが変わって、うちのコードが壊れた」

リナはインシデントの原因を調べていた。Stripe のAPIレスポンスのフィールド名が変更された。payment.charge_id を参照しているコードがNilエラーを起こしていた。

影響範囲を確認すると、charge_id という文字列が5ファイルに散在していた。

$ grep -r "charge_id" app/ --include="*.rb"
app/controllers/payments_controller.rb:52:  payment.charge_id
app/models/payment.rb:34:  self.charge_id = stripe_response[:charge_id]
app/services/refund_service.rb:18:  stripe.refund(charge_id: payment.charge_id)
app/workers/payment_sync_worker.rb:45:  Payment.where(charge_id: nil).each...
app/mailers/order_mailer.rb:67:  "決済番号: #{payment.charge_id}"

外部システム(Stripe)の変更が、内部のビジネスロジック全体に波及していた。これはコンテキスト間の境界が適切に設計されていなかった問題だ。

コンテキスト間の関係パターン

コンテキストマップには様々な関係パターンがある。FreshCartの状況を整理しよう。

Loading diagram...

腐敗防止層(Anti-Corruption Layer)の実装

外部システムからの「汚染」を防ぐ翻訳層。これを設けることで、外部APIの変更が内部に影響しなくなる。

決済APIのACL

module PaymentContext
  class StripePaymentAdapter
    class PaymentError < StandardError; end
    class CardError < PaymentError
      attr_reader :code, :decline_code
 
      def initialize(message:, code:, decline_code: nil)
        super(message)
        @code = code
        @decline_code = decline_code
      end
    end
 
    def initialize(stripe_client: Stripe)
      @stripe = stripe_client
    end
 
    # ドメインの言語でメソッドを定義
    def process_payment(order:, payment_method:)
      # 内部ドメインオブジェクト → Stripeの形式に変換(ここが翻訳)
      stripe_response = @stripe::PaymentIntent.create({
        amount: order.final_amount.to_i,
        currency: order.final_amount.currency.to_s.downcase,
        payment_method: payment_method.token,
        confirm: true,
        metadata: {
          order_id: order.id,
          customer_id: order.customer_id
        },
        description: "FreshCart注文: #{order.id}"
      })
 
      # Stripeのレスポンス → ドメインオブジェクトに変換(ここが翻訳)
      PaymentTransaction.new(
        transaction_id: stripe_response.id,           # id → transaction_id
        status: translate_status(stripe_response.status),
        amount: SharedKernel::Money.new(
          amount: stripe_response.amount,
          currency: stripe_response.currency.to_sym
        ),
        processed_at: Time.at(stripe_response.created).utc,
        payment_method_type: stripe_response.payment_method_types.first
      )
 
    rescue Stripe::CardError => e
      raise CardError.new(
        message: translate_card_error_message(e.code),
        code: e.code,
        decline_code: e.decline_code
      )
    rescue Stripe::InvalidRequestError => e
      raise PaymentError, "決済リクエストが不正です: #{e.message}"
    rescue Stripe::StripeError => e
      raise PaymentError, "決済処理に失敗しました: #{e.message}"
    end
 
    def refund_payment(transaction_id:, amount:)
      stripe_response = @stripe::Refund.create({
        payment_intent: transaction_id,
        amount: amount.to_i,
        reason: 'requested_by_customer'
      })
 
      RefundResult.new(
        refund_id: stripe_response.id,
        status: translate_refund_status(stripe_response.status),
        amount: SharedKernel::Money.new(
          amount: stripe_response.amount,
          currency: stripe_response.currency.to_sym
        ),
        refunded_at: Time.at(stripe_response.created).utc
      )
    rescue Stripe::StripeError => e
      raise PaymentError, "返金処理に失敗しました: #{e.message}"
    end
 
    private
 
    # Stripeの状態 → ドメインの状態に変換
    def translate_status(stripe_status)
      case stripe_status
      when 'succeeded'        then PaymentStatus::SUCCEEDED
      when 'processing'       then PaymentStatus::PROCESSING
      when 'requires_action'  then PaymentStatus::REQUIRES_ACTION
      when 'requires_payment_method' then PaymentStatus::REQUIRES_PAYMENT_METHOD
      when 'canceled'         then PaymentStatus::CANCELLED
      else                         PaymentStatus::UNKNOWN
      end
    end
 
    def translate_refund_status(stripe_status)
      case stripe_status
      when 'succeeded' then RefundStatus::SUCCEEDED
      when 'pending'   then RefundStatus::PENDING
      when 'failed'    then RefundStatus::FAILED
      else                  RefundStatus::UNKNOWN
      end
    end
 
    # Stripeのエラーコード → ユーザーフレンドリーなメッセージ
    def translate_card_error_message(code)
      case code
      when 'card_declined'       then 'カードが使用できませんでした'
      when 'insufficient_funds'  then '残高が不足しています'
      when 'expired_card'        then 'カードの有効期限が切れています'
      when 'incorrect_cvc'       then 'セキュリティコードが正しくありません'
      when 'invalid_card_number' then 'カード番号が正しくありません'
      else                            'カードに問題が発生しました'
      end
    end
  end
 
  # ドメインの語彙で定義された決済結果
  class PaymentTransaction
    attr_reader :transaction_id, :status, :amount, :processed_at, :payment_method_type
 
    def initialize(transaction_id:, status:, amount:, processed_at:, payment_method_type:)
      @transaction_id = transaction_id
      @status = status
      @amount = amount
      @processed_at = processed_at
      @payment_method_type = payment_method_type
      freeze
    end
 
    def succeeded?
      status == PaymentStatus::SUCCEEDED
    end
 
    def requires_action?
      status == PaymentStatus::REQUIRES_ACTION
    end
  end
end

INFO

ACLの効果: 次にStripeが charge_id を別のフィールド名に変更しても、変更箇所は StripePaymentAdapter の内部だけだ。transaction_id を使っているドメインコードは一切変更不要。変更が1ファイルの1箇所に集約される。

配送APIのACL

module ShippingContext
  class YamatoShippingAdapter
    class ShippingError < StandardError; end
 
    def initialize(client: YamatoApiClient.new(
      api_key: Rails.application.credentials.yamato[:api_key],
      environment: Rails.env.production? ? :production : :sandbox
    ))
      @client = client
    end
 
    def schedule_delivery(order:, preferred_date: nil)
      # ドメインオブジェクト → ヤマト運輸APIの形式に変換
      response = @client.create_shipment({
        sender: build_sender_info,
        receiver: build_receiver_info(order.delivery_address),
        package: build_package_info(order),
        delivery_date: preferred_date&.strftime('%Y%m%d'),
        service_type: determine_service_type(order)
      })
 
      # ヤマト運輸のレスポンス → ドメインオブジェクトに変換
      DeliverySchedule.new(
        tracking_number: response[:slip_no],           # slip_no → tracking_number
        estimated_delivery: parse_delivery_date(response[:delivery_date]),
        courier: :yamato,
        service_name: translate_service_name(response[:service_type])
      )
    rescue YamatoApiClient::ApiError => e
      raise ShippingError, "配送予約に失敗しました: #{e.message}"
    end
 
    def track_delivery(tracking_number)
      response = @client.inquire(slip_no: tracking_number)  # YamatoはURLを"slip_no"と呼ぶ
 
      DeliveryTrackingInfo.new(
        tracking_number: tracking_number,
        status: translate_tracking_status(response[:status_cd]),
        current_location: response[:current_place],
        estimated_delivery: parse_delivery_date(response[:delivery_date]),
        delivery_history: response[:history].map { |h| translate_history_entry(h) },
        updated_at: Time.parse(response[:update_datetime]).utc
      )
    rescue YamatoApiClient::ApiError => e
      raise ShippingError, "追跡情報の取得に失敗しました: #{e.message}"
    end
 
    private
 
    def build_receiver_info(delivery_address)
      {
        zip_code: delivery_address.postal_code,
        pref: delivery_address.prefecture,
        city: delivery_address.city,
        address: delivery_address.street,
        building: delivery_address.building.to_s,
        name: delivery_address.recipient_name
      }
    end
 
    def build_package_info(order)
      {
        # ヤマトAPIはサイズをS/M/Lで分類する
        size: determine_package_size(order),
        # 重さはg単位
        weight_g: estimate_weight_grams(order)
      }
    end
 
    def translate_tracking_status(status_code)
      case status_code
      when '00' then TrackingStatus::IN_TRANSIT
      when '05' then TrackingStatus::AT_DISTRIBUTION_CENTER
      when '10' then TrackingStatus::OUT_FOR_DELIVERY
      when '15' then TrackingStatus::DELIVERY_ATTEMPTED
      when '20' then TrackingStatus::DELIVERED
      when '30' then TrackingStatus::RETURNED_TO_SENDER
      else           TrackingStatus::UNKNOWN
      end
    end
 
    def build_sender_info
      {
        zip_code: '1500001',
        pref: '東京都',
        city: '渋谷区',
        address: '神宮前1-1-1',
        name: 'FreshCart株式会社',
        tel: '03-1234-5678'
      }
    end
 
    def determine_service_type(order)
      # 冷凍食品がある場合はクール便
      has_frozen = order.order_items.any? do |item|
        item.requires_cold_shipping?
      end
      has_frozen ? 'cool_delivery' : 'normal_delivery'
    end
 
    def determine_package_size(order)
      total_items = order.item_count
      if total_items <= 3 then 'S'
      elsif total_items <= 8 then 'M'
      else 'L'
      end
    end
  end
end

共有カーネル(Shared Kernel)

複数のコンテキストが共有する最小限のコード。変更は全関係チームの合意が必要。

# lib/shared_kernel/
module SharedKernel
  # 全コンテキストで使うMoney値オブジェクト
  class Money
    # ... (5章で実装済み)
  end
 
  # 全コンテキストで使うページネーション
  class Pagination
    attr_reader :page, :per_page, :total_count
 
    def initialize(page:, per_page:, total_count:)
      @page = [page.to_i, 1].max
      @per_page = [[per_page.to_i, 1].max, 100].min  # 1〜100の範囲
      @total_count = total_count.to_i
      freeze
    end
 
    def total_pages
      (total_count.to_f / per_page).ceil
    end
 
    def has_next?
      page < total_pages
    end
 
    def has_previous?
      page > 1
    end
 
    def offset
      (page - 1) * per_page
    end
 
    def to_h
      {
        page: page,
        per_page: per_page,
        total_count: total_count,
        total_pages: total_pages,
        has_next: has_next?,
        has_previous: has_previous?
      }
    end
  end
 
  # 全コンテキストで使うソート条件
  class SortOrder
    DIRECTIONS = %w[asc desc].freeze
 
    attr_reader :field, :direction
 
    def initialize(field:, direction: 'desc')
      raise ArgumentError, "不正なソート方向: #{direction}" unless DIRECTIONS.include?(direction.to_s)
      @field = field.to_s
      @direction = direction.to_s
      freeze
    end
 
    def to_sql_fragment(allowed_fields)
      raise ArgumentError, "許可されていないフィールド: #{field}" unless allowed_fields.include?(field)
      "#{field} #{direction}"
    end
  end
end

WARNING

共有カーネルは慎重に扱う。共有するコードが増えるほど、コンテキスト間の結合度が高まる。MoneyPagination のような「本当に全コンテキストで同じ意味を持つ」ものだけに限定する。ビジネスロジックを共有カーネルに入れると、変更が難しくなる。

顧客/供給者(Customer/Supplier)パターン

上流(Supplier)が下流(Customer)に対してAPIを提供する関係。FreshCartでは商品カタログ(Supplier)が注文処理(Customer)にデータを提供する。

# 商品カタログコンテキスト(Supplier: データを提供する側)
module CatalogContext
  class ProductCatalogService
    # 注文処理コンテキストが必要とする形式でデータを提供する
    # 注文処理側の要件を考慮したインターフェース設計
    def find_orderable_product(product_id)
      product = ProductRecord
        .where(id: product_id, status: 'active')
        .first
 
      return nil unless product
 
      # Supplierが提供するデータ構造を明示的に定義
      {
        id: product.id,
        name: product.display_name,
        price_jpy: product.current_price_in_jpy,
        tax_category: product.tax_category,
        available: product.currently_available?
      }
    end
 
    # バルク取得(注文処理の複数商品取得に対応)
    def find_orderable_products(product_ids)
      ProductRecord
        .where(id: product_ids, status: 'active')
        .each_with_object({}) do |product, hash|
          hash[product.id] = {
            id: product.id,
            name: product.display_name,
            price_jpy: product.current_price_in_jpy,
            tax_category: product.tax_category,
            available: product.currently_available?
          }
        end
    end
  end
end
 
# 注文処理コンテキスト(Customer: データを受け取る側)
module OrderContext
  class CatalogAdapter
    def initialize(catalog_service: CatalogContext::ProductCatalogService.new)
      @catalog_service = catalog_service
    end
 
    def find_orderable_product(product_id)
      raw = @catalog_service.find_orderable_product(product_id)
      raise ProductNotFound, "商品(#{product_id})が見つかりません" unless raw
 
      translate_to_orderable_product(raw)
    end
 
    def find_orderable_products(product_ids)
      raws = @catalog_service.find_orderable_products(product_ids)
 
      product_ids.map do |id|
        raw = raws[id]
        raise ProductNotFound, "商品(#{id})が見つかりません" unless raw
        translate_to_orderable_product(raw)
      end
    end
 
    private
 
    def translate_to_orderable_product(raw)
      OrderableProduct.new(
        id: raw[:id],
        name: raw[:name],
        unit_price: SharedKernel::Money.new(
          amount: raw[:price_jpy],
          currency: :jpy
        ),
        taxable: raw[:tax_category] != 'food_exempt',
        available: raw[:available]
      )
    end
  end
end

AWS上でのマイクロサービス間通信

FreshCartがマイクロサービス化した際のコンテキスト間通信。

Loading diagram...
# マイクロサービス間のHTTP通信もACLで包む
module OrderContext
  class CatalogServiceHttpClient
    TIMEOUT_SECONDS = 5
 
    def initialize(
      base_url: ENV.fetch('CATALOG_SERVICE_URL'),
      http_client: Faraday.new(
        request: { timeout: TIMEOUT_SECONDS, open_timeout: 2 }
      )
    )
      @base_url = base_url
      @http = http_client
    end
 
    def find_product(product_id)
      response = @http.get("#{@base_url}/api/v1/products/#{product_id}",
        nil,
        { 'Authorization' => "Bearer #{service_token}" }
      )
 
      case response.status
      when 200
        raw = JSON.parse(response.body, symbolize_names: true)
        # ACL: カタログサービスのフィールド名 → 注文コンテキストのフィールド名
        {
          id: raw[:product_id],         # product_id → id
          name: raw[:product_name],     # product_name → name
          price_jpy: raw[:price_cents], # price_cents → price_jpy(単位変換も)
          tax_category: raw[:tax_category],
          available: !raw[:out_of_stock]
        }
      when 404
        nil
      else
        raise CatalogServiceError, "カタログサービスエラー: #{response.status}"
      end
    rescue Faraday::TimeoutError
      raise CatalogServiceUnavailable, "カタログサービスへの接続がタイムアウト"
    rescue Faraday::ConnectionFailed
      raise CatalogServiceUnavailable, "カタログサービスに接続できません"
    end
 
    private
 
    def service_token
      Rails.application.credentials.service_tokens[:order_service]
    end
  end
end

サーキットブレーカーパターン

外部サービスの障害がFreshCart全体に波及しないようにする。

module OrderContext
  class ResilientCatalogAdapter
    def initialize(
      catalog_client: CatalogServiceHttpClient.new,
      cache: Rails.cache,
      circuit_breaker: CircuitBreaker.new(
        name: 'catalog_service',
        failure_threshold: 5,       # 5回連続失敗でオープン
        recovery_timeout: 30.seconds  # 30秒後に半開状態
      )
    )
      @catalog_client = catalog_client
      @cache = cache
      @circuit_breaker = circuit_breaker
    end
 
    def find_orderable_product(product_id)
      @circuit_breaker.call do
        raw = @catalog_client.find_product(product_id)
        return nil unless raw
 
        # キャッシュに保存(後のフォールバック用)
        product = translate(raw)
        @cache.write("orderable_product:#{product_id}", product.to_h, expires_in: 5.minutes)
        product
      end
    rescue CircuitBreaker::OpenError
      # カタログサービスが落ちていたらキャッシュから返す
      cached = @cache.read("orderable_product:#{product_id}")
      if cached
        Rails.logger.warn("カタログサービス利用不可: キャッシュから返却 product=#{product_id}")
        OrderableProduct.new(**cached)
      else
        raise ProductNotAvailable, "商品情報が一時的に利用できません"
      end
    end
 
    private
 
    def translate(raw)
      OrderableProduct.new(
        id: raw[:id],
        name: raw[:name],
        unit_price: SharedKernel::Money.new(amount: raw[:price_jpy], currency: :jpy),
        taxable: raw[:tax_category] != 'food_exempt',
        available: raw[:available]
      )
    end
  end
end

INFO

サーキットブレーカーは電気の回路ブレーカーと同じ考え方だ。障害が多発しているサービスへの呼び出しを自動的に遮断し(回路オープン)、一定時間後に少しだけ通信を試みる(回路半開)。これにより障害の連鎖(カスケード障害)を防ぐ。

コンテキストマップのドキュメント化

コードと並行して、視覚的なドキュメントを維持する。

# FreshCart コンテキストマップ v3.0
最終更新: 2024-03-15
 
## コンテキスト一覧
 
| コンテキスト | 種別 | 担当チーム | 外部依存 |
|------------|------|----------|--------|
| 注文処理 | コア | EC Dev | Stripe, ヤマト運輸 |
| 商品カタログ | コア | Catalog Dev | なし |
| 在庫管理 | サポーティング | Ops Dev | なし |
| 配送管理 | サポーティング | Ops Dev | ヤマト運輸API |
| 決済処理 | サポーティング | Finance Dev | Stripe API |
| 通知 | 汎用 | Platform | SendGrid, FCM |
 
## 関係パターン
 
| 上流 (Supplier) | 下流 (Customer) | パターン | 実装 | 備考 |
|---------------|----------------|---------|------|------|
| 商品カタログ | 注文処理 | Customer/Supplier | HTTP REST + ACL | キャッシュ5分 |
| 注文処理 | 在庫管理 | Customer/Supplier | EventBridge | 最終的整合性 |
| Stripe | 決済処理 | Conformist → ACL | ACL必須 | フィールド変更対応 |
| ヤマト運輸 | 配送管理 | Conformist → ACL | ACL必須 | ステータスコード変換 |
 
## 変更管理ルール
 
- 共有カーネル(Money, Pagination)の変更は全チームの合意が必要
- Supplier側のAPIを変更する際は2週間前に Customer チームに通知
- ACLの変更はそのコンテキストのチームのみで対応可能

リナの気づき

「外部APIが変わっても、もう怖くない」

ACLを導入してからStripeのAPIレスポンスが変更された際も、変更箇所は StripePaymentAdaptertranslate_status メソッドだけで済んだ。以前は charge_id が5箇所に散在していて、全てを見つけるのに半日かかっていたのが嘘のようだ。

「ACLは単なる変換層ではない。外部の世界と内部の世界を明確に分ける境界線だ。外部が変わるのは当然のこと。変わってもいいように設計するのが仕事だ」

まとめ

  • ACL(腐敗防止層) = 外部システムの変更からドメインを守る翻訳層
  • 共有カーネル = 複数コンテキストで共有する最小限のコード(Money, Paginationなど)
  • 顧客/供給者 = Supplierが定義したAPIでDownstreamにデータを提供
  • サーキットブレーカー = 外部サービス障害の連鎖を防ぐ
  • コンテキストマップ = チームの設計決定を可視化し、変更管理ルールを明示

次の章では、Railsのモノリスとしての現実的な実装戦略「DDDとRailsの現実解」を学ぶ。2万行のレガシーコードをどう段階的に改善するかを見ていこう。