mybook

DDDとRailsの現実解 — Fat Model を超えて

「理想はわかった。でも、今ある2万行のコードをどうするんだ」

タカシの言葉がリナの胸に刺さった。DDDの概念を学んだはいいが、現実には5年分の負債が積み重なったRailsのコードベースがある。ゼロから書き直す余裕はない。スプリントのバックログは常に満杯で、新機能の要求は止まらない。

「段階的に変えるしかない」とリナは答えた。「全部を一度に変えようとすると壊す。でも、触る場所から少しずつ変えていけば、1年後には違うコードベースになってる」

なぜFat Modelが生まれるか

Railsの規約は素晴らしい。しかし「どこに何を書くか」の指針を持たないと、Fat Modelに向かう引力が働く。

# FreshCartの現実: 2000行になったOrderモデル
class Order < ApplicationRecord
  belongs_to :user
  has_many :order_items
  has_one :shipment
  has_one :payment
 
  validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
 
  # ビジネスロジックが全部入る(2000行の内訳)
  def confirm!; end                   # 注文確定
  def cancel!; end                    # 注文キャンセル
  def calculate_discount; end         # 割引計算
  def apply_coupon(code); end         # クーポン適用
  def send_confirmation_email; end    # メール送信
  def update_inventory; end           # 在庫更新
  def add_loyalty_points; end         # ポイント付与
  def calculate_tax; end              # 税金計算
  def validate_address; end           # 住所バリデーション
  def format_for_invoice; end         # 請求書フォーマット
  def sync_with_erp; end              # 基幹システム連携
  def to_shipping_label; end          # 配送ラベル生成
  # ... まだ200行ある
end

Railsを5年使うと、自然にこうなる。原因はRailsの「モデル = ビジネスロジック置き場」という解釈が広まっているから。しかしこれは間違いで、Railsのモデルは「ActiveRecord = O/Rマッパー」が本来の責務だ。

現実的な配置戦略

Railsのモノリスで段階的にDDDを適用する戦略を紹介する。

ディレクトリ構造

app/
├── models/               # ActiveRecord モデル(データ層)
│   ├── order.rb          # ORM + DB制約 + 最小限のスコープ
│   ├── order_item.rb
│   └── product.rb
├── domains/              # ドメインオブジェクト(ビジネスロジック層)
│   ├── order_context/
│   │   ├── order.rb                # エンティティ(集約ルート)
│   │   ├── order_item.rb           # エンティティ(内部)
│   │   ├── order_status.rb         # 値オブジェクト
│   │   ├── delivery_address.rb     # 値オブジェクト
│   │   └── services/
│   │       ├── discount_calculation_service.rb
│   │       └── stock_reservation_service.rb
│   ├── inventory_context/
│   │   ├── stock.rb
│   │   └── services/
│   │       └── stock_reservation_service.rb
│   └── shared_kernel/
│       ├── money.rb
│       └── pagination.rb
├── use_cases/            # アプリケーションサービス
│   ├── place_order_use_case.rb
│   ├── confirm_order_use_case.rb
│   └── cancel_order_use_case.rb
├── repositories/         # リポジトリ実装
│   ├── active_record_order_repository.rb
│   └── in_memory_order_repository.rb
├── adapters/             # ACL・外部APIアダプター
│   ├── stripe_payment_adapter.rb
│   ├── yamato_shipping_adapter.rb
│   └── catalog_service_adapter.rb
└── presenters/           # 出力変換
    ├── order_presenter.rb
    └── order_list_presenter.rb

この構造の重要な点は app/models/app/domains/ が分離されていること。ActiveRecordモデルはデータ層で、ドメインオブジェクトはビジネスロジック層だ。

ActiveRecordモデルを薄くする

# app/models/order.rb — 薄いActiveRecordモデル(責務を絞る)
class Order < ApplicationRecord
  # アソシエーション
  belongs_to :customer
  has_many :order_items, dependent: :destroy
  has_many :products, through: :order_items
 
  # DBレベルのバリデーション(ドメインルールとは別)
  validates :customer_id, :status, presence: true
  validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
  validates :total_amount_cents, numericality: { greater_than_or_equal_to: 0 }
 
  # 便利なスコープ(SQLクエリ最適化)
  scope :pending,     -> { where(status: 'pending') }
  scope :confirmed,   -> { where(status: 'confirmed') }
  scope :by_customer, ->(customer_id) { where(customer_id: customer_id) }
  scope :recent,      -> { order(created_at: :desc) }
  scope :stale_pending, -> { pending.where('created_at < ?', 30.minutes.ago) }
  scope :this_month,  -> { where(created_at: Time.current.all_month) }
 
  # ビジネスロジックはここに書かない
  # confirm!, cancel!, calculate_discount はドメインオブジェクトの責務
