mybook

エピローグ — DDDは目的ではなく手段

6ヶ月が経った。

リナはFreshCartのコードベースを眺めていた。まだ完璧ではない。レガシーコードが残っている箇所もある。でも、何かが確実に変わっていた。

「在庫引き当てのロジックを変えたい」

カズキが言った。以前なら頭を抱えていたはずだ。Order モデルを開いて2000行のコードを上から読み始め、「在庫」という文字列を grep して5ファイルを渡り歩く。影響範囲が読めないから変更が怖くて、最終的に既存コードの周辺に無理矢理コードを挿入して、それが次のバグの種になる。

StockReservationServicereserve メソッドを変更します。影響範囲は在庫管理コンテキスト内のみです。テストが80件あるので、壊したら即わかります」

リナは即答できた。

DDDで変わった6ヶ月

チームのコミュニケーションが変わった

Before:
カズキ: 「注文が確定したときに在庫を押さえる処理が欲しい」
リナ: 「確定したとき?finalize_orderメソッドを呼んだとき?それともステータスが
       confirmedになったとき?在庫を押さえるって、どのテーブルのどのカラムを?」
(翻訳コスト: 15分の会話が必要)

After:
カズキ: 「OrderConfirmedイベントが発火したときに StockReservation を作りたい」
リナ: 「StockReservationHandlerに追加するだけです。30分で実装できます」
(共通言語で即理解: 2分で会話が終わる)

DDDを学ぶ前、チームとの会話でリナはいつも「翻訳」していた。ビジネスの言葉(在庫引き当て、注文確定)をコードの言葉(status: 'confirmed', stock.decrement!)に変換する作業だ。その翻訳には常にリスクがある。「確定する」と「fialize!を呼ぶ」は同じ意味か?開発者がそう判断したからそうなっているだけで、本当に同じかは誰も保証できない。

ユビキタス言語を導入してからは、StockReservationOrderConfirmedInventoryContext がコードにもSlackにもドキュメントにも現れるようになった。翻訳コストがなくなった。

コードの変更コストが変わった

Loading diagram...

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
end

3日かけて実装したこのコードは、タカシのコードレビューで即却下された。

「何のためのリポジトリ?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
end

6行で済んだ。

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
end

INFO

「ボーイスカウトルール」(来たときよりもきれいに帰れ)を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ステップ

Loading diagram...

ステップ1: 用語集を作る

チームでホワイトボードを囲み、30分で合意できる用語を10個決める。既存のコードは変えなくていい。

ステップ2: 最も痛い箇所を特定する

git log --format=format: --name-only | sort | uniq -c | sort -rg | head -10 でよく変更されるファイルを特定する。バグトラッカーで最も問題が多いモデルを確認する。

ステップ3: 値オブジェクトを1つ抽出する

MoneyEmailPostalCode など、明確な概念を持つ値を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つの値オブジェクトを始めてみてください。