コンテキストマップの実践 — サービス間の関係を定義する
「外部の決済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の状況を整理しよう。
腐敗防止層(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
endINFO
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
endWARNING
共有カーネルは慎重に扱う。共有するコードが増えるほど、コンテキスト間の結合度が高まる。Money や Pagination のような「本当に全コンテキストで同じ意味を持つ」ものだけに限定する。ビジネスロジックを共有カーネルに入れると、変更が難しくなる。
顧客/供給者(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
endAWS上でのマイクロサービス間通信
FreshCartがマイクロサービス化した際のコンテキスト間通信。
# マイクロサービス間の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
endINFO
サーキットブレーカーは電気の回路ブレーカーと同じ考え方だ。障害が多発しているサービスへの呼び出しを自動的に遮断し(回路オープン)、一定時間後に少しだけ通信を試みる(回路半開)。これにより障害の連鎖(カスケード障害)を防ぐ。
コンテキストマップのドキュメント化
コードと並行して、視覚的なドキュメントを維持する。
# 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レスポンスが変更された際も、変更箇所は StripePaymentAdapter の translate_status メソッドだけで済んだ。以前は charge_id が5箇所に散在していて、全てを見つけるのに半日かかっていたのが嘘のようだ。
「ACLは単なる変換層ではない。外部の世界と内部の世界を明確に分ける境界線だ。外部が変わるのは当然のこと。変わってもいいように設計するのが仕事だ」
まとめ
- ACL(腐敗防止層) = 外部システムの変更からドメインを守る翻訳層
- 共有カーネル = 複数コンテキストで共有する最小限のコード(Money, Paginationなど)
- 顧客/供給者 = Supplierが定義したAPIでDownstreamにデータを提供
- サーキットブレーカー = 外部サービス障害の連鎖を防ぐ
- コンテキストマップ = チームの設計決定を可視化し、変更管理ルールを明示
次の章では、Railsのモノリスとしての現実的な実装戦略「DDDとRailsの現実解」を学ぶ。2万行のレガシーコードをどう段階的に改善するかを見ていこう。