プロローグ — コードがビジネスを語れない
「また 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
endSTATUS_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 は別ファイルに定義されているが中身が複雑だ。
この「翻訳コスト」が問題だ。カズキが話す「在庫引き当て」をリナが「どのコードのこと?」と探し回る時間。要件の変更がコードのどこに影響するか把握できない恐怖。それが積み重なって、機能追加のたびに「また何かが壊れるかもしれない」という不安を生んでいた。
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, ...
endOrder は注文だけでなく、在庫管理、決済、配送準備、メール送信、ポイント、アラート、分析、キャッシュまで担っていた。
この構造の問題は何か。一つのメソッドを変えると、関係のない機能が壊れる可能性がある。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の問題を書き出した。技術的な問題と、それがもたらすビジネス上の問題を両方書く。
技術的な問題:
- ビジネス用語がコードに存在しない(「在庫引き当て」「注文ライフサイクル」「顧客ロイヤルティ」)
- 責務が混在している(
Orderが在庫・決済・配送・通知・分析まで担う) - 変更影響範囲が把握できない(どこを変えると何が壊れるか不明)
- テストが書けない(外部サービスへの直接依存、副作用が多すぎる)
- 同じ概念が複数の名前で実装されている(「在庫引き当て」が3種類)
ビジネス上の問題:
- 新機能の実装に時間がかかる(コードを理解するだけで1日かかる)
- バグが頻発する(在庫の二重引き当て、注文状態の不整合)
- ビジネス要件の変更がコードに反映しにくい(翻訳コストが高い)
- エンジニアの離職リスク(コードが複雑すぎて新メンバーが戦力化しない)
リナの決意
「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つのトランザクションで更新するか」を明示的に定義する。Order と Stock を別々の集約として設計することで、それぞれの整合性保証の責務が明確になり、楽観的ロックをどこに適用すべきかが自然に見えてくる。
コードの匂い — 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つの柱が何を解決するのかを見ていこう。