Upgrade to Pro — share decks privately, control downloads, hide ads and more …

副作用をどこに置くか問題:オブジェクト指向で整理する設計判断ツリー

 副作用をどこに置くか問題:オブジェクト指向で整理する設計判断ツリー

Avatar for Koya Masuda

Koya Masuda

January 09, 2026
Tweet

More Decks by Koya Masuda

Other Decks in Programming

Transcript

  1. 1つのメソッドにまとめる def register_user(params) # メアドを小文字に params[:email] = params[:email].downcase # パスワードをハッシュ化

    params[:password] = generate_hash(params[:password]) user = User.create!(params) # DB書き込み LoginLogger.log(params) # ログイン履歴 UserMailer.welcome(user) # メール送信 AnalyticsEvent.track(user) # ログ記録 user end 8/63
  2. 1つのメソッドの責務が多すぎる 変更に弱い 再利用性が低い 可読性が低い def register_user(params) params[:email] = params[:email].downcase #

    正規化 params[:password] = generate_hash(params[:password]) # 正規化 user = User.create!(params) # 永続化 LoginLogger.log(params) # ログ UserMailer.welcome(user) # 通知 AnalyticsEvent.track(user) # ログ user end 9/63
  3. 失敗時の挙動 ユーザーは作成済み ウェルカムメールが失敗 分析用のロギングは実行されない def register_user(params) params[:email] = params[:email].downcase #

    成功 params[:password] = generate_hash(params[:password]) # 成功 user = User.create!(params) # 成功 LoginLogger.log(params) # 成功 UserMailer.welcome(user) # 失敗 AnalyticsEvent.track(user) # 実行されない user end 10/63
  4. オブジェクトのライフサイクルにフックしてみる before_save → オブジェクトの保存直前に実行 after_commit → トランザクションのCOMMIT後 class User <

    ApplicationRecord before_save :normalize_email before_save :encrypt_password after_commit :send_welcome_mail after_commit :track_analytics_event def register_user(params) User.create!(params) end end 11/63
  5. テスタビリティ スタブの嵐… ユーザー作成のテストをしたいだけなのに… class RegisterUserTest < ActiveSupport::TestCase test 'creates a

    user' do UserMailer.stub(:welcome, nil) do LoginLogger.stub(:log, nil) do AnalyticsEvent.stub(:track, nil) do # ネストが深くなる地獄 assert_difference 'User.count', 1 do register_user(email: '[email protected]', password: 'password') end end end end end end 12/63
  6. 前提② 呼称 他の呼び方 ここでの意味 オブジェクト インスタンス クラスnewしたもの フィールド メンバ変数 インスタンスごとに持

    つ状態 モデル エンティティ Active Record パター ンのデータ構造をもち DB操作をするクラス 16/63
  7. キースライド: 判断ツリー その副作用が失敗したらオブジェクトは不完全な状態になる? │ ├─ YES → オブジェクトの存在条件 │ 例:

    パスワードのハッシュ化 │ → そのクラス(モデル)の中に書く │ └─ NO → 他の関心へのメッセージ │ その処理が失敗したら元の操作も失敗させたい? │ ├─ YES → ユースケースの一部 │ 例: 決済、ポイント付与 │ → ユースケースを表すクラスにまとめる │ └─ NO → 独立した関心事 例: メール送信、Slack通知、ログ記録 → インターフェースを介して詳細を実装 17/63
  8. キースライド: 判断ツリー その副作用が失敗したらオブジェクトは不完全な状態になる? │ ├─ YES → オブジェクトの存在条件 ココ │

    例: パスワードのハッシュ化 │ → そのクラス(モデル)の中に書く │ └─ NO → 他の関心へのメッセージ │ その処理が失敗したら元の操作も失敗させたい? │ ├─ YES → ユースケースの一部 │ 例: 決済、ポイント付与 │ → ユースケースを表すクラスにまとめる │ └─ NO → 独立した関心事 例: メール送信、Slack通知、ログ記録 → インターフェースを介して詳細を実装 18/63
  9. 存在条件かどうか見分けるコツ その副作用の結果以下を満たすなら「存在条件」 オブジェクトは不完全な状態になる? Railsではコールバックやバリデーションの標準機能が用意されて いる class User < ApplicationRecord #

    オブジェクトの永続化時にバリデーションを行う validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, presence: true, uniqueness: { case_sensitive: false } end 20/63
  10. 存在条件がライフサイクルにフックされていないと 似たメソッドが増殖する 口座(Account)の残高(balance)は0以上でないとならない という存在条件を確認して回る class Account def withdraw(amount) raise "残高不足です"

    if balance - amount < 0 # do something end def transfer_to(other_account, amount) raise "残高不足です" if balance - amount < 0 # do something end def monthly_fee!(fee: 500) raise "残高不足です" if balance - fee < 0 # do something end end 24/63
  11. 存在条件のあるべき姿 # app/models/account.rb class Account < ApplicationRecord validates :balance, numericality:

    { greater_than_or_equal_to: 0 } def withdraw(amount) transaction do new_balance = balance - amount update!(balance: new_balance) # バリデーションで0未満を防ぐ end end end # 使う側はシンプル。実装の詳細は知らない。 account.withdraw(10_000) 25/63
  12. 他の関心へメッセージ その副作用が失敗したらオブジェクトは不完全な状態になる? │ ├─ YES → オブジェクトの存在条件 │ 例: パスワードのハッシュ化

    │ → そのクラス(モデル)の中に書く │ └─ NO → 他の関心へのメッセージ ココ │ その処理が失敗したら元の操作も失敗させたい? │ ├─ YES → ユースケースの一部 │ 例: 決済、ポイント付与 │ → ユースケースを表すクラスにまとめる │ └─ NO → 独立した関心事 例: メール送信、Slack通知、ログ記録 → インターフェースを介して詳細を実装 27/63
  13. 他の関心へのメッセージの判断ポイント 他の関心へのメッセージ │ その処理が失敗したら元の操作も失敗させたい? ココ │ ├─ YES → ユースケースの一部

    │ 例: 決済、ポイント付与 │ → ユースケースを表すクラスにまとめる │ └─ NO → 独立した関心事 例: メール送信、Slack通知、ログ記録 → インターフェースを介して詳細を実装 28/63
  14. 他の関心と運命をともにするとき 他の関心へのメッセージ │ その処理が失敗したら元の操作も失敗させたい? ココ │ ├─ YES → ユースケースの一部

    │ 例: 決済、ポイント付与 │ → ユースケースを表すクラスにまとめる │ └─ NO → 独立した関心事 例: メール送信、Slack通知、ログ記録 → インターフェースを介して詳細を実装 29/63
  15. 存在条件と同じくライフサイクルにフック Orderの状態が注文完了になったらPointを作成する 注文(Order) → ポイント(Point) への依存が生まれる class Order < ApplicationRecord

    after_commit :grant_points, on: :update, if: :completed? private def grant_points point = Point.new(self.user, self.amount) point.save! end end 31/63
  16. 使いすぎると? 一歩間違えるとコールバック地獄 class Order < ApplicationRecord after_commit :grant_points, on: :update,

    if: :completed? after_commit :create_delivery, on: :update, if: :completed? after_commit :create_invoice, on: :update, if: :completed? after_commit :record_purchase_history, on: :update, if: :completed? after_commit :update_stock, on: :update, if: :completed? private def grant_points Point.create!(user: user, amount: amount * 0.01) end def create_delivery Delivery.create!(order: self, address: shipping_address) end # 処理が続く... end 37/63
  17. Checkoutクラスを作成する 複数のオブジェクトを同一のTransactionで処理し、整合性を保 つ # app/models/checkout.rb class Checkout def initialize(order:, user:)

    @order = order @user = user end def execute ActiveRecord::Base.transaction do # 注文を完了状態にする @order.complete! # ポイントを付与する Point.create!(user: @user, amount: @order.amount * 0.01) end end end 40/63
  18. 運命をともにするパターンのまとめ 副作用が失敗したら元の操作も失敗させたい → ユースケースの 一部 データベースの制約はないが、ビジネス上の整合性を保ちたい 場合に有効 イベント(ユースケース)をクラスにする 例: Checkout,

    UserRegistration 同一トランザクションで複数のモデルを更新 モデル間の直接的な依存を避けられる ライフサイクルにフックする形でクラスの中に書いても良い ただし、拡張性やテスタビリティなどのトレードオフを理解しておくこと 42/63
  19. 独立した関心事 他の関心へのメッセージ │ その処理が失敗したら元の操作も失敗させたい? │ ├─ YES → ユースケースの一部 │

    例: 決済、ポイント付与 │ → ユースケースを表すクラスにまとめる │ └─ NO → 独立した関心事 ココ 例: メール送信、Slack通知、ログ記録 → インターフェースを介して詳細を実装 43/63
  20. 依存の向き 呼び出し側 → 呼び出される側 Checkout → Mailer class Checkout def

    initialize(order:, user:) @order = order @user = user end def execute ActiveRecord::Base.transaction do # 注文を完了状態にする @order.complete! # ポイントを付与する Point.create!(user: @user, amount: @order.amount * 0.01) end # メール送信 OrderMailer.perform_later(@user, @order) end end 45/63
  21. 依存の数 もし要求がもっと増えたら… Checkout → Point, Mailer, Delivery, Invoice, Stock… 依存が多く、テストが大変、再利用しにくくて、変更しづらい

    class Checkout def execute ActiveRecord::Base.transaction do @order.complete! Point.create!(user: @user, amount: @order.amount * 0.01) end # メール送信 OrderMailer.perform_later(@user, @order) # Delivery.create # Invoice.create # Stock.decrease # ... end end 46/63
  22. インターフェースの作成 Checkoutはインターフェースを知っているだけにする # ========== 抽象(インターフェース) ========== module CheckoutSubscriber def on_checkout(user,

    order) # 未実装時に例外とする raise NotImplementedError, "#{self.class}で#{__method__}が実装されていません" end end 49/63
  23. Checkoutクラスから抽象に依存する subscriberはon_checkoutメソッドを持っているものとする = ダックタイピング (多態性を表現する技術) class Checkout def initialize(order:, user:)

    @order = order @user = user @subscribers = [] # <- 追加 end def execute ActiveRecord::Base.transaction do @order.complete! Point.create!(user: @user, amount: @order.amount * 0.01) end + subscribers.each { |subscriber| subscriber.on_checkout(@user, @order) } - OrderMailer.perform_later(@user, @order) end end 50/63
  24. Transaction に含めてはいけないケース ロールバックできないもの class Order < ApplicationRecord def checkout(payment) transaction

    do complete! # 1. 注文ステータス更新 PaymentApi.charge(payment) # 2. 課金実行(HTTP通信 → 成功) Inventory.decrease(items) # 3. 在庫減算 → 在庫不足で例外発生! OrderMailerJob.perform_later(self) # 4. 実行されない end # トランザクションがロールバック # - complete! → まだ commit していないから戻る # - PaymentApi.charge → 戻らない(外部APIは戻せない) # # 結果: 注文はキャンセル状態、でも顧客のカードからは引き落とし済み end end 55/63
  25. 抽象に依存させる class Order < ApplicationRecord def checkout(payment) # 1. 同じトランザクションで成功/失敗すべき処理

    transaction do complete! Inventory.decrease(items) end # 2. 同期処理が必要: 決済(同期、レスポンスに必要) # リトライ機構は PaymentApiに任せる result = PaymentApi.charge(payment) Payment.create!(order: self, status: result.status) # 3. 他の関心事: メール(非同期、レスポンスに不要) OrderMailJob.perform_later(self) end end 56/63