mybook

プロローグ — コードがビジネスを語れない

「また order が壊れた」

深夜0時を回ったオフィスで、プロダクトエンジニアのリナは画面を睨んでいた。注文確定ボタンを押すと在庫が減るはずが、なぜか二重で減算されている。バグレポートはすでに10件を超えていた。

Slackには「注文したのに在庫が足りないと言われた」「同じ商品が2つ引き当てられている」といったユーザーの怒りのメッセージが流れている。カスタマーサポートは謝罪の対応に追われていた。

リナはターミナルを開き、ログを追い始めた。が、ログも混乱していた。process called, update status, stock changed — どのログがどのリクエストに対応しているのか、まったくわからない。

リナのEC事業

リナが所属するのは、オンライン食料品EC「FreshCart」の開発チームだ。創業から5年、ユーザー数は50万を突破し、取扱商品は10万SKUを超えた。急成長の代償として、コードベースは誰も全体を把握できない迷宮になっていた。

開発チームは現在8名。当初の3名体制から徐々に拡大したが、コードのオーナーシップは曖昧だった。誰でもどこでも変更できる状態が、複雑さを生み出し続けていた。

問題の根源となったコードがこれだ。

# これは何をするコードか、説明できるか?
def process(id, type, flag = false)
  obj = Obj.find(id)
  if type == 1
    obj.update(status: flag ? 2 : 1)
    Mailer.send_mail(obj.user_id, type)
    Stock.where(item_id: obj.item_id).first.decrement!(:count)
  elsif type == 2
    obj.update(status: 3)
    Stock.where(item_id: obj.item_id).first.increment!(:count)
  end
end

このコードを書いたのは3年前に退職したエンジニアだ。type == 1 が注文確定で、type == 2 がキャンセルだと、誰もわからなかった。コメントもない。テストもない。引数名の flag が何を意味するのかも不明だ。

リナはGitのログを掘り返した。コミットメッセージは「fix bug」「update」「change status」。理由が書かれていない。誰かが誰かのバグを直したのだろうが、なぜそうしたのかがわからない。

# app/models/order.rb より抜粋 — 全体は500行を超える
class Order < ApplicationRecord
  STATUS_PENDING    = 0
  STATUS_CONFIRMED  = 1
  STATUS_PROCESSING = 2
  STATUS_SHIPPED    = 3
  STATUS_DELIVERED  = 4
  STATUS_CANCELLED  = 5
 
  def process_order(type, opts = {})
    if type == 'confirm'
      # 確認済みにする
      self.status = STATUS_CONFIRMED
      # 在庫を減らす
      items.each do |i|
        s = Stock.where(item_id: i.item_id).first
        s.qty -= i.qty
        s.save
      end
      # メール
      OrderMailer.delay.send_confirm(id)
      # ポイント
      user.point += (total_price * 0.01).to_i
      user.save
    elsif type == 'cancel'
      # ...
    end
  end
end

STATUS_CONFIRMED = 1 のような数値定数が散在し、コードの読み手は常に「1って何だっけ」と頭の中で変換しなければならない。opts = {} は何のために存在するのか不明で、使われている箇所もなかった。

WARNING

整数値で状態を管理するコードは「魔法の数字(Magic Number)」と呼ばれるアンチパターンだ。status == 1 と書かれていても、1が何を意味するのか文脈なしには理解できない。数ヶ月後には書いた本人も忘れる。

ビジネスとコードの断絶

翌朝、リナはビジネスサイドのマネージャー・カズキと打ち合わせをした。カズキはEC事業の全体像を把握している、いわゆる「ドメインエキスパート」だ。

「在庫の引き当てロジックを変えたい。注文確定時ではなく、カート追加時に仮押さえしたい。競合他社が先にそれをやっていて、うちはユーザーが注文確定した後に在庫切れを伝える仕様になっているからクレームが多い」