end

INFO

ActiveRecordモデルに残すもの: アソシエーション、DBバリデーション、クエリスコープ。ビジネスロジック(状態遷移ルール、計算ロジック、副作用)は全てドメインオブジェクトとユースケースに移す。

段階的リファクタリング戦略

ゼロから書き直すのではなく、段階的に改善する。リナが選んだ3フェーズのアプローチ。

フェーズ1: 値オブジェクトを抽出する(1〜2週間)

まず、コードに散らばる「値の概念」を値オブジェクトに抽出する。リスクが最も低い。既存コードを壊さずに始められる。

# Before: 金額をIntegerで扱っていた(浮動小数点の罠あり)
class Order < ApplicationRecord
  def total_price
    items.sum { |item| item.price * item.quantity }
  end
 
  def apply_discount(percentage)
    total_price * (1 - percentage / 100.0)  # 0.3 * 0.1 = 0.030000000000000002
  end
 
  def apply_coupon(amount)
    [total_price - amount, 0].max  # 負にならないようにガード
  end
end
 
# After: Moneyオブジェクトで精度を保証
# app/domains/shared_kernel/money.rb
module SharedKernel
  class Money
    include Comparable
    attr_reader :amount, :currency
 
    def initialize(amount:, currency: :jpy)
      @amount = Rational(amount)  # Rationalで精度保証
      @currency = currency.to_sym
      freeze
    end
 
    def +(other)
      assert_same_currency!(other)
      Money.new(amount: @amount + other.amount, currency: @currency)
    end
 
    def -(other)
      assert_same_currency!(other)
      result = @amount - other.amount
      raise NegativeAmount, "金額が負になります" if result < 0
      Money.new(amount: result, currency: @currency)
    end
 
    def *(multiplier)
      Money.new(amount: @amount * multiplier, currency: @currency)
    end
 
    def apply_discount(rate)
      raise ArgumentError, "割引率は0〜1の間で指定してください" unless (0..1).include?(rate)
      discount = (@amount * Rational(rate)).floor  # 切り捨て(顧客に有利)
      Money.new(amount: @amount - discount, currency: @currency)
    end
 
    def <=>(other)
      return nil unless other.is_a?(Money) && currency == other.currency
      amount <=> other.amount
    end
 
    def to_i
      @amount.to_i
    end
 
    def zero?
      @amount.zero?
    end
 
    private
 
    def assert_same_currency!(other)
      raise CurrencyMismatch, "通貨が異なります: #{currency} vs #{other.currency}" if currency != other.currency
    end
  end
end
 
# ActiveRecordモデルへの統合(composed_of)
class Order < ApplicationRecord
  composed_of :total_price,
    class_name: 'SharedKernel::Money',
    mapping: [%w[total_amount_cents amount], %w[currency currency]]
end

フェーズ1の作業リスト:

  • Money 値オブジェクトの作成と composed_of での統合
  • DeliveryAddress 値オブジェクトの作成と住所フォーマットロジックの移行
  • OrderStatus 値オブジェクトの作成(状態遷移ルールの集約)

各ステップで必ず既存テストを実行し、リグレッションがないことを確認する。

フェーズ2: ユースケースに副作用を切り出す(2〜4週間)

Fat Modelのメソッドから「副作用」(メール送信、在庫更新など)をユースケースに移す。

