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行ある
endRailsを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 はドメインオブジェクトの責務
endINFO
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
endWARNING
フェーズ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
endINFO
DDDは「複雑なドメイン」のための道具だ。注文確定・キャンセル・割引のような複雑なビジネスルールがある場所に使う。単純なマスターデータ管理(住所の登録・削除)には、素直なRailsで良い。
テスト戦略
DDDを導入すると、テストピラミッドが劇的に変わる。
# ドメインオブジェクトのテスト(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
endINFO
読み取り専用のクエリ(ダッシュボード、一覧表示)にはドメインオブジェクト変換は必須ではない。ActiveRecordで直接SQL結果を返す「クエリサービス」を使うことで、ドメインモデルの複雑さを回避しながら効率的なクエリが書ける(CQRS的アプローチ)。
リナの現実解
3ヶ月後、FreshCartのコードベースはこうなった。
| 指標 | Before | After |
|---|---|---|
Order モデルの行数 | 2,000行 | 150行 |
OrderContext::Order ドメインオブジェクト | なし | 250行(純粋なビジネスロジック) |
ConfirmOrderUseCase | なし | 80行(調整のみ) |
| テストカバレッジ | 45% | 78% |
| ユニットテストの平均実行時間 | 300ms/test | 3ms/test |
| 新機能の平均実装時間 | 3日 | 1.5日 |
| インシデントの件数(月次) | 8件 | 2件 |
完璧ではない。まだレガシーコードが混在している箇所もある。しかし「コードがビジネスを語れる」エリアが確実に広がっている。
「段階的にやったのが正解だった」とリナは振り返った。「最初のスプリントで値オブジェクトを作り始めたとき、チームメンバーは懐疑的だった。でも3ヶ月でテストが速くなり、バグが減った。数字が正直だった」
まとめ
- 段階的アプローチ = 値オブジェクト抽出(フェーズ1)→ ユースケース切り出し(フェーズ2)→ ドメインオブジェクト導入(フェーズ3)
- ActiveRecordは薄く = ORM・DBバリデーション・クエリスコープのみ
- 罠を避ける = ユースケースへのロジック漏れ、ActiveRecordラッパーだけのリポジトリ、ドメインがインフラを知ること
- テストピラミッド = ドメインオブジェクトのユニットテストを厚くする(DBなし・高速)
- 読み取りはCQRS的に = 集計・一覧クエリはActiveRecordを直接使う
最後の章では、6ヶ月のDDD実践を振り返り、落とし穴と成功の秘訣をまとめる。