リナは頭を抱えた。「在庫の引き当て」という言葉はビジネス側では明確な概念だが、コードの中にその言葉は存在しない。Stock.decrement!Stock.increment! が散在しているだけだ。

「引き当て」という言葉をコードで検索しても何もヒットしない。「reserve」で検索すると3箇所でバラバラな実装が見つかった。どれが正しいのか、それぞれの違いは何なのか、リナには判断できなかった。

# 検索で見つかった3つの"在庫引き当て"処理
 
# 場所1: app/models/order.rb
Stock.find_by(item_id: item.item_id).decrement!(:qty, item.qty)
 
# 場所2: app/services/cart_service.rb
stock = Stock.where("item_id = ? AND qty > 0", item_id).first
stock.update_column(:qty, stock.qty - quantity) if stock
 
# 場所3: app/workers/order_processing_worker.rb
StockManager.hold(product_id: product_id, amount: quantity)

3箇所で3種類の実装。どれもバラバラで、どれが正しいのかわからない。update_column はバリデーションをスキップするので危険だし、StockManager は別ファイルに定義されているが中身が複雑だ。

Loading diagram...

この「翻訳コスト」が問題だ。カズキが話す「在庫引き当て」をリナが「どのコードのこと?」と探し回る時間。要件の変更がコードのどこに影響するか把握できない恐怖。それが積み重なって、機能追加のたびに「また何かが壊れるかもしれない」という不安を生んでいた。

Fat Model という名の地獄

FreshCartのコードで最も肥大化したファイルは Order モデルだった。行数は気づけば800行を超えていた。

class Order < ApplicationRecord
  belongs_to :user
  belongs_to :product
  has_many :order_items
  has_one :shipment
  has_one :payment
 
  # 注文確定 — 何でもここで処理する
  def confirm
    update!(status: 'confirmed')
 
    # 在庫管理の責務
    items.each do |item|
      Stock.find_by(product_id: item.product_id)
           .decrement!(:quantity, item.quantity)
    end
 
    # 決済の責務
    PaymentService.charge(self)
 
    # 配送の責務
    ShipmentService.prepare(self)
 
    # 通知の責務
    UserMailer.order_confirmed(self).deliver_later
 
    # ポイントの責務
    LoyaltyService.add_points(user, total_amount)
 
    # 在庫アラートの責務(別の在庫管理ロジック)
    InventoryAlertService.check_low_stock(items)
 
    # 分析データ送信の責務
    Analytics.track('order_confirmed', order_id: id, amount: total_amount)
 
    # キャッシュの責務
    Rails.cache.delete("user_orders_#{user_id}")
  end
 
  # キャンセル — confirm の鏡像だが少し違う
  def cancel
    update!(status: 'cancelled')
    items.each do |item|
      Stock.find_by(product_id: item.product_id)
           .increment!(:quantity, item.quantity)
    end
    PaymentService.refund(self)
    UserMailer.order_cancelled(self).deliver_later
    LoyaltyService.remove_points(user, total_amount)
    Analytics.track('order_cancelled', order_id: id)
    Rails.cache.delete("user_orders_#{user_id}")
  end
 
  # 200行続く...
  # calculate_discount, apply_coupon, check_inventory, ...
end

Order は注文だけでなく、在庫管理、決済、配送準備、メール送信、ポイント、アラート、分析、キャッシュまで担っていた。

この構造の問題は何か。一つのメソッドを変えると、関係のない機能が壊れる可能性がある。PaymentService のインターフェースが変わると、Order#confirm を変えなければならない。メール送信の仕様が変わっても、在庫管理の処理が変わっても、すべてが Order に集中してくる。

WARNING

「神クラス(God Class)」と呼ばれるアンチパターンだ。すべてを知っているクラスは、すべての変更の影響を受ける。変更のたびにリスクが高まり、テストが書けなくなり、理解が困難になる。

テストはあったが、カバレッジは38%だった。しかもテストの中には、実際には何もテストしていない「テストのあるふり」が多かった。

