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

[JA] Domain Modeling with Vanilla Rails: 「操作」から...

[JA] Domain Modeling with Vanilla Rails: 「操作」から再考するRailsの設計論

Presented at Kaigi on Rails 2026 (October 16, 2026)

Original English version: https://decks.yasaichi.com/20261016/domain-modeling-with-vanilla-rails

Avatar for Yuichi Goto

Yuichi Goto

October 16, 2026

More Decks by Yuichi Goto

Other Decks in Programming

Transcript

  1. モデル: メールアドレスの検証と確認メールの送信予約を実装 class User < ApplicationRecord validates :email, format: URI::MailTo::EMAIL_REGEXP,

    uniqueness: true generates_token_for :email_verification, expires_in: 1.day do [ email, email_verified_at ] end after_create_commit :send_verification_email private def send_verification_email UserMailer.with(user: self).email_verification.deliver_later end end 注 : UserEmail を使えば、メールアドレスとその確認状態を User から分離できる 10
  2. User の作成操作で実行されるバリデーションとコールバックを利用 class User < ApplicationRecord validates :email, format: URI::MailTo::EMAIL_REGEXP,

    uniqueness: true after_create_commit :send_verification_email def send_verification_email UserMailer.with(user: self).email_verification.deliver_later end end 11
  3. コントローラー: Userの作成操作を呼び出し、結果に応じて画面を表示 class UsersController < ApplicationController def new @user =

    User.new end scaffold で生成した コントローラーとほぼ同じ def create @user = User.new(user_params) if @user.save redirect_to action: :pending_verification else render :new, status: :unprocessable_content end end end 12
  4. ビューとルーティング: モデル名と状態からフォームの送信先を決定 <!-- app/views/users/new.html.erb --> User POST /users <%= form_with(model:

    @user) do |form| %> <div> <%= form.email_field :email, required: true, autocomplete: "email" %> 新規の なので になる </div> <div> <%= form.submit "Continue" %> </div> <% end %> # config/routes.rb resources :users, only: %i[ new create ] 13
  5. リソース・モデル・テーブルに対する操作を一対一に対応させる Resource routing Scaffold generator Active Record Request to a

    resource Controller action Model operation Database operation POST /users UsersController#create User#save INSERT INTO users ... resources :users @user = User.new(...) @user.save 15
  6. I was so excited about all the code I wasn’t

    writing. 出典 : Rails World 2026 Opening Keynote - DHH 18
  7. モデル: Userの作成操作を中心にGoogleサインアップを構成 class User < ApplicationRecord has_many :identities, class_name: "UserIdentity",

    inverse_of: :user validates :email, format: URI::MailTo::EMAIL_REGEXP, uniqueness: true validates :email_verified_at, presence: true, on: :google_signup after_create_commit :send_verification_email, unless: :email_verified_at def self.from_omniauth(provider:, uid:, email:, email_verified:) # Return the linked user here if present; otherwise, create one below. User.new(email:) do |user| user.email_verified_at = Time.current if email_verified user.identities.build(provider:, uid:) user.save(context: :google_signup) end end end 注 : Devise ・ OmniAuth の Wiki の実装例 [1] [2] を題材に合わせて変更したもの 23
  8. Google アカウントとの紐付け情報を持つ User を作成するファクトリ class User < ApplicationRecord has_many :identities,

    class_name: "UserIdentity", inverse_of: :user def self.from_omniauth(provider:, uid:, email:, email_verified:) User.new(email:) do |user| user.identities.build(provider:, uid:) user.save(context: :google_signup) User を保存すると、新規の 紐付け情報も保存される end end end 24
  9. メールアドレスの確認状態を検証し、確認メールは送信しない class User < ApplicationRecord on: と unless: で異なる振る舞いを実現 validates

    :email_verified_at, presence: true, on: :google_signup after_create_commit :send_verification_email, unless: :email_verified_at def self.from_omniauth(provider:, uid:, email:, email_verified:) User.new(email:) do |user| user.email_verified_at = Time.current if email_verified user.save(context: :google_signup) end end end 25
  10. コントローラー: Userの作成操作を呼び出し、結果に応じて画面を表示 class Omniauth::SessionsController < ApplicationController def create @user =

    User.from_omniauth(**user_params) が保存済みかどうかで分岐 if @user.persisted? User start_new_session_for(@user) redirect_to after_authentication_url, notice: "User was ..." else redirect_to new_user_path, alert: "Could not authenticate you ..." end end private def user_params = request.env["omniauth.auth"].then { … } end 注 : Google アカウントによる認証のリクエスト・レスポンスの処理に OmniAuth を利用する場合の例 26
  11. User モデルを用いた Google ・メールによるサインアップの全体像 User Email sign-up request Google auth

    callback Controller action UsersController#create Specialized factory Model operation Database operation User.from_omniauth User#save INSERT INTO users ... Controller action Omniauth::Sessions… Conditional execution on: :google_signup unless: :email_verified_at 2 つのサインアップのユースケースが 1 つの User の作成操作に対応 27
  12. 共通の方針: ユースケース固有の振る舞いを既存のモデルから分離する Separate objects Email sign-up request Controller action UsersController#create

    Dedicated operation #1 email_verification Google auth callback Controller action Omniauth::Sessions… User Dedicated operation #2 user.identites.build Model operation Database operation User#save INSERT INTO users ... Common behaviour validates :email 33
  13. サインアップの成功シナリオの場合 現状の設計 本発表のアプローチ 両方のサインアップを同じ User の作成操作として捉える それぞれに固有の振る舞いを Userに実装 各サインアップを独立した ドメインの操作として捉える

    それぞれに固有の振る舞いを 各操作を表現するモデルに実装 BTS: 本アプローチと『 Vanilla Rails is plenty 』 [7] との共通点から、タイトルに Vanilla を採用している 36
  14. メールアドレスによるサインアップ操作を表現するモデル(一部) class EmailSignUp include ActiveModel::Attributes, ActiveModel::Model attribute :email, :string def

    save return false if invalid? || !user.save UserMailer.with(user:).email_verification.deliver_later true end unless: :email_verified_at で実行条件を private def user @user ||= User.new(email:) end end 指定していたコールバックに相当 38
  15. Google アカウントによるサインアップ操作を表現するモデル(一部) class GoogleSignUp # ActiveModel includes omitted. attribute :email_verified,

    :boolean attribute :uid, :string validates :email_verified, comparison: { equal_to: true } def save return false if invalid? on: :google_signup で実行条件を 指定していたバリデーションに相当 user.identities.build(provider: :google, uid:) user.save end def user @user ||= User.new(email:, email_verified_at: Time.current) end end 39
  16. 参考: テーブルと対応しないモデルの基底クラス at WASO class ApplicationModel include ActiveModel::Validations::Callbacks, ActiveModel::Attributes, ActiveModel::Model

    NotSaved = Class.new(StandardError) { … } def save save! rescue ActiveModel::ValidationError, ActiveRecord::RecordInvalid, NotSaved false end def save! validate! create_or_update || raise(NotSaved.new('Failed to save the model', self)) true end end : ApplicationModel @_h_s_ [8] 注 という名前は の発表 に由来している 40
  17. サインアップ操作に固有の振る舞いが分離されたUserモデル class User < ApplicationRecord has_many :identities, class_name: "UserIdentity", inverse_of:

    :user # In this example, we choose to keep the email validations in User. validates :email, format: URI::MailTo::EMAIL_REGEXP, uniqueness: true generates_token_for :email_verification, expires_in: 1.day do [ email, email_verified_at ] end end 41
  18. サインアップ操作を表現するモデルに共通するバリデーション class EmailSignUp # Same implementation in GoogleSignUp USER_ATTRIBUTES =

    %i[ email ].freeze validate :user_must_be_valid private def user_must_be_valid return if user.valid? User のエラーを操作を表現するモデルの エラーとして取り込む user.errors.each do |error| next unless error.attribute.in?(USER_ATTRIBUTES) errors.import(error) end end end 注 : ActiveModel::Errors#import は Rails 6.1 から利用できる 42
  19. コントローラー: サインアップ操作を呼び出し、結果に応じて画面を表示 class EmailSignUpsController < ApplicationController def new @email_sign_up =

    EmailSignUp.new end 呼び出すモデルが変わっても コントローラーの構成は同じ def create @email_sign_up = EmailSignUp.new(email_sign_up_params) if @email_sign_up.save redirect_to action: :pending_verification else render :new, status: :unprocessable_content end end end 43
  20. 操作を表現するモデルを用いたGoogle・メールによるサインアップの全体像 Operation models Email sign-up request Controller action EmailSignUpsController Save

    EmailSignUp email_verification Google auth callback Controller action Omniauth::Sessions… User Save GoogleSignUp user.identites.build Model operation Database operation User#save INSERT INTO users ... Common behaviour validates :email リソースの作成操作とモデルの作成操作は 引き続き一対一に対応 44
  21. テーブルと対応するサインアップ操作のモデル(一部) class EmailSignUp < ApplicationRecord belongs_to :referrer, class_name: "User", optional:

    true belongs_to :user validate :user_must_be_valid, if: :user EmailSignUp を保存すると、 新規Userも保存される after_create_commit :send_verification_email delegate :email, to: :user, allow_nil: true private # NOTE: Same implementation as in the previous examples. def user_must_be_valid; end def send_verification_email; end end 注 : 2 つのモデルを STI で同じテーブルに対応させる設計も可能だが、本題から脱線するため割愛 50
  22. 操作を記録する場合もRailsのアプローチを維持できる class EmailSignUpsController < ApplicationController def create @email_sign_up = EmailSignUp.new(email_sign_up_params)

    if @email_sign_up.save redirect_to action: :pending_verification else render :new, status: :unprocessable_content end end private def email_sign_up_params { user: User.new(params.expect(…)), referrer_id: session[:referrer_id] } end end 51
  23. フォームオブジェクトを用いたメールアドレスによるサインアップ(一部) class EmailSignUpForm include ActiveModel::Model include ActiveModel::Attributes attribute :email, :string

    def save return false if invalid? || !user.save UserMailer.with(user:) .email_verification.deliver_later true end private def user @user ||= User.new(email:) end end class EmailSignUpsController < ApplicationController def create @email_sign_up_form = EmailSignUpForm.new( email_sign_up_form_params ) if @email_sign_up_form.save redirect_to action: :pending_verification else render :new, status: :unprocessable_content end end end モデルの実装は、記録が不要な 操作を表現するモデルと同じ 53
  24. イベントエンティティを用いたメールアドレスによるサインアップ(一部) class EmailSignUp < ApplicationRecord belongs_to :referrer, class_name: "User", optional:

    true belongs_to :user class EmailSignUpsController < ApplicationController def create @email_sign_up = EmailSignUp.new( email_sign_up_params ) validate :user_must_be_valid, if: :user after_create_commit :send_verification_email delegate :email, to: :user, allow_nil: true private # NOTE: Same implementation # as in the previous examples. def user_must_be_valid; end def send_verification_email; end end if @email_sign_up.save redirect_to action: :pending_verification else render :new, status: :unprocessable_content end end end モデルの実装は、記録が必要な 操作を表現するモデルと同じ 54
  25. 操作を表現するモデルと既存のアプローチの関係 Concept level Form web forms Operation domain operations Event

    entity domain activities Does the operation need a record? No Implementation level EmailSignUp(Form) uses ActiveModel Yes EmailSignUp uses ActiveRecord 55
  26. 「操作」という単一の概念から既存のアプローチの実装に帰着できる Concept level Form web forms Operation domain operations Event

    entity domain activities Does the operation need a record? No Implementation level EmailSignUp uses ActiveModel Yes EmailSignUp uses ActiveRecord 56
  27. 再掲: Userモデルを用いたメールアドレスによるサインアップ class User < ApplicationRecord validates :email, format: URI::MailTo::EMAIL_REGEXP,

    uniqueness: true generates_token_for :email_verification, expires_in: 1.day do [ email, email_verified_at ] end after_create_commit :send_verification_email private def send_verification_email UserMailer.with(user: self).email_verification.deliver_later end end 68
  28. 1. バリデーションとコールバックから User が扱うルールを明らかにする class User < ApplicationRecord validates :email,

    format: URI::MailTo::EMAIL_REGEXP, uniqueness: true メールアドレスの形式が正しく、未登録であること after_create_commit :send_verification_email private ユーザーの作成に成功した場合、 確認メールの送信が予約されること def send_verification_email UserMailer.with(user: self).email_verification.deliver_later end end 69
  29. 3.2. EmailSignUp モデルとして抽出し、その固有の振る舞いを集約 class EmailSignUp # ActiveModel includes omitted. attribute

    :email, :string def save return false if invalid? || !user.save UserMailer.with(user:).email_verification.deliver_later true end private def user @user ||= User.new(email:) end end class User < ApplicationRecord validates :email, format: URI::MailTo::EMAIL_REGEXP, uniqueness: true end 73
  30. 参考文献 1. OmniAuth: Overview↩ 2. Managing Multiple Providers↩ 3. Growing

    Rails Applications in Practice↩ 4. Simplicity on Rails -- RDB, REST and Ruby↩ 5. Railsの仕組みを理解してモデルを上手に育てる - モデルを見つける、モデルを分割する良いタイミング -↩ 6. 入門 FormObject↩ 7. Vanilla Rails is plenty↩ 8. Rails with ApplicationModel↩ 9. オブジェクト指向入門 第2版 原則・コンセプト↩ 10. エリック・エヴァンスのドメイン駆動設計↩ 78