# Before: Orderモデルに全部入り(副作用のスパゲッティ)
class Order < ApplicationRecord
  def confirm!
    with_lock do
      raise "既に確定されています" if confirmed?
      raise "注文が空です" if order_items.empty?
 
      transaction do
        update!(status: 'confirmed', confirmed_at: Time.current)
 
        # 副作用1: 在庫更新
        order_items.each do |item|
          stock = Stock.find_by!(product_id: item.product_id)
          raise "在庫不足" if stock.quantity < item.quantity
          stock.decrement!(:quantity, item.quantity)
        end
 
        # 副作用2: メール送信
        OrderMailer.confirmation(self).deliver_later
 
        # 副作用3: ポイント付与
        user.increment!(:loyalty_points, (total_amount_cents / 100))
 
        # 副作用4: 基幹システム同期
        ErpSyncJob.perform_later(id)
      end
    end
  end
end
# After Step 1: ドメインオブジェクトに状態変化のみ(副作用なし)
module OrderContext
  class Order
    def confirm!
      raise InvalidTransition, "pending状態のみ確定できます" unless pending?
      raise EmptyOrder, "注文が空です" if order_items.empty?
 
      @status = OrderStatus::CONFIRMED
      @confirmed_at = Time.current
      add_domain_event(OrderConfirmed.new(order_id: @id, order_items: @order_items))
    end
 
    def pending?
      @status == OrderStatus::PENDING
    end
  end
end
 
# After Step 2: ユースケースが副作用を調整(確認済み副作用のリスト)
class ConfirmOrderUseCase
  def initialize(
    order_repository:,
    stock_reservation_service:,
    event_bus:
  )
    @order_repository = order_repository
    @stock_reservation_service = stock_reservation_service
    @event_bus = event_bus
  end
 
  def call(order_id:, customer_id:)
    order = @order_repository.find(order_id)
 
    # 認可
    raise Unauthorized unless order.belongs_to_customer?(customer_id)
 
    # ドメインロジック(状態変化)
    order.confirm!
 
    # インフラ処理(トランザクション内で原子的に)
    @order_repository.transaction do
      @stock_reservation_service.reserve_for_order(order)
      @order_repository.save(order)
      @order_repository.store_outbox_events(order.domain_events)
    end
 
    # イベント発行(トランザクション外)
    @event_bus.publish_all(order.domain_events)
 
    UseCaseResult.success(order)
  rescue OrderContext::InvalidTransition => e
    UseCaseResult.failure(:invalid_transition, e.message)
  rescue StockReservationFailed => e
    UseCaseResult.failure(:insufficient_stock, e.message)
  end
end

フェーズ3: ドメインオブジェクトを完全に分離する(1〜2ヶ月)

ActiveRecordモデルとは完全に独立したドメインオブジェクトを構築し、リポジトリで橋渡しする。

# app/repositories/active_record_order_repository.rb
class ActiveRecordOrderRepository
  include OrderContext::OrderRepositoryInterface
 
  def find(id)
    record = ::Order
      .includes(:order_items, :customer)
      .find(id)
    reconstruct(record)
  rescue ActiveRecord::RecordNotFound
    raise OrderNotFound, "注文(#{id})が見つかりません"
  end
 
  def save(domain_order)
    record = ::Order.find_or_initialize_by(id: domain_order.id)
    record.assign_attributes(
      customer_id: domain_order.customer_id,
      status: domain_order.status.to_s,
      total_amount_cents: domain_order.total_amount.to_i,
      currency: domain_order.total_amount.currency.to_s,
      confirmed_at: domain_order.confirmed_at,
      cancelled_at: domain_order.cancelled_at
    )
 
    ::ActiveRecord::Base.transaction do
      record.save!
      sync_order_items(record, domain_order.order_items)
    end
  end
 
  private
 
  # ActiveRecord → ドメインオブジェクトへの変換
  def reconstruct(record)
    order_items = record.order_items.map do |item_record|
      OrderContext::OrderItem.new(
        id: item_record.id,
        product_id: item_record.product_id,
        product_name: item_record.product_name,
        unit_price: SharedKernel::Money.new(
          amount: item_record.unit_price_cents,
          currency: item_record.currency.to_sym
        ),
        quantity: item_record.quantity
      )
    end
 
    OrderContext::Order.restore(
      id: record.id,
      customer_id: record.customer_id,
      status: OrderContext::OrderStatus.from_string(record.status),
      order_items: order_items,
      total_amount: SharedKernel::Money.new(
        amount: record.total_amount_cents,
        currency: record.currency.to_sym
      ),
      confirmed_at: record.confirmed_at,
      cancelled_at: record.cancelled_at,
      created_at: record.created_at
    )
  end
 
  def sync_order_items(order_record, domain_items)
    existing_ids = order_record.order_items.pluck(:id)
    new_ids = domain_items.map(&:id).compact
 
    # 削除されたアイテムを除去
    to_delete = existing_ids - new_ids
    order_record.order_items.where(id: to_delete).destroy_all
 
    domain_items.each do |item|
      item_record = order_record.order_items.find_or_initialize_by(id: item.id)
      item_record.assign_attributes(
        product_id: item.product_id,
        product_name: item.product_name,
        unit_price_cents: item.unit_price.to_i,
        currency: item.unit_price.currency.to_s,
        quantity: item.quantity
      )
      item_record.save!
    end
  end
