Domain Modeling with Vanilla Rails

Rethinking Application Design around Domain Operations

Yuichi Goto | Kaigi on Rails 2026 | October 16, 2026

About me

  • 👤Yuichi Goto
  • 🌐@yasaichi
  • 🧑‍💻Full-Stack Engineer (Rails)
  • 🏢WASO Ltd (London, UK)

WASO: An online supermarket in the UK and France

About this talk

🚩 Goals

  • Understand Rails productivity and how models become fat
  • Learn how to discover and model domain operations

👥 Who this talk is for

  • Comfortable building Rails features end to end
  • Familiar with fat models and ways to address them

Agenda

  1. How Rails models become fat 👈
  2. Treating domain operations as distinct concepts
  3. Discovering domain operations from business rules

Running example: sign-up with Google or email

Email sign-up: success scenario (excerpt)

  1. ⚙️ Display the sign-up form
  2. 👤 Enter an email address and submit the form
  3. ⚙️ Validate email format and uniqueness
  4. ⚙️ Create a user with an unverified email address
  5. ⚙️ Schedule a verification email and display a screen prompting verification
Note: ⚙️ represents the application; 👤 represents the user

A naive implementation

Model: validate email and schedule a verification email

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
Note: UserEmail could separate the email address and verification status from User

Using validations and callbacks on User creation

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

Controller: call User’s creation operation and respond accordingly

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.new(user_params)

    if @user.save
      redirect_to action: :pending_verification
    else
      render :new, status: :unprocessable_content
    end
  end
end
Almost the same
as a scaffold-generated controller

View and routing: model name and state determine the URL

<!-- app/views/users/new.html.erb -->
<%= 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 ]
A new User means POST /users

The underlying approach

Mapping resource, model, and table operations one-to-one

Use cases* built on model CRUD operations

Behavior in the success scenario Implementation
Displaying the sign-up form Controller and view
Validating email format and uniqueness Validations on User creation
Creating a user with an unverified email address save on a new User
Scheduling a verification email after_create_commit on User
Displaying a screen prompting email verification Controller and view
* This talk covers state-changing use cases that create, update, or delete records

What makes Rails productive?

Just add validations and callbacks to scaffold-generated models to implement state-changing use cases.

  • Fewer design decisions: Rails provides the application structure
  • Less code: reuse scaffolding, validations, and callbacks

Q: What if another use case
also needs to create a User?

A: The same approach works,
but leads to fat models

Google sign-up: success scenario (excerpt)

  1. 👤 Choose Google sign-up and authenticate with Google
  2. ⚙️ Validate the returned email’s format and uniqueness
  3. ⚙️ Require a verified email address and create a user
  4. ⚙️ Create the new user’s Google account link
  5. ⚙️ Display the sign-up completion screen

Required behavior differs by sign-up method

Behavior in the success scenario Email Google account
Validating email format and uniqueness Required Required
Validating email verification status Not required Required
Creating a new user’s Google account link Not required Required
Scheduling a verification email Required Not required

Model: Google sign-up built on User’s creation operation

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
Note: Adapted from the Devise and OmniAuth wiki examples [1] [2]
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)
    end
  end
end
Saving the User also saves
its new Google account link

Require verified email; skip verification email

class User < ApplicationRecord



  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
Different behavior with on: and unless:

Controller: call User’s creation operation and respond accordingly

class Omniauth::SessionsController < ApplicationController
  def create
    @user = User.from_omniauth(**user_params)

    if @user.persisted?
      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
Branch on whether the User is persisted
Note: An example using OmniAuth to handle Google authentication requests and responses

Email and Google sign-up with the User model

Two sign-up use cases map
to one User creation operation

Why models grow fat

Because use-case-specific behavior accumulates in the same model through:

  • Class and instance methods:
    Implement use-case-specific behavior (User.from_omniauth)
  • Validation and callback conditions:
    Switch behavior by use case (on: :google_signup)
FYI: Growing Rails Applications in Practice [3] makes a similar observation

Recap

  • Rails approach: 1-to-1 resource, model, and table operations; state-changing use cases built on model CRUD operations
  • Pros: fewer design decisions and less code
  • Cons: a risk of fat models because use-case-specific behavior accumulates in them

Agenda

  1. How Rails models become fat
  2. Treating domain operations as distinct concepts 👈
  3. Discovering domain operations from business rules

Approaches presented in these talks

  • Introduce form objects:
    An object representing a form handles its specific behavior
  • Identify event entities:
    A model representing a domain activity records its occurrence and handles related behavior
