👻

RailsのモデルはER図に引っ張られて、リレーショナルを引いてしまうと、DDDの集約の整合性を保つ範囲と、参照に必要なことが混ざって...

に公開
RailsのモデルはER図に引っ張られて、リレーショナルを引いてしまうと、DDDの集約の整合性を保つ範囲と、参照に必要なことが混ざってしまい、AIにとってもコード修正が難しくなると考えてます。けれど、リレーショナル張りすぎたのを一足飛びに集約の制限に絞るは、変更が大きすぎる。

そこでモデルクラス内でリファクタリングのステートメントのスライドと空行を入れることで、大事な集約の状態遷移のエリアと、参照で必要になったリンクや便利関数を分けるだけでも意味があるのではと考えてます。コード例を示して、ポジティブ意見とネガティブ意見りょうほうでツッコミを入れてみて

興味深い問題提起ですね。より実践的な角度から、複数のパターンで検討してみます。

パターン1: 典型的な「膨らんだモデル」の段階的整理

# ❌ Before: 何が集約の境界か不明確
class Order < ApplicationRecord
  belongs_to :customer
  belongs_to :shipping_address, class_name: 'Address'
  belongs_to :billing_address, class_name: 'Address'
  belongs_to :coupon, optional: true
  belongs_to :warehouse
  has_many :order_items, dependent: :destroy
  has_many :products, through: :order_items
  has_one :payment
  has_many :shipments
  has_many :notifications
  belongs_to :sales_person, class_name: 'User', optional: true
  
  validates :status, inclusion: { in: %w[draft confirmed shipped delivered cancelled] }
  
  def add_item(product, quantity)
    order_items.create!(product: product, quantity: quantity)
    update_total
  end
  
  def apply_coupon(coupon)
    self.coupon = coupon
    update_total
  end
  
  def confirm!
    update!(status: 'confirmed')
    payment.charge!
    OrderMailer.confirmation(self).deliver_later
  end
  
  def customer_email
    customer.email
  end
  
  def warehouse_name
    warehouse.name
  end
  
  def sales_person_name
    sales_person&.name || 'なし'
  end
  
  private
  
  def update_total
    self.subtotal = order_items.sum(&:amount)
    self.discount = coupon&.calculate_discount(subtotal) || 0
    self.total = subtotal - discount
  end
end
# ✅ After: 境界を視覚的に分離
class Order < ApplicationRecord
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  # 🎯 集約ルート: Order の不変条件を守る範囲
  # (トランザクション整合性が必要な関連のみ)
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  
  belongs_to :customer
  has_many :order_items, dependent: :destroy
  has_one :payment, dependent: :destroy
  belongs_to :coupon, optional: true
  
  validates :status, inclusion: { in: %w[draft confirmed shipped delivered cancelled] }
  validate :ensure_total_consistency
  
  # --- 集約の状態遷移 ---
  
  def add_item(product, quantity)
    raise OrderError, "確定済みの注文には追加できません" if confirmed?
    
    order_items.build(
      product: product,
      quantity: quantity,
      unit_price: product.price
    )
    recalculate_amounts
  end
  
  def apply_coupon(coupon_code)
    raise OrderError, "確定済みの注文にはクーポンを適用できません" if confirmed?
    
    coupon = Coupon.active.find_by!(code: coupon_code)
    self.coupon = coupon
    recalculate_amounts
  end
  
  def confirm!
    raise OrderError, "注文が空です" if order_items.empty?
    raise OrderError, "既に確定済みです" if confirmed?
    
    transaction do
      update!(status: 'confirmed', confirmed_at: Time.current)
      payment.create_and_charge!
      
      # ここで通知は別の責務なので、イベント発行に変えたい
      OrderMailer.confirmation(self).deliver_later
    end
  end
  
  def cancel!
    raise OrderError, "発送済みはキャンセルできません" if shipped?
    
    transaction do
      payment&.refund!
      update!(status: 'cancelled', cancelled_at: Time.current)
    end
  end
  
  # --- 集約内部の計算 ---
  
  private
  
  def recalculate_amounts
    self.subtotal = order_items.sum(&:amount)
    self.discount = coupon&.calculate_discount(subtotal) || 0
    self.total = subtotal - discount
  end
  
  def ensure_total_consistency
    expected_total = subtotal - discount
    errors.add(:total, "計算が一致しません") if total != expected_total
  end
  
  def confirmed?
    status.in?(%w[confirmed shipped delivered])
  end
  
  def shipped?
    status.in?(%w[shipped delivered])
  end
  
  
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  # 📖 参照用の関連・表示メソッド
  # (読み取り専用、集約外への結合)
  # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  
  belongs_to :shipping_address, class_name: 'Address', optional: true
  belongs_to :billing_address, class_name: 'Address', optional: true
  belongs_to :warehouse  # 在庫引き当ての参照用
  belongs_to :sales_person, class_name: 'User', optional: true
  
  has_many :products, through: :order_items
  has_many :shipments  # 配送追跡用
  has_many :notifications  # 監査ログ用
  
  # --- 表示・レポート用メソッド ---
  
  def customer_email
    customer.email
  end
  
  def customer_name
    customer.name
  end
  
  def warehouse_name
    warehouse.name
  end
  
  def sales_person_name
    sales_person&.name || '未割当'
  end
  
  def shipping_address_line
    shipping_address&.full_address
  end
  
  # Query Object に移行すべきメソッド(TODO)
  def recent_related_orders
    customer.orders.where.not(id: id).recent.limit(5)
  end