end

WARNING

フェーズ3への移行は慎重に。ActiveRecordモデルとドメインオブジェクトが並存する「移行期間」は避けられないが、この期間は「どちらで保存するか」のルールを明確にしておかないと二重書き込みや不整合が発生する。リポジトリを通じた保存に統一する日付を決めて、移行チケットを作成すること。

よくある「なんちゃってDDD」の罠

リナのチームが最初の3ヶ月で踏んだ罠をまとめる。

罠1: サービスオブジェクトにドメインロジックを漏らす

# NG: ドメインロジックがユースケースに漏れている
class ConfirmOrderUseCase
  def call(order_id:)
    order = order_repository.find(order_id)
 
    # ↓ これはドメインロジック。OrderエンティティかDomainServiceが担うべき
    raise "pending状態でないと確定できません" if order.status != 'pending'
    raise "空の注文は確定できません" if order.order_items.empty?
    raise "在庫不足です" if order.order_items.any? { |i| i.quantity > stock_for(i) }
 
    # ユースケースが状態を直接変更している
    order.status = 'confirmed'
    order_repository.save(order)
  end
end
 
# OK: ドメインロジックはドメインオブジェクト内に閉じている
class ConfirmOrderUseCase
  def call(order_id:)
    order = order_repository.find(order_id)
    order.confirm!   # ← ドメインオブジェクトがルールを持つ
    order_repository.save(order)
  end
end
 
class OrderContext::Order
  def confirm!
    raise InvalidTransition unless pending?
    raise EmptyOrder if order_items.empty?
    # ← 在庫チェックはDomainServiceが担う(外部情報が必要)
    @status = OrderStatus::CONFIRMED
  end
end

罠2: リポジトリをActiveRecordのラッパーにする

# NG: ActiveRecordをそのまま委譲するだけ(リポジトリパターンの意味がない)
class OrderRepository
  def find(id)
    ::Order.find(id)          # ActiveRecordモデルをそのまま返す
  end
 
  def find_pending
    ::Order.pending           # ActiveRecordのスコープをそのまま返す
  end
 
  def where_customer(customer_id)
    ::Order.where(customer_id: customer_id)  # クエリをそのまま返す
  end
end
 
# OK: ドメインオブジェクトを返す / ドメインの言語でメソッドを定義する
class OrderRepository
  def find(id)
    record = ::Order.find(id)
    reconstruct(record)          # ← ドメインオブジェクトに変換
  end
 
  def find_pending_for_customer(customer_id)
    ::Order
      .pending                   # ← スコープはリポジトリ内に閉じる
      .by_customer(customer_id)
      .includes(:order_items)    # ← eager loading もリポジトリが責任を持つ
      .map { |r| reconstruct(r) }
  end
end

罠3: ドメインオブジェクトがインフラ(ActiveRecord)を知っている

# NG: ドメインオブジェクトがDBを知っている(依存関係が逆転している)
module OrderContext
  class Order
    def save!
      # ドメインオブジェクトがActiveRecordを直接使う
      ::Order.find_or_initialize_by(id: @id).update!(
        status: @status.to_s,
        total_amount_cents: @total_amount.to_i
      )
    end
  end
end
 
# OK: ドメインオブジェクトはインフラを一切知らない
module OrderContext
  class Order
    # 保存ロジックは一切ない
    # 状態変化とドメインイベントのみ
    def confirm!
      raise InvalidTransition unless pending?
      @status = OrderStatus::CONFIRMED
      @confirmed_at = Time.current
      add_domain_event(OrderConfirmed.new(order_id: @id))
    end
 
    # ← 保存はリポジトリが担う
  end