Note: “Event entity” may be a term used by some practitioners in Japan.

Common strategy: separate use-case-specific behavior

Proposed approach

Introduce models that
represent domain operations

For the sign-up success scenarios

🛤️ Current design

  • Treat both as the same User creation operation
  • Implement their specific behavior in User

💡 Proposed approach

  • Treat each as a distinct domain operation
  • Implement specific behavior in its operation model
BTS: “Vanilla” in the title reflects similarities between this approach and “Vanilla Rails is plenty” [7]

Implementation

Email sign-up operation model (excerpt)

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

  private def user
    @user ||= User.new(email:)
  end
end
Corresponds to the callback conditioned by
unless: :email_verified_at

Google sign-up operation model (excerpt)

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?

    user.identities.build(provider: :google, uid:)
    user.save
  end

  def user
    @user ||= User.new(email:, email_verified_at: Time.current)
  end
end
Corresponds to the validation conditioned by
on: :google_signup

FYI: Base class for tableless models 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
Note: The name ApplicationModel comes from Hiroyasu Shimoyama’s talk [8]

User model after separating sign-up-specific behavior

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

Validations shared by the sign-up operation models

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.errors.each do |error|
      next unless error.attribute.in?(USER_ATTRIBUTES)
      errors.import(error)
    end
  end
end
Import the User’s errors
as the operation model’s errors
Note: ActiveModel::Errors#import is available from Rails 6.1

Controller: call the sign-up operation and respond accordingly

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
The controller’s structure stays the same
even when the model changes

Email and Google sign-up with operation models

Resource creation actions still map
one-to-one to model creation operations

What operation models achieve

  • Addressing fat models:
    Separate use-case-specific behavior from existing models
  • Keeping Rails’ approach:
    Build state-changing use cases on model CRUD operations to preserve Rails’ high productivity

Why domain operations are still overlooked

  1. Actors and targets such as users are easy to recognize as models: they often appear in URLs and views and are persisted
  2. Rails provides a structure for building state-changing use cases on these models’ CRUD operations
  3. Thus, domain operations are overlooked as distinct concepts, and their specific behavior accumulates in those models

When a domain operation
needs a record

New requirement: record the referrer at sign-up

  1. 👤 Access the service through a URL containing a referral code
  2. ⚙️ Identify the referrer and store their ID in the session
  3. 👤 Start sign-up with Google or email
  4. ⚙️ On successful sign-up, link the new user to the referrer stored in the session

Persisting the operation models

Encapsulate operation-specific behavior and operation records in the same model.

  1. Create a table to record sign-up operations
  2. Add columns referencing the new user and the referrer
  3. Make the operation models table-backed

Table-backed sign-up operation model (excerpt)

class EmailSignUp < ApplicationRecord
  belongs_to :referrer, class_name: "User", optional: true
  belongs_to :user

  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
Saving the EmailSignUp
also saves the new User
Note: Both models could share the same table using STI; this is omitted to stay on topic

Keeping Rails’ approach with operation records

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

Operation models
and the earlier approaches

Implementing email sign-up with a form object (excerpt)

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
Same model implementation
as an operation model without records

Implementing email sign-up with an event entity (excerpt)

class EmailSignUp < ApplicationRecord
  belongs_to :referrer, class_name: "User",
             optional: true
  belongs_to :user

  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
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
end
Same model implementation
as an operation model with records

Connecting operation models to form objects and event entities

The key idea: one concept, two familiar implementations

Recap

  • Proposed approach: separate use-case-specific behavior into operation models to address fat models and keep Rails’ approach
  • Implementation: same as form objects without records; same as event entities with records
  • Key idea: the same domain operation concept works with or without records

Agenda

  1. How Rails models become fat
  2. Treating domain operations as distinct concepts
  3. Discovering domain operations from business rules 👈

🤔💭 We’ve seen what operation models achieve, but...

Q: How can we discover
domain operations in existing code?

A: Examine business rules
handled by models

Business rule examples: Google sign-up

  • “The email address has a valid format and is not already registered”
  • “The email address is verified”
  • “If the user is created successfully, the new user’s Google account link is created”
FYI: Rules can be classified by when they must hold. See Object-Oriented Software Construction [9]

Proposed approach: start from business rules

  1. Identify business rules from model implementations
  2. Consider another use case and examine whether each rule must also hold there
  3. Discover domain operations to model from use-case-specific rules

1. Identifying rules from model implementations