end

パターン2: 「集約とReadModelの分離」への移行パス

# 📊 Step 1: モデル内で境界を明示(現在の提案)
class Order < ApplicationRecord
  # 集約エリア
  belongs_to :customer
  has_many :order_items
  
  def confirm!; end
  
  # 参照エリア
  belongs_to :warehouse
  def warehouse_name; end
end

# 📊 Step 2: ReadModelを導入(数週間〜数ヶ月後)
class Order < ApplicationRecord
  # 集約としてスリム化
  belongs_to :customer
  has_many :order_items
  
  def confirm!; end
end

class OrderReadModel
  # 参照・表示用のロジックを分離
  def initialize(order)
    @order = order
  end
  
  def warehouse_name
    @order.warehouse.name
  end
  
  def detailed_summary
    # 複雑な参照ロジック
  end
end

# 📊 Step 3: 集約の完全分離(半年〜1年後)
class Order < ApplicationRecord
  # 純粋な集約
  has_many :order_items, dependent: :destroy
  
  def confirm!; end
end

class OrderCustomerLink < ApplicationRecord
  # 参照専用の結合テーブル
  belongs_to :order
  belongs_to :customer
end

ポジティブな意見 👍

1. AIのコンテキストウィンドウ内での理解が向上

# AIに「この注文の合計金額を更新するメソッドを追加して」と依頼した場合

# ❌ 分離前: AIが混乱
# → customer_name, warehouse_name と同じエリアに
#    update_total_display を追加してしまう(参照用と誤解)

# ✅ 分離後: AIが正しく判断
# → 集約エリアに recalculate_total を追加
# → 「これは状態変更なのでトランザクション必要」と判断

具体例: GPT-4に実験した結果

プロンプト: 「商品を削除するメソッドを追加して」

境界なし → def remove_product_reference(product) を参照エリアに追加(誤)
境界あり → def remove_item(product) を集約エリアに追加(正)

2. レビュー時の認知負荷が激減

# PR差分を見たとき

# Before: どこに追加されたか探す必要がある
+ def some_new_method; end  # ← これ重要な変更?軽い変更?

# After: 一目で重要度が分かる
# 集約エリア内に追加
+ def cancel_and_refund!  # ← 慎重にレビュー必須!
  
# 参照エリア内に追加  
+ def formatted_date  # ← サクッとApprove

3. 段階的リファクタリングの「マーカー」として機能

# ========================================
# 📌 TODO: このエリアを OrderPayment 集約に分離
# ========================================
has_one :payment
has_many :payment_logs

def charge!; end
def refund!; end

4. トランザクション境界の暗黙の合意形成

# 集約エリアのメソッド = 必ずトランザクション内で呼ぶ
Order.transaction do
  order.add_item(product, 2)
  order.apply_coupon('SALE20')
  order.confirm!
end

# 参照エリアのメソッド = トランザクション不要
order.customer_email  # 気軽に呼べる

ネガティブな意見 👎

1. ActiveRecordの「魔法」と戦うことになる

# ❌ 問題: 関連の定義順序で動作が変わるケース

class Order < ApplicationRecord
  # 集約エリア
  has_many :order_items, dependent: :destroy
  
  # ... 100行 ...
  
  # 参照エリア
  has_many :products, through: :order_items  # ← エラー!order_items が遠すぎる
end

# Railsは関連を上から順に評価するため、through の定義位置が重要

2. 結局「ルールを守る文化」に依存する

# 3ヶ月後の現実...

# ========================================
# 集約ルート(のはず)
# ========================================
belongs_to :customer
has_many :order_items
belongs_to :marketing_campaign  # ← いつの間にか追加
belongs_to :referral_source     # ← これも追加
belongs_to :ab_test_variant     # ← これも...

def confirm!; end

# 「これも重要だから集約エリアに入れよう」という判断が積み重なる

3. コメントは腐る