end

罠4: 全部にDDDを適用しようとする

# NG: シンプルなCRUDにも集約・リポジトリを導入する
module AddressContext
  class DeliveryAddressManager
    include Aggregate
    # ... 過剰なレイヤー化
  end
 
  class DeliveryAddressRepository
    # ... 単純なCRUDに不要なリポジトリ
  end
end
 
# OK: 複雑さがないところにDDDは不要
class DeliveryAddressesController < ApplicationController
  def create
    address = current_customer.delivery_addresses.create!(address_params)
    render json: address, status: :created
  end
 
  private
 
  def address_params
    params.require(:delivery_address).permit(:postal_code, :prefecture, :city, :street)
  end
end

INFO

DDDは「複雑なドメイン」のための道具だ。注文確定・キャンセル・割引のような複雑なビジネスルールがある場所に使う。単純なマスターデータ管理(住所の登録・削除)には、素直なRailsで良い。

テスト戦略

DDDを導入すると、テストピラミッドが劇的に変わる。

Loading diagram...
# ドメインオブジェクトのテスト(DBなし・高速)
RSpec.describe OrderContext::Order do
  let(:address) { OrderContext::DeliveryAddress.new(postal_code: '150-0001', prefecture: '東京都', city: '渋谷区', street: '神宮前1-1-1', recipient_name: '田中太郎') }
 
  describe '#confirm!' do
    context '正常系' do
      let(:order) do
        order = described_class.new(id: 'order-1', customer_id: 'customer-1', delivery_address: address)
        order.add_item(product_id: 'p-1', product_name: 'りんご', unit_price: SharedKernel::Money.new(amount: 200, currency: :jpy), quantity: 2)
        order
      end
 
      it '状態がCONFIRMEDになる' do
        order.confirm!
        expect(order.status).to eq(OrderContext::OrderStatus::CONFIRMED)
      end
 
      it '確定日時が記録される' do
        freeze_time do
          order.confirm!
          expect(order.confirmed_at).to eq(Time.current)
        end
      end
 
      it 'OrderConfirmedイベントが発生する' do
        order.confirm!
        events = order.domain_events
        expect(events).to include(an_instance_of(OrderContext::OrderConfirmed))
      end
    end
 
    context '異常系' do
      it '空の注文は確定できない' do
        order = described_class.new(id: 'order-1', customer_id: 'customer-1', delivery_address: address)
        expect { order.confirm! }.to raise_error(OrderContext::EmptyOrder)
      end
 
      it '確定済みの注文は再確定できない' do
        order = create_confirmed_order
        expect { order.confirm! }.to raise_error(OrderContext::InvalidTransition)
      end
    end
  end
end
 
# ユースケースのテスト(InMemoryリポジトリで高速)
RSpec.describe ConfirmOrderUseCase do
  let(:order_repository) { OrderContext::InMemoryOrderRepository.new }
  let(:stock_service) { instance_double(StockReservationService, reserve_for_order: true) }
  let(:event_bus) { instance_double(EventBus, publish_all: nil) }
 
  subject(:use_case) do
    described_class.new(
      order_repository: order_repository,
      stock_reservation_service: stock_service,
      event_bus: event_bus
    )
  end
 
  context '正常系' do
    let!(:order) do
      order = build_pending_order_with_items
      order_repository.save(order)
      order
    end
 
    it 'success結果を返す' do
      result = use_case.call(order_id: order.id, customer_id: order.customer_id)
      expect(result).to be_success
    end
 
    it '注文がconfirmed状態になる' do
      use_case.call(order_id: order.id, customer_id: order.customer_id)
      saved_order = order_repository.find(order.id)
      expect(saved_order.status).to eq(OrderContext::OrderStatus::CONFIRMED)
    end
  end
 
  context '在庫不足' do
    before { allow(stock_service).to receive(:reserve_for_order).and_raise(StockReservationFailed, "在庫不足") }
 
    it 'failure結果を返す' do
      order = build_pending_order_with_items
      order_repository.save(order)
      result = use_case.call(order_id: order.id, customer_id: order.customer_id)
      expect(result).to be_failure
      expect(result.error_code).to eq(:insufficient_stock)
    end
  end