# 意味のないテスト
it 'confirms an order' do
  order = create(:order)
  order.confirm
  expect(order.status).to eq('confirmed')
  # LoyaltyService, PaymentService, ShipmentService の副作用は検証していない
  # これらが失敗してもテストは通る
end

「もっとマシな設計があるはずだ」

ある夜、同僚のタカシがSlackにリンクを貼った。

「これ読んだことある? Eric Evans の "Domain-Driven Design"。うちのコードの問題、ここに全部書いてある気がする」

リナはその本をKindleで購入し、その夜から読み始めた。冒頭にこう書かれていた。

The heart of software is its ability to solve domain-related problems for its users. (ソフトウェアの本質は、ユーザーのドメイン関連の問題を解決する能力にある)

「ドメイン」という言葉が目に飛び込んできた。ビジネスの問題領域。FreshCartのコードは、その「ドメイン」を表現できていなかった。コードはデータベースの操作を記述しているだけで、「食料品を安全に届ける」というビジネスの問題を語っていなかった。

翌日、リナはタカシに声をかけた。

「DDDって実際に使ったことある?」

「前の会社でちょっとだけ。でもかなり深い概念があって、やり方を間違えるとかえって複雑になる。ただ、基本的な考え方は今のFreshCartのコードに絶対効く」

INFO

DDD(ドメイン駆動設計)は「技術のためのソフトウェア」から「ビジネスのためのソフトウェア」への転換を促すアプローチだ。コードがビジネスの言語を話せるようになると、変更が容易になり、バグが減り、チームのコミュニケーションが改善される。

現状の整理

リナはノートを開き、FreshCartの問題を書き出した。技術的な問題と、それがもたらすビジネス上の問題を両方書く。

技術的な問題:

  1. ビジネス用語がコードに存在しない(「在庫引き当て」「注文ライフサイクル」「顧客ロイヤルティ」)
  2. 責務が混在している(Order が在庫・決済・配送・通知・分析まで担う)
  3. 変更影響範囲が把握できない(どこを変えると何が壊れるか不明)
  4. テストが書けない(外部サービスへの直接依存、副作用が多すぎる)
  5. 同じ概念が複数の名前で実装されている(「在庫引き当て」が3種類)

ビジネス上の問題:

  1. 新機能の実装に時間がかかる(コードを理解するだけで1日かかる)
  2. バグが頻発する(在庫の二重引き当て、注文状態の不整合)
  3. ビジネス要件の変更がコードに反映しにくい(翻訳コストが高い)
  4. エンジニアの離職リスク(コードが複雑すぎて新メンバーが戦力化しない)
Loading diagram...

リナの決意

「DDDを学んで、FreshCartを変えよう」

ただし、リナには現実的な制約がある。

  • 2万行を超えるレガシーコードをゼロから書き直す時間はない
  • 8人のチームは日々の機能開発も止められない
  • ビジネスサイドは「技術的負債の返済」にはすぐに予算を出さない

だから「段階的に」進める必要がある。全部を変えなくていい。最も痛い箇所から始め、少しずつ改善する。

リナはDDDの全体像を学ぶために、まずエリック・エヴァンスの本を読み切ることにした。そして学んだことをFreshCartのコードに少しずつ適用していく。

これは、その旅の記録だ。

この物語で学ぶこと

13章を通じて、以下のDDDの概念をFreshCartの具体的なコードで学んでいく。

テーマFreshCartでの応用
3章ユビキタス言語「在庫引き当て」をコードに反映
4章境界づけられたコンテキスト「Product」の複数の意味を分離
5章エンティティ・値オブジェクトMoney 値オブジェクトの導入
6章集約Order 集約の設計
7章リポジトリテストしやすいデータアクセス層
8章ドメインサービス割引計算ロジックの整理
9章ドメインイベント注文確定後の後続処理の分離
10章アプリケーションサービス300行コントローラーを20行に
11章コンテキストマップ外部APIへの依存を分離
12章DDDとRailsの現実解段階的リファクタリング戦略