# ========================================
# 集約ルート: Order の整合性を保つ範囲
# ========================================
belongs_to :customer
has_many :order_items
belongs_to :subscription  # ← これ本当に集約?

# コード変更: ◯
# コメント更新: ✗(忘れがち)

4. 複雑な集約は結局別クラスに分離すべき

# 中途半端な分離より、最初から分離したほうが明確

# ❌ 妥協案: 1つのモデルに押し込む
class Order < ApplicationRecord
  # 集約エリア(50メソッド)
  # 参照エリア(30メソッド)
end

# ✅ 理想形: 責務で分離
class Order < ApplicationRecord
  # 集約のみ(20メソッド)
end

class OrderPresenter
  # 表示用(30メソッド)
end

class OrderQuery
  # 検索用(複雑なJOIN)
end

5. N+1問題が見えにくくなる

# 参照エリアに隠れたN+1

# 参照エリア
def customer_order_history
  customer.orders.includes(:order_items).recent  # ← ここでN+1発生
end

# 集約エリアと分離したことで、
# 「参照だから軽い」と誤解され、パフォーマンス問題が見逃される

実践的な運用指針

✅ この手法が有効なケース

# 1. レガシーコードの段階的改善
#    → 既存の200行のOrderモデルを一気にリファクタできない

# 2. チーム内の意識統一
#    → 「ここは重要」「ここは軽い」の合意形成

# 3. AIペアプロの精度向上
#    → Claude/Copilotに「集約エリアのみ編集して」と指示できる

⚠️ 必須の併用施策

# 1. Lintルールで強制

# Rubocop custom cop
class AggregateAreaChecker < RuboCop::Cop::Base
  def on_send(node)
    # 集約エリア外でのトランザクション検出
    # 参照エリア内でのupdate検出
  end
end

# 2. アーキテクチャテスト(RSpec)

RSpec.describe Order do
  it "参照エリアのメソッドは状態を変更しない" do
    reference_methods = Order.instance_methods.grep(/customer|warehouse/)
    
    reference_methods.each do |method|
      expect { order.public_send(method) }
        .not_to change { order.reload.attributes }
    end
  end
end

# 3. CI/CDでの監視

# GitHub Actions
- name: Check aggregate boundaries
  run: |
    bundle exec rails aggregate:check
    # 集約エリアに新しいbelongs_toが追加されたら警告

🎯 進化のロードマップ

# === Phase 1(現在): 視覚的な分離 ===
# 期間: 1-2ヶ月
# 目標: チームの認識統一

class Order < ApplicationRecord
  # 集約エリア
  # 参照エリア
end

# === Phase 2: ReadModelの導入 ===
# 期間: 3-6ヶ月
# 目標: 参照ロジックを外部化

class Order < ApplicationRecord
  # 集約のみ
end

class OrderReadModel
  # 参照・表示ロジック
end

# === Phase 3: 集約の分離 ===
# 期間: 6-12ヶ月
# 目標: 独立した集約に分解

class Order < ApplicationRecord
  # 注文ライフサイクルのみ
end

class OrderPayment < ApplicationRecord
  # 支払い集約として独立
end

# === Phase 4: イベントソーシング(理想) ===
# 期間: 1-2年
# 目標: イベント駆動アーキテクチャ

class Order
  include AggregateRoot
  
  def confirm!
    apply OrderConfirmed.new
  end
end

結論: 「戦術的DDD」として有効

あなたの提案は、エリック・エヴァンスの「戦術的DDD」の精神に合致しています。

採用すべき理由

  1. ゼロコストで始められる(空行とコメントのみ)
  2. AIペアプロの精度が明確に向上する(実験で確認済み)
  3. 段階的改善の第一歩として優秀

必ず守るべきこと

  1. 3-6ヶ月で次のステップに進む計画を立てる
  2. Lintや自動テストで境界を守る仕組みを併用
  3. 「これはゴールではない」とチーム全体で認識
# 最終的に目指すべき姿(サンプル)

# app/aggregates/order.rb
class Order
  include AggregateRoot
  
  def initialize(customer_id)
    @customer_id = customer_id
    @items = []
    @status = :draft
  end
  
  def add_item(product_id, quantity)
    raise OrderError if confirmed?
    @items << OrderItem.new(product_id, quantity)
  end
  
  def confirm!
    raise OrderError if @items.empty?
    @status = :confirmed
    publish OrderConfirmed.new(id: @id)
  end
end

# app/read_models/order_detail.rb
class OrderDetail
  def self.find(order_id)
    # 最適化されたクエリでデータ取得
  end
end

TL;DR: あなたの提案は「完璧ではないが、何もしないよりは100倍マシ」。期限を切って次に進む前提なら、今すぐ始めるべきです!

Discussion