end
 
# APIリクエストスペック(E2E・実DB)
RSpec.describe 'POST /api/v1/orders/:id/confirm', type: :request do
  let(:customer) { create(:customer) }
  let(:order) { create(:order, :pending, :with_items, customer: customer) }
 
  before { stub_stock_reservation_success }
 
  it '200を返す' do
    post "/api/v1/orders/#{order.id}/confirm",
      headers: auth_headers(customer)
    expect(response).to have_http_status(:ok)
  end
 
  it 'ステータスが更新される' do
    post "/api/v1/orders/#{order.id}/confirm",
      headers: auth_headers(customer)
    expect(order.reload.status).to eq('confirmed')
  end
end

パフォーマンスの考慮

ドメインオブジェクトとActiveRecordの二重構造は、N+1問題のリスクがある。

class ActiveRecordOrderRepository
  def find_by_customer(customer_id, pagination:)
    # eager loadingを確実に設定する
    records = ::Order
      .includes(:order_items, :customer)           # ← N+1防止
      .by_customer(customer_id)
      .recent
      .limit(pagination.per_page)
      .offset(pagination.offset)
 
    records.map { |record| reconstruct(record) }
  end
 
  # 読み取り専用の集計クエリ → ドメインオブジェクト変換不要(CQRS的アプローチ)
  def count_pending
    ::Order.pending.count
  end
 
  def total_revenue_this_month
    ::Order
      .confirmed
      .this_month
      .sum(:total_amount_cents)       # ← ActiveRecordで直接集計
  end
 
  def revenue_by_day(since:)
    ::Order
      .confirmed
      .where(confirmed_at: since..)
      .group("DATE(confirmed_at)")
      .sum(:total_amount_cents)
  end
end
# CQRSパターン: 読み取り専用クエリサービス(ドメインオブジェクトを経由しない)
class OrderQueryService
  def recent_orders_for_customer(customer_id:, page:, per_page:)
    ::Order
      .select('orders.id, orders.status, orders.total_amount_cents, orders.created_at')
      .includes(:order_items)
      .by_customer(customer_id)
      .recent
      .page(page)
      .per(per_page)
      .map do |record|
        {
          id: record.id,
          status: record.status,
          total_amount: record.total_amount_cents,
          item_count: record.order_items.size,
          ordered_at: record.created_at.iso8601
        }
      end
  end
end

INFO

読み取り専用のクエリ(ダッシュボード、一覧表示)にはドメインオブジェクト変換は必須ではない。ActiveRecordで直接SQL結果を返す「クエリサービス」を使うことで、ドメインモデルの複雑さを回避しながら効率的なクエリが書ける(CQRS的アプローチ)。

リナの現実解

3ヶ月後、FreshCartのコードベースはこうなった。

指標BeforeAfter
Order モデルの行数2,000行150行
OrderContext::Order ドメインオブジェクトなし250行(純粋なビジネスロジック)
ConfirmOrderUseCaseなし80行(調整のみ)
テストカバレッジ45%78%
ユニットテストの平均実行時間300ms/test3ms/test
新機能の平均実装時間3日1.5日
インシデントの件数(月次)8件2件

完璧ではない。まだレガシーコードが混在している箇所もある。しかし「コードがビジネスを語れる」エリアが確実に広がっている。

「段階的にやったのが正解だった」とリナは振り返った。「最初のスプリントで値オブジェクトを作り始めたとき、チームメンバーは懐疑的だった。でも3ヶ月でテストが速くなり、バグが減った。数字が正直だった」

まとめ

  • 段階的アプローチ = 値オブジェクト抽出(フェーズ1)→ ユースケース切り出し(フェーズ2)→ ドメインオブジェクト導入(フェーズ3)
  • ActiveRecordは薄く = ORM・DBバリデーション・クエリスコープのみ
  • 罠を避ける = ユースケースへのロジック漏れ、ActiveRecordラッパーだけのリポジトリ、ドメインがインフラを知ること
  • テストピラミッド = ドメインオブジェクトのユニットテストを厚くする(DBなし・高速)
  • 読み取りはCQRS的に = 集計・一覧クエリはActiveRecordを直接使う

最後の章では、6ヶ月のDDD実践を振り返り、落とし穴と成功の秘訣をまとめる。