エピローグ — DDDは目的ではなく手段
6ヶ月が経った。
リナはFreshCartのコードベースを眺めていた。まだ完璧ではない。レガシーコードが残っている箇所もある。でも、何かが確実に変わっていた。
「在庫引き当てのロジックを変えたい」
カズキが言った。以前なら頭を抱えていたはずだ。Order モデルを開いて2000行のコードを上から読み始め、「在庫」という文字列を grep して5ファイルを渡り歩く。影響範囲が読めないから変更が怖くて、最終的に既存コードの周辺に無理矢理コードを挿入して、それが次のバグの種になる。
「StockReservationService の reserve メソッドを変更します。影響範囲は在庫管理コンテキスト内のみです。テストが80件あるので、壊したら即わかります」
リナは即答できた。
DDDで変わった6ヶ月
チームのコミュニケーションが変わった
Before:
カズキ: 「注文が確定したときに在庫を押さえる処理が欲しい」
リナ: 「確定したとき?finalize_orderメソッドを呼んだとき?それともステータスが
confirmedになったとき?在庫を押さえるって、どのテーブルのどのカラムを?」
(翻訳コスト: 15分の会話が必要)
After:
カズキ: 「OrderConfirmedイベントが発火したときに StockReservation を作りたい」
リナ: 「StockReservationHandlerに追加するだけです。30分で実装できます」
(共通言語で即理解: 2分で会話が終わる)
DDDを学ぶ前、チームとの会話でリナはいつも「翻訳」していた。ビジネスの言葉(在庫引き当て、注文確定)をコードの言葉(status: 'confirmed', stock.decrement!)に変換する作業だ。その翻訳には常にリスクがある。「確定する」と「fialize!を呼ぶ」は同じ意味か?開発者がそう判断したからそうなっているだけで、本当に同じかは誰も保証できない。
ユビキタス言語を導入してからは、StockReservation、OrderConfirmed、InventoryContext がコードにもSlackにもドキュメントにも現れるようになった。翻訳コストがなくなった。
コードの変更コストが変わった
6ヶ月間の数字:
| 指標 | DDD導入前 | DDD導入後 |
|---|---|---|
| 新機能の平均実装時間 | 3.2日 | 1.5日 |
| リリース後のバグ発生率 | 月平均8件 | 月平均2件 |
| コードレビューの時間 | 平均1.5時間 | 平均45分 |
| 新メンバーのオンボーディング | 2週間 | 1週間 |
バグの性質が変わった
DDD導入前のバグの多くは「意図せぬ副作用」だった。Order#confirm! を変更したら Stock の挙動が変わった。User モデルを更新したら注文のメール送信が重複した。これらは境界が不明確なコードが引き起こす問題だ。
DDD導入後、バグの多くは「ビジネス要件の誤解」になった。「20%割引と会員割引は重複適用できるか」という仕様の誤解。これは実は良いことだ。「意図せぬ副作用バグ」から「仕様誤解バグ」に変わった。前者は防ぎにくいが、後者はドメインエキスパートとの対話で防げる。
DDDの過剰適用の罠
一方で、リナはDDDを盲目的に適用した部分で大きな失敗も経験した。
失敗例1: シンプルなCRUDにDDDを適用した
「お知らせ管理」機能を実装するとき、リナは癖でDDDのパターンを適用した。
# 過剰適用: お知らせ管理にDDDを適用した結果(3日かかった)
module AnnouncementContext
class Announcement
attr_reader :id, :title, :body, :published_at
def initialize(id:, title:, body:, published_at: nil)
@id = id
@title = title
@body = body
@published_at = published_at
freeze
end
def published?
@published_at.present?
end
# これ以上ビジネスロジックがない...
end
module AnnouncementRepositoryInterface
def find(id); raise NotImplementedError; end
def save(announcement); raise NotImplementedError; end
def find_published; raise NotImplementedError; end
end
class ActiveRecordAnnouncementRepository
include AnnouncementRepositoryInterface
def find(id)
record = AnnouncementRecord.find(id)
Announcement.new(
id: record.id,
title: record.title,
body: record.body,
published_at: record.published_at
)
end
def save(announcement)
record = AnnouncementRecord.find_or_initialize_by(id: announcement.id)
record.update!(
title: announcement.title,
body: announcement.body,
published_at: announcement.published_at
)
end
end
class CreateAnnouncementUseCase
def call(title:, body:)
announcement = Announcement.new(id: SecureRandom.uuid, title: title, body: body)
@repository.save(announcement)
UseCaseResult.success(announcement)
end
end
end3日かけて実装したこのコードは、タカシのコードレビューで即却下された。
「何のためのリポジトリ?Announcement ドメインオブジェクトに何のビジネスロジックがある?published? だけ?それ published_at.present? でいいじゃないか」
# 正解: シンプルなCRUDのままで良かった(3時間で実装)
class Announcement < ApplicationRecord
validates :title, :body, presence: true
validates :title, length: { maximum: 100 }
scope :published, -> { where.not(published_at: nil).order(published_at: :desc) }
scope :draft, -> { where(published_at: nil) }
def published?
published_at.present?
end
def publish!
update!(published_at: Time.current)
end
end6行で済んだ。
WARNING
すべてのモデルにドメインオブジェクトを作ってはいけない。「このモデルに複雑なビジネスルールはあるか?状態遷移はあるか?複数の外部要因が絡み合うか?」と自問する。Noなら、ActiveRecordのシンプルなCRUDで十分だ。DDDはコストのかかる投資だ。ROIを考えろ。
失敗例2: 集約を細かくしすぎた
DDDの本を読みすぎたリナは、全てを値オブジェクトにしようとした。
# やりすぎ: 粒度が細かすぎる値オブジェクト群
class OrderItemQuantity
attr_reader :value
def initialize(value)
raise ArgumentError, "数量は1以上" if value < 1
@value = Integer(value)
freeze
end
def +(other) = OrderItemQuantity.new(@value + other.value)
end
class OrderItemProductId
attr_reader :value
def initialize(value)
raise ArgumentError, "商品IDは必須" if value.blank?
@value = value.to_s
freeze
end
end
class OrderItemProductName
attr_reader :value
def initialize(value)
raise ArgumentError, "商品名は必須" if value.blank?
@value = value.to_s
freeze
end
endチームメンバーから「もうRubyのIntegerじゃだめなんですか?」と言われて気づいた。値オブジェクトは「概念として重要な意味を持つ値」に使うべきで、単なる型安全のためではない。
# バランスが取れた粒度
# Money: 金額と通貨が不可分 → 値オブジェクト
# DeliveryAddress: 複数のフィールドが1つの「住所」という概念 → 値オブジェクト
# 商品ID: ただの文字列 → Stringのまま
# 数量: 1以上の整数 → Integerのまま(バリデーションはOrderItemエンティティで)
module OrderContext
class OrderItem
attr_reader :id, :product_id, :product_name, :unit_price, :quantity
def initialize(id:, product_id:, product_name:, unit_price:, quantity:)
raise ArgumentError, "数量は1以上" if quantity < 1
@id = id
@product_id = product_id # String — そのまま
@product_name = product_name # String — そのまま
@unit_price = unit_price # Money — 値オブジェクト(通貨の概念があるから)
@quantity = Integer(quantity) # Integer — そのまま
freeze
end
def subtotal
unit_price * quantity
end
end
end失敗例3: 理想を追いすぎて既存コードを触れなくなった
DDDを学ぶと「このコードは間違っている」という視点が生まれる。しかし全てを「正しいDDD」に書き直そうとすると、永遠に終わらない。
リナが学んだルール: 「変更が必要なときにリファクタリングする。変更しないコードはそのまま残す」
# レガシーコードを抱えたファイルへのコミット
# Bad: 全部を「正しいDDD」に書き直そうとする
# Good: 変更するメソッドだけをDDDパターンに改善する
# たとえば cancel! を変更するなら、confirm! も一緒に直したくなるが、
# confirm! は今触らなくていい。cancel! だけ直す。
class Order < ApplicationRecord
# 既存のままのレガシーコード(触らない)
def confirm!
# ... 100行のレガシーコード
end
# 新しく書き直したキャンセル処理(ドメインオブジェクトに委譲)
def cancel!(reason:)
domain_order = OrderContext::Order.restore_from_record(self)
domain_order.cancel!(reason: reason)
update!(status: domain_order.status.to_s, cancelled_at: domain_order.cancelled_at)
domain_order.domain_events.each { |event| EventBus.publish(event) }
end
endINFO
「ボーイスカウトルール」(来たときよりもきれいに帰れ)をDDDに適用する。変更するファイルだけを少し良くする。変更しないコードは触らない。1年後には、よく変更されるコードが自然に綺麗になっている。
DDDの段階的導入の知恵
リナが6ヶ月で学んだ「DDDの正しい導入方法」をまとめる。
ステップ1: 言語から始める(Week 1〜2)
コードは触らない。まずチームの用語を統一する。
# FreshCart 用語統一宣言 v1.0
## 廃止する言葉 → 採用する言葉
- 「在庫引き当て / inventory hold / stock lock」→ StockReservation
- 「注文確定 / finalize / complete」→ OrderConfirmation
- 「ユーザー(注文文脈)」→ Customer(アカウント文脈ではUserのまま)
- 「商品(注文文脈)」→ OrderableProduct(カタログ文脈ではProductのまま)
## 合意ルール
- Slackでの会話でも上記の言葉を使う
- PRのタイトルとコミットメッセージに用語を含める
- ドキュメントの改訂時に古い言葉を修正する言語の統一は、コードを一行も変えずにチームの認知負荷を下げる最速の方法だ。
ステップ2: 最も痛い箇所を特定する(Week 3〜4)
全部を変えようとしない。「最もバグが多い」「最も変更が難しい」箇所を特定し、そこから始める。
# バグトラッカーと git log から定量的に分析する
# ツール: git log で変更頻度を確認
# git log で最もコミット数が多いファイルを確認
# $ git log --format=format: --name-only | sort | uniq -c | sort -rg | head -20
#
# 結果例:
# 89 app/models/order.rb ← 最も変更が多い
# 67 app/models/stock.rb
# 45 app/controllers/orders_controller.rb
# 23 app/models/product.rb
# バグトラッカーから: 過去3ヶ月で最もバグが発生したモデル
# Order: 23件
# Stock: 15件
# → OrderとStockをDDDでリファクタリングすることが最大のROIステップ3: テストを先に書く(保険をかける)
リファクタリング前に現在の振る舞いをテストで記録する。これが安全網になる。
# Orderのリファクタリング前に、現在の振る舞いをテストで記録
# 「キャラクタライゼーションテスト」と呼ばれる技法
RSpec.describe '注文確定の現在の振る舞い(変更前の記録)' do
it '在庫がある場合、注文が確定される' do
order = create(:order, :pending, :with_items)
create(:stock, product: order.order_items.first.product, quantity: 100)
expect { order.confirm! }.to change { order.reload.status }.to('confirmed')
end
it '在庫がない場合、例外が発生する' do
order = create(:order, :pending, :with_items)
create(:stock, product: order.order_items.first.product, quantity: 0)
expect { order.confirm! }.to raise_error(ActiveRecord::RecordInvalid)
end
it '確定後にメールが送信される' do
order = create(:order, :pending, :with_items)
create(:stock, product: order.order_items.first.product, quantity: 100)
expect { order.confirm! }.to have_enqueued_mail(OrderMailer, :confirmation)
end
endリファクタリング後もこのテストが全てパスすれば、外部からの振る舞いが変わっていないことが保証される。
ステップ4: 小さく、頻繁にリファクタリング
大きなリファクタリングPRは危険だ。コードレビューが困難になり、マージコンフリクトが増え、何かが壊れたときの原因特定が難しくなる。
Week 1: MoneyクラスをOrderItemに導入(30行の変更、PR #412)
Week 2: DeliveryAddressを値オブジェクトに(50行の変更、PR #425)
Week 3: OrderStatusをEnumクラスに(40行の変更、PR #438)
Week 4: OrderエンティティをActiveRecordから分離開始(80行、PR #451)
Week 5: InMemoryOrderRepositoryを追加(60行、PR #463)
Week 6: ConfirmOrderUseCaseを作成(100行、PR #477)
Week 7: OrdersControllerをUserCaseに委譲(70行、PR #489)
Week 8: ドメインイベント基盤を構築(90行、PR #502)
8週間で500行の変更。週平均60行。これは安全なペースだ。
DDDはいつ「やりすぎ」か — 判断基準
| 状況 | 推奨アプローチ |
|---|---|
| チームが3人以下・短期プロジェクト | シンプルなRails規約で十分。DDDは重い |
| ビジネスルールが安定・変化が少ない | Serviceクラス程度で十分。集約は不要 |
| CRUDが主体(ブログ、FAQ、お知らせ) | ActiveRecordのまま。DDDは過剰 |
| ドメインが複雑・チームが5人以上 | DDDの投資が報われる |
| 長期運用・頻繁な仕様変更 | DDDによる柔軟性が価値を持つ |
| 外部システムとの複雑な連携 | ACL・コンテキストマップが特に有効 |
INFO
FreshCartがDDDを採用すべき根拠: 8人のチーム、5年間の運用計画、注文・在庫・配送・決済という複雑な4コンテキスト、外部API(Stripe、ヤマト運輸)との連携、週に3〜5の機能変更。これら全てが揃って初めてDDDのコストに見合う。
ソフトウェアの本質に向き合う
リナは最初に読んだエリック・エヴァンスの言葉を思い出した。
The heart of software is its ability to solve domain-related problems for its users.
ソフトウェアの本質は、ビジネスの問題を解決することだ。DDDは、コードがその問題をより明確に表現するための道具に過ぎない。
6ヶ月前、リナは深夜に在庫の二重引き当てバグを調査していた。grep -r "stock" app/ --include="*.rb" を実行したら20ファイルにヒットして、どれが正しい実装かわからなかった。
今は違う。
# 在庫引き当てに関するコードを探す
$ find app/domains/inventory_context -name "*.rb"
app/domains/inventory_context/stock.rb
app/domains/inventory_context/stock_reservation.rb
app/domains/inventory_context/services/stock_reservation_service.rb
# 3ファイルだけ。全部読むのに10分かからないコードがビジネスを語れるようになった。
「在庫の引き当て」というビジネス概念が StockReservationService という1つのクラスに閉じている。バグが起きたとき、調査する場所が明確だ。変更するとき、影響範囲が予測できる。新メンバーが入ったとき、どのコードが何をしているか説明しやすい。
DDDを始めるための最初の3ステップ
ステップ1: 用語集を作る
チームでホワイトボードを囲み、30分で合意できる用語を10個決める。既存のコードは変えなくていい。
ステップ2: 最も痛い箇所を特定する
git log --format=format: --name-only | sort | uniq -c | sort -rg | head -10 でよく変更されるファイルを特定する。バグトラッカーで最も問題が多いモデルを確認する。
ステップ3: 値オブジェクトを1つ抽出する
Money、Email、PostalCode など、明確な概念を持つ値を1つ選んで値オブジェクトにする。30行の変更でリスクは低い。成功体験を積む。
DDDは大きな設計変更ではない。「コードがビジネスを語れるか」という問いを、毎日少しずつ実践することだ。
6ヶ月後のFreshCart技術スタック
リナがDDDを導入した後のFreshCartのアーキテクチャは、こうなった。
app/
├── models/ # ActiveRecord(薄い・DBアクセスのみ)
│ ├── order.rb # 150行(スコープ・バリデーション)
│ └── stock.rb # 80行
├── domains/ # ドメインオブジェクト(ビジネスロジック)
│ ├── order_context/
│ │ ├── order.rb # 250行(集約ルート)
│ │ ├── order_item.rb
│ │ ├── order_status.rb
│ │ └── services/
│ │ ├── discount_calculation_service.rb
│ │ └── stock_reservation_service.rb
│ ├── inventory_context/
│ │ └── stock.rb # 120行
│ └── shared_kernel/
│ ├── money.rb
│ └── pagination.rb
├── use_cases/ # アプリケーションサービス(調整役)
│ ├── place_order_use_case.rb # 120行
│ ├── confirm_order_use_case.rb # 80行
│ └── cancel_order_use_case.rb # 70行
├── repositories/ # リポジトリ実装
│ ├── active_record_order_repository.rb # 本番用
│ └── in_memory_order_repository.rb # テスト用
└── adapters/ # ACL
├── stripe_payment_adapter.rb
└── yamato_shipping_adapter.rb
テストカバレッジ: 78%(導入前: 45%) ユニットテストの平均実行時間: 3ms(導入前: 300ms) 月次インシデント数: 2件(導入前: 8件)
Golang との比較 — 型システムが強制する明確さ
後日、FreshCartはマイクロサービス化を検討し始めた。Golangでの実装も試みた。
// Golangではインターフェースが依存性を明確にする
type OrderRepository interface {
FindByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, order *Order) error
FindByCustomer(ctx context.Context, customerID string, pagination Pagination) ([]*Order, error)
}
// 具体実装はインターフェースを満たす
type postgresOrderRepository struct {
db *sql.DB
}
func (r *postgresOrderRepository) FindByID(ctx context.Context, id string) (*Order, error) {
// ...
}
// ユースケースはインターフェースに依存(具体実装を知らない)
type ConfirmOrderUseCase struct {
orderRepo OrderRepository // インターフェース
stockSvc StockReservationService
eventBus EventBus
}Golangの型システムは、Rubyよりも明示的にインターフェースの依存を強制する。コンパイル時にインターフェースの不一致が検出される。
Railsでは duck typing で同じことを実現するが、実行時まで型エラーが発見されない。そのためRSpect + InMemoryリポジトリによるテストが重要になる。
DDDで生まれた副産物
DDDを適用する中で、リナは予期しない副産物を得た。
副産物1: オンボーディングが速くなった
新メンバーのユウキが入社した時、リナは domains/order_context/ ディレクトリを見せるだけで良かった。「注文処理のビジネスロジックは全部ここにある。ユビキタス言語の用語集はこのファイル。読めばFreshCartの注文フローが理解できる」
以前は2週間かかっていたオンボーディングが1週間になった。
副産物2: ビジネス要件の曖昧さが見えるようになった
「複数の割引が重複した場合の計算ルールを教えてください」とリナがカズキに聞いたのは、 DiscountCalculationService を実装しようとした時だった。
カズキは黙ってしまった。「...そういえば、ゴールド会員の10%割引とキャンペーンの5%割引が重複した場合、どっちが先に計算されるか決めてなかった」
コードを書こうとして初めて、ビジネスの仕様の穴が見つかった。DDDがドメインエキスパートとの対話を促した。
副産物3: インフラ変更への耐性
「来月からElastiCacheをValKeyに移行する」という決定が来た。以前なら Rails.cache.delete(...) が全ファイルに散在していたため、影響範囲の把握だけで2日かかっていた。
DDDを適用した後は、キャッシュアクセスが OrderCacheRepository に集約されていたため、変更箇所は1ファイルだけだった。
まとめ — リナが学んだこと
1. コードはビジネスの言葉で書く(ユビキタス言語)
2. 責務を明確に分ける(境界づけられたコンテキスト)
3. 状態と振る舞いを一緒に置く(エンティティ・値オブジェクト)
4. 変更を1箇所に閉じる(集約)
5. テストしやすく設計する(リポジトリ・依存性注入)
6. 副作用を宣言する(ドメインイベント)
7. 過剰適用しない(DDDは手段であって目的ではない)
「次は何をDDDで改善しようか」
リナはコーヒーを飲みながら、FreshCartのコードを眺めた。決済コンテキストにまだレガシーコードが残っている。配送追跡の状態管理も複雑だ。やるべきことはまだたくさんある。
でも、もう怖くない。
コードはビジネスを語り始めていた。そして、ビジネスが変わるたびに、コードも一緒に成長できるようになっていた。
この物語の主人公・リナと FreshCart は架空ですが、DDDのパターンはすべて実際に適用可能な技術です。あなたのプロジェクトにも、必ず「最も痛い箇所」があるはずです。そこから、1つの値オブジェクトを始めてみてください。