INFO

完璧なDDDを目指す必要はない。「コードがビジネスを語れるか」という問いを、日々少しずつ実践することが目標だ。リナのFreshCartは決して完璧なDDDの実装例ではなく、現実の泥臭い改善の記録だ。


深夜のデバッグ — 問題の根本を探る

翌日、リナはバグの根本原因を追うことにした。「在庫が二重で減算されている」というバグだが、どこで二重になっているのかがわからない。

まずログを時系列で整理した。

[00:01:23] POST /orders/12345/confirm - Started
[00:01:23] Order.find(12345) - Found
[00:01:23] Stock.find(product_id: 99) - qty: 5
[00:01:23] Stock.update qty: 5 -> 4
[00:01:23] OrderMailer.deliver_later
[00:01:24] POST /orders/12345/confirm - Started  ← 同じリクエストが2回!
[00:01:24] Order.find(12345) - Found
[00:01:24] Stock.find(product_id: 99) - qty: 4
[00:01:24] Stock.update qty: 4 -> 3

同じリクエストが2回送られていた。ユーザーが「注文確定」ボタンを二度タップしたのだ。

通常はサーバー側でべき等性(idempotency)を保証すべきだ。一度確定した注文を再度確定しようとしたら、エラーを返すか、無視するかすべきだ。

# 現状のコード: べき等性がない
def confirm!
  # pending? チェックがない
  self.status = 'confirmed'
  Stock.decrement!(...)  # 二重に実行される
end
 
# 修正後: べき等性を保証
def confirm!
  raise AlreadyConfirmed, "注文は既に確定されています" unless pending?
  # ...
end

しかしリナが気づいたのは、これが単なる「べき等性の欠如」以上の問題だということだ。

pending? を確認してから confirmed に変更するまでの間に、別のリクエストが入ったら?」

データベースのトランザクションでロックをかけていなければ、レースコンディションが発生する可能性がある。

# レースコンディションの例
# リクエストA:               リクエストB:
order = Order.find(12345)  # order = Order.find(12345)
order.pending?             # → true
                           # order.pending?  → true(Aがまだ保存していない)
order.confirm!             #
                           # order.confirm!  ← 両方が同時に実行される

この問題を解決するには、データベースレベルのロックが必要だ。

# 楽観的ロックを使う(DDDと相性が良い)
# migration: add_column :orders, :lock_version, :integer, default: 0
 
def confirm!
  raise AlreadyConfirmed unless pending?
  @status = OrderStatus::CONFIRMED
  # → Repositoryがlock_versionで競合を検知する
end
 
# repository
def save(domain_order)
  record = OrderRecord.find(domain_order.id)
  record.lock!  # SELECTとUPDATEの間をロック
  record.update!(
    status: domain_order.status.to_s,
    lock_version: record.lock_version + 1
  )
rescue ActiveRecord::StaleObjectError
  raise OrderConcurrentModification, "注文が同時に変更されました。もう一度試してください"
end

これは6章(集約)で詳しく学ぶ。今は「ドメインモデルがきちんと設計されていれば、このような問題が設計段階で見えてくる」という点を理解してほしい。

INFO

DDDの集約設計では「どのデータをまとめて1つのトランザクションで更新するか」を明示的に定義する。OrderStock を別々の集約として設計することで、それぞれの整合性保証の責務が明確になり、楽観的ロックをどこに適用すべきかが自然に見えてくる。

コードの匂い — DDD導入前の診断

リナはFreshCartのコードをDDDの観点から診断した。「コードの匂い(Code Smell)」として知られる問題パターンがいくつか見つかった。

匂い1: 意味のない変数名・メソッド名

# grep結果: 意味不明な名前の宝庫
def proc(t, f = false)  # process? procure? t は type か term か?
  ...
end
 
def upd_st(s)            # update_status? update_stock?
  ...
end
 
def chk(id)              # check? checkout?
  ...
end

匂い2: コメントがコードの代わりになっている