Important business rules are often implicitly encoded in the following code*:

  • Validations and callbacks, including execution conditions
  • Guard clauses with early returns or explicit exceptions
  • Creating or updating other instances within a transaction
* Guard clause example: Domain-Driven Design [10]; other examples: the speaker’s experience

2. Examining rules with another use case

  1. Consider another use case, even if not actually required, built on the same model’s state-changing operations
  2. Examine whether each rule must also hold there to identify use-case-specific rules

3. Discovering domain operations to model

  1. Consider whether a use-case-specific rule belongs to a distinct operation
  2. If so, extract that operation as a model and encapsulate its specific behavior

Example: email sign-up

Revisiting email sign-up as implemented in 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

1. Identifying User’s rules from validations and callbacks

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
The email address has a valid format and is not already registered
If the user is created successfully,
a verification email is scheduled

2.1. Scenario: user invitation by an admin

  1. 👤 An admin enters an email address and submits the form
  2. ⚙️ Create a user with an unverified email address
  3. ⚙️ Schedule an invitation email
  4. 👤 The invited user opens the email link and accepts the invitation
  5. ⚙️ Mark the email address of the user created in #2 as verified

2.2. Verification email is sign-up specific

Business rule handled by User Email sign-up Invitation
by an admin
The email address has a valid format and is not already registered Required Required
If the user is created successfully,
a verification email is scheduled
Required Not required

3.1. The rule belongs to email sign-up

Because the rule must hold whenever email sign-up succeeds, but not every time User creation succeeds.

  • “The email address has a valid format and is not already registered” → The user
  • “If the user is created successfully, a verification email is scheduled” → The email sign-up operation

3.2. Extracting EmailSignUp to encapsulate its specific behavior

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

Recap

  • Remaining question: how to discover domain operations to model in existing code
  • Proposed approach: start from models’ business rules and reconsider which concepts they belong to
  • Proposed method: consider another use case using the model’s state-changing operations to identify use-case-specific rules

One more thing...

Writing a new edition of Perfect Ruby on Rails

This talk grew out of revising the “Expert Rails” part of the book, which will also cover:

  • Treating domain values as distinct concepts
  • Separating behavior specific to read-only use cases
  • Separating behavior specific to the presentation layer

Summary and thank you

  • Rails’ approach: building state-changing use cases on model CRUD operations offers high productivity, but risks fat models
  • Operation models: address fat models while preserving this approach, using the same concept with or without records
  • Discovering domain operations to model: examine models’ business rules and reconsider which concepts they belong to
  • Before discussing where to put code or layering, ask this:
    “Which concept does this business rule belong to?”
This deck is made with Marp. Thanks, @yhatt!

References

  1. OmniAuth: Overview↩
  2. Managing Multiple Providers↩
  3. Growing Rails Applications in Practice↩
  4. Simplicity on Rails -- RDB, REST and Ruby↩
  5. Growing models with an understanding of Rails: when to discover and split models (in Japanese)↩
  6. An Introduction to FormObject↩
  7. Vanilla Rails is plenty↩
  8. Rails with ApplicationModel↩
  9. Object-Oriented Software Construction, 2nd ed.↩
  10. Domain-Driven Design: Tackling Complexity in the Heart of Software↩

口頭補足 Railsの高い生産性とモデルの肥大化の機序を理解すること、既存コードから操作を発見し、モデリングする方法を理解することがゴール。 Railsで一通りの機能を実装でき、モデルの肥大化とその解決策について議論したことがある方を想定する。

口頭補足 メールの検証と確認メールの送信予約をUserに置く。UserEmailを作ってメールアドレスの値と確認状態をUserから分離する設計もできるが、この例ではUserにまとめて説明する。

口頭補足 form_withはモデル名と新規・保存済みの状態から送信先を推定する。ここでは新規UserなのでPOST /usersになり、resourcesが生成したルーティングにマッチする。

https://app.diagrams.net/#G16Y4CRwATXYsIf7yPwDiFxnKkzJeN9gXq#%7B%22pageId%22%3A%221umC9DkSM2zcDom8CygX%22%7D

口頭補足 ここまでの内容を抽象化すると、Railsでは、scaffoldで生成したモデルにバリデーションやコールバックを追加するだけで、更新系のユースケースを実現できる、と言える。これがRailsの高い生産性の正体。 それを支える要因は二つ。 1. アプリケーションの構造があらかじめ用意されており、設計判断が減らせること。これにより、データモデリングと必要な振る舞いの実装に集中できる。 2. scaffoldとバリデーション・コールバックを利用して、記述するコード量を減らせること

口頭補足 Rails WorldのKeynoteでの「書かずに済んだコードに興奮していた」という言葉。今の例でも、Railsに任せられる部分が多い。

口頭補足 buildした紐付け情報はUserを保存すると一緒に保存される。関連の検証もこの経路で実行されるため、uidの検証はUserIdentityに任せられる。

口頭補足 on: :google_signupで確認日時の存在を検証し、unless: :email_verified_atで確認メールのコールバックを実行しない。確認済みの値を受け取ったときだけ日時を設定するため、この条件でGoogle側の確認状態を検証できる。

口頭補足 OmniAuthでGoogle認証のリクエストとレスポンスを扱う例。このfrom_omniauthが返すUserが保存済みかどうかで成功と失敗を分けている。この例では省いている、既存ユーザーを取得できた場合も成功扱いになる。

https://app.diagrams.net/#G16Y4CRwATXYsIf7yPwDiFxnKkzJeN9gXq#%7B%22pageId%22%3A%22DgWlHgV3BffLBHDclJP7%22%7D

口頭補足 過去のKaigi on Railsでは、フォームを表すオブジェクトに固有の処理を担わせる方法や、行為を表すモデルに自身の記録と関連処理を担わせる方法が議論された。イベントエンティティは業務上の出来事を記録するエンティティという意味で用いる。

https://app.diagrams.net/#G16Y4CRwATXYsIf7yPwDiFxnKkzJeN9gXq#%7B%22pageId%22%3A%22jHk9XW5TJDwDVSJHRNqv%22%7D

口頭補足 操作そのものを表現するモデルを導入する。以降はoperation modelと呼ぶ。

口頭補足 EmailSignUpのsaveはUserを保存したあとに確認メールの送信を予約する。もともとunless: :email_verified_atで実行条件を指定していたコールバックに相当する振る舞いが、この操作のモデルに移っている。

口頭補足 GoogleSignUpはメールアドレスが確認済みかを検証し、紐付け情報をbuildしてUserを保存する。on: :google_signup付きのバリデーションで実現していた確認済みかどうかの検証が、このモデルに対応する。

口頭補足 Userからはサインアップ固有のコールバックや実行条件がなくなる。この例ではメールの形式と未登録の検証、確認用トークンをUserに残している。操作のモデルへ何でも移すという意味ではない。

口頭補足 Userに残した検証は操作モデルからも利用する。 Userの検証に失敗したら、必要な属性のエラーを操作モデルのエラーとして取り込む。 ActiveModel::Errors#import APIはRails 6.1より利用可能

https://app.diagrams.net/#G16Y4CRwATXYsIf7yPwDiFxnKkzJeN9gXq#%7B%22pageId%22%3A%22kRmInHfIsOCptKvRygoD%22%7D

口頭補足 操作を表現するテーブルと対応しないモデルの導入により、既存モデルへユースケース固有の振る舞いが蓄積する、Railsのアプローチの欠点に対処できた。 一方で、URLで表されるリソースとモデルへの操作を対応させ、モデルの作成操作を中心にユースケースを構成する、Railsのアプローチの利点は維持できた。

口頭補足 EmailSignUpをActiveRecordのモデルにする。belongs_toの新規Userは操作モデルとともに保存され、作成のコミット後に確認メールを予約する。紹介者への参照も記録できる。二つの操作モデルをSTIで同じテーブルに対応させる設計も可能だが、ここでは割愛する。

https://app.diagrams.net/#G16Y4CRwATXYsIf7yPwDiFxnKkzJeN9gXq#%7B%22pageId%22%3A%22-GD7zlmD5HF45cgQk2EL%22%7D

https://app.diagrams.net/#G16Y4CRwATXYsIf7yPwDiFxnKkzJeN9gXq#%7B%22pageId%22%3A%22-GD7zlmD5HF45cgQk2EL%22%7D

口頭補足 既存のコードから、モデル化すべき操作をどう発見するか。手書きしたコードにも、AIコーディングエージェントが生成したコードにも同じ問いを向けられる。

口頭補足 ガード節の例はEric EvansのDDD本から、他は発表者の経験に基づく。こうした実装なら必ず操作モデルにする、という判定基準ではない。

口頭補足 サインアップ操作をEmailSignUpとして抽出し、作成後の確認メール送信予約をそこへ集約する。Userには形式と未登録の検証を残す。 ここで表示しているのは前のパートと同じ操作モデルで、本パートでは既存のUserからそれを発見する判断の過程を示した。