def confirm!
  # ステータスを2に更新(2 = confirmed)
  update_column(:status, 2)
 
  # 在庫を減らす
  items.each do |item|
    # product_idで在庫を検索して数量を引く
    Stock.where(product_id: item.product_id).first.decrement!(:qty, item.qty)
  end
 
  # 確認メール送信
  OrderMailer.delay.confirmation(id)
end

コメントが多いということは、コードが自明でないということだ。「ステータスを2に更新(2 = confirmed)」という行はコメントなしには理解できない。DDDでは、コードが自己説明的(self-documenting)であるべきで、コメントが不要な状態を目指す。

匂い3: 同じロジックが複数箇所に散在

$ grep -r "stock.decrement\|Stock.decrement\|stock.qty -=" app/ --include="*.rb" | wc -l
17

在庫を減らすロジックが17箇所にある。全てが同じ条件で動いているか保証できない。一つを変更しても他の16箇所が間違ったまま残る。

匂い4: 集約度が低すぎる(または高すぎる)テスト

# テストが実装の詳細を知りすぎている
it '注文確定でメールが送られる' do
  order = create(:order)
  expect(OrderMailer).to receive(:confirmation).with(order.id)
  expect(Stock).to receive(:decrement!).twice
  expect(User).to receive(:update!)
 
  order.confirm!
end

このテストは Order#confirm! の実装を逐一検証している。confirm! の実装が変わるたびにテストも壊れる。これは「実装に密結合したテスト(fragile test)」だ。DDDのドメインオブジェクトに対するテストは「振る舞い(behavior)」を検証すべきで、実装の詳細は問わない。

# DDDスタイルのテスト: 振る舞いを検証
it '注文確定後、状態がCONFIRMEDになる' do
  order = build_pending_order_with_items
  order.confirm!
  expect(order.status).to eq(OrderContext::OrderStatus::CONFIRMED)
end
 
it '注文確定でOrderConfirmedイベントが発生する' do
  order = build_pending_order_with_items
  order.confirm!
  expect(order.domain_events).to include(an_instance_of(OrderContext::OrderConfirmed))
end

これらの匂いを特定することで、リナはDDD導入の優先順位を決めることができた。

リナの決意

「DDDを学んで、FreshCartを変えよう」

ただし、リナには現実的な制約がある。

  • 2万行を超えるレガシーコードをゼロから書き直す時間はない
  • 8人のチームは日々の機能開発も止められない
  • ビジネスサイドは「技術的負債の返済」にはすぐに予算を出さない

だから「段階的に」進める必要がある。全部を変えなくていい。最も痛い箇所から始め、少しずつ改善する。

リナはDDDの全体像を学ぶために、まずエリック・エヴァンスの本を読み切ることにした。そして学んだことをFreshCartのコードに少しずつ適用していく。

これは、その旅の記録だ。

この物語で学ぶこと

13章を通じて、以下のDDDの概念をFreshCartの具体的なコードで学んでいく。

テーマFreshCartでの応用
3章ユビキタス言語「在庫引き当て」をコードに反映
4章境界づけられたコンテキスト「Product」の複数の意味を分離
5章エンティティ・値オブジェクトMoney 値オブジェクトの導入
6章集約Order 集約の設計
7章リポジトリテストしやすいデータアクセス層
8章ドメインサービス割引計算ロジックの整理
9章ドメインイベント注文確定後の後続処理の分離
10章アプリケーションサービス300行コントローラーを20行に
11章コンテキストマップ外部APIへの依存を分離
12章DDDとRailsの現実解段階的リファクタリング戦略

INFO

完璧なDDDを目指す必要はない。「コードがビジネスを語れるか」という問いを、日々少しずつ実践することが目標だ。リナのFreshCartは決して完璧なDDDの実装例ではなく、現実の泥臭い改善の記録だ。


次の章では、DDDの全体像を俯瞰する。戦略的パターンと戦術的パターン、2つの柱が何を解決するのかを見ていこう。