🔥

Endless メソッドでコントローラーを1行化する

に公開

この記事は Codex を利用して執筆しているため、記述に矛盾が含まれる可能性があります。お気づきの点があればぜひ教えてください。

はじめに

肥大化した Rails コントローラーに悩まされていないでしょうか。ビジネスロジックが入り込み、テストが辛く、IRB で振る舞いを確認するたびに複雑なパラメータや画面遷移を再現しなければならない――そんな状況を、Ruby のダイナミクスを活かして解消したいのが本稿の狙いです。

目指すゴールは次の通りです。

  • Rails の RESTful フローを崩さず薄いコントローラーを取り戻す: まずは new → create → show → update に沿った責務分担を明確化する。
  • リクエストと検証の疎結合で IRB / テストを容易に: パラメータ検証と状態更新を切り離し、update などもコンソールから安全に試行できるようにする。
  • 宣言的なフローからイベントを導き副作用を整理: フロー定義をそのままイベント的エンティティへ落とし込み、同期・非同期の切り替えやメール送信をわかりやすく保つ。そのための手段として Endless メソッドなどの構文的な工夫を活かす。

目的を掘り下げる

  • 肥大化するコントローラーを Railsway へ引き戻す: ルーティングからビジネスロジックまでの責務を整理し、読み始めてすぐ意図がわかる構造にする。Endless メソッドはその実装を助ける手段に過ぎない。
  • パラメータ検証と更新処理を切り離す: weak_parameters で入力を確定させたうえでドメインオブジェクトに委譲し、画面リロードなしでも IRB やテストで完結できるようにする。
  • イベント系エンティティとして再構成する: CallLater / Delayed を起点に、コード送信や検証完了といったイベントを明示し、副作用をトレースしやすくする。

この背景を踏まえ、Railsway のフローを崩さないままコントローラーを薄く保つ手段として Endless メソッド(def method_name = expression 形式)を活用し、ビジネスロジックはモデル配下のドメインオブジェクトへ委譲します。構文的な簡潔さはあくまで手段であり、Delayed Job の delay や Sidekiq Extensions に似た CallLater / Delayed ヘルパーと組み合わせて副作用を宣言的に扱えるようにします。

本記事では Inertia.js を利用した SMS 認証フローを題材に、1) 目指すコントローラー像、2) それを支えるルーティングとドメイン設計、3) Endless メソッドがもたらす宣言的な書き味を順番に確認します。詳細なコードは記事末尾の付録にまとめ、本文では「なぜそう書くのか」を中心に記述します。

まず押さえたい構成要素

config/routes.rb

# config/routes.rb
namespace :mypage do
  resources :verifications, only: [:new, :create, :show, :update]
end

GET /mypage/verifications/new で電話番号を入力し、POST /mypage/verifications で検証レコードを作成して SMS を送信します。GET /mypage/verifications/:id でコード入力画面を表示し、PATCH /mypage/verifications/:id で検証コードを確定してユーザー作成またはログインに進みます。すべて Mypage::VerificationsController に収める Railsway の構成です。

Schemafile の断片

create_table :user_verifications, force: :cascade do |t|
  t.string   :phone_number,        null: false
  t.string   :authentication_code, null: false
  t.datetime :expires_at,          null: false
  t.datetime :verified_at
  t.references :user, foreign_key: true
  t.timestamps
end

create_table :user_verification_attempts, force: :cascade do |t|
  t.references :verification, null: false, foreign_key: { to_table: :user_verifications }
  t.jsonb :payload, null: false
  t.string :status, null: false, default: "pending"
  t.timestamps
end

create_table :users, force: :cascade do |t|
  t.string :phone_number, null: false
  t.timestamps
end
add_index :users, :phone_number, unique: true

Attempt 側には status カラムを用意し、pending / succeeded / failed の 3 状態を str_enum で表現します。検証プロセスの成否や再実行可否を履歴として辿れるようにするのが狙いナリ。

アプローチの概要

  • アクションは Endless メソッドで 1 行にし、宣言的にリダイレクトや props を組み立てる
  • ドメインオブジェクト(User::Verification::Attempt::Verifier / User::Verification::CodeSender)がリクエスト依存の状態を肩代わりし、IRB からも呼び出しやすくする
  • CallLater / Delayed で(Delayed Job や Sidekiq が昔から提供していた delay 拡張のように)同期・非同期を同じ呼び方で切り替え、コントローラーから非同期処理を追い出す
  • weak_parameters で入力を検証し、クリーンなパラメータオブジェクトを下流へ渡す

前提

  • Rails + Inertia.js を使用する想定
  • weak_parameters によるパラメータ検証を導入済み
  • SMS 認証は User::Verification::Attempt::Verifier / User::Verification::CodeSender というドメインオブジェクトで表現する

全体の流れ

前提とゴール

  • Inertia.js の画面構成を利用し、バックエンドは Rails の RESTful アクションで実装する。
  • 電話番号を起点に SMS 認証を行い、検証完了後にマイページへ遷移させる。

画面とアクションの流れ

  1. GET /mypage/verifications/new — Inertia.js で電話番号入力画面を表示する。
  2. POST /mypage/verificationsVerificationsController#createUser::Verification を生成し、SMS 認証コード送信イベントを非同期でキックする。
  3. GET /mypage/verifications/:id — 認証コード入力画面を表示する。
  4. PATCH /mypage/verifications/:idVerificationsController#update が入力されたコードを User::Verification::Attempt に記録し、そのまま検証してユーザー作成またはログインを完了させる。

画面の描画は Inertia 側に任せ、バックエンドは VerificationsController 1 本に検証フローをまとめつつ、update で試行レコードを内部的に作成・検証する構造にします。

SMS認証付きユーザー登録の実装

ユースケースと設計の要点

先ほど整理した new → verifications#create → show → verifications#update の流れを、Inertia.js の 2 画面構成(電話番号入力 / 認証コード入力)でそのまま具現化します。ここで重要なのは次の順序です。

  1. 電話番号入力 → create アクションが before_create コールバックを通じて検証レコードを用意し、SMS 認証コード送信イベントを非同期キック(登録フェーズ)
  2. SMS 認証コード入力 → update アクションが試行レコードを内部で永続化し、その場で検証・ユーザー生成(認証フェーズ)

登録時に必ずコード送信を済ませ、試行レコード作成時に検証からユーザー作成まで完結させるという前提が、ドメインロジック全体の設計を決めます。

モデルの定義

create アクションで生成した認証レコードを show / update に引き継ぐ土台として、状態と関連オブジェクトを束ねる User::Verification モデルを用意します。外部連携や検証処理といったビジネスロジックは、後述のドメインクラスへ委譲します。

class ApplicationRecord
  include Trice::ReferenceTime
end

# app/models/user.rb
class User < ApplicationRecord
end

# app/models/user/verification.rb
class User::Verification < ApplicationRecord
  before_create { self.authentication_code ||= SecureRandom.random_number(10**6).to_s.rjust(6, "0") }
  before_create { self.expires_at ||= reference_time.since(5.minutes) }

  belongs_to :user, optional: true
  has_many :attempts, class_name: "User::Verification::Attempt", inverse_of: :verification, dependent: :destroy

  def code_sender = User::Verification::CodeSender.new(self)
end

Trice::ReferenceTime を mixin しておけば、テストやコンソールから reference_time を固定したままレコード生成が行えます。これによりコールバック内の expires_at 計算も決定的になり、RSpec の travel_to に頼らずに検証できます。

code_sender は検証レコードに束縛された SMS 送信専用のオブジェクトです。create! の直後に code_sender.call_later を呼び出せば、生成済みの認証コードと有効期限をそのまま SMS に載せられます。バックグラウンド実行を選んでも戻り値は User::Verification のままなので、コントローラー側は永続化結果を show 画面にシームレスに引き渡せるナリ。

verify 用の Attempt でパラメータと検証を一貫管理

検証アクションで扱うパラメータはリクエスト依存が強いため、User::Verification とは別に試行を表す Attempt モデルを用意し、永続化したうえで検証処理を呼び出します。

# app/models/user/verification/attempt.rb
class User::Verification::Attempt < ApplicationRecord
  str_enum :status, [:pending, :succeeded, :failed], default: :pending
  belongs_to :verification, class_name: "User::Verification", inverse_of: :attempts
  store_accessor :payload, :authentication_code

  def verifier = User::Verification::Attempt::Verifier.new(self)
end

payload は PostgreSQL の jsonb カラムとして定義し、store_accessor で個別の値へアクセスします。Attempt では str_enumstatus を宣言するだけに留め、Endless メソッドの verifierUser::Verification::Attempt::Verifier インスタンスを生成して「永続化済みの入力を渡す」責務だけに徹します。status の初期値は Schema 側の default: "pending" に委ねているため、コールバックを追加せずとも冪等なメッセージパッシングを維持できるナリ。(str_enum は Rails 7.1 以降で利用できる ActiveRecord::Enum の拡張、あるいは rails-str_enum などの軽量 gem を導入して使う想定ナリ。)

User::Verification::Attempt::Verifierreference_time を使って期限判定や verified_at の更新時刻を決め、電話番号で既存ユーザーを再利用するか新規作成するかまでをトランザクション内で完結させます。検証成功時は attempt.succeeded!、失敗時は attempt.failed! を呼び出して履歴を残しつつ、戻り値として { user:, user_created: } を返します。AttemptTrice::ReferenceTime を mixin しているため、テストでは reference_time をスタブするだけで検証日時を固定できます。呼び出し元は attempt.verifier.call => { user:, user_created: } のパターンで結果を受け取り、ビューやセッション更新へ利用できるナリ。

ドメインロジッククラス(モデルの下層に配置)

SMS認証コード送信

create アクションから call_later で非同期実行し、レコード作成時に確定した認証コードと有効期限をそのまま取り出して送信に専念させます。戻り値の型は RBS インライン注釈(# : User::Verification)で示し、呼び出し側への意図も併記しておきます。

# app/models/user/verification/code_sender.rb
class User::Verification::CodeSender
  include CallLater.define(User::Verification, queue_as: :notifications)

  def call = TwilioClient.messages.create(from:, to:, body:)

  def verification = record
  def body = "Your verification code is: #{code}"
  def from = Rails.application.credentials.dig(:twilio, :phone_number)
  def to = verification.phone_number
  def expires_at = verification.expires_at
  def code = verification.authentication_code
end

User::Verification::Attempt::Verifier で検証とユーザー確定を完結させる

# app/models/user/verification/attempt/verifier.rb
class User::Verification::Attempt::Verifier
  include CallLater.define(User::Verification::Attempt, queue_as: :notifications)
  class FailedVerification < StandardError; end

  def call
    ensure_valid_attempt
    resolve_and_update
  rescue FailedVerification => error
    attempt.failed!
    raise error
  end

  private

  def attempt = record
  def verification = attempt.verification
  def entered_code = attempt.authentication_code
  def current_time = attempt.reference_time
  def expired? = verification.expires_at.present? && verification.expires_at <= current_time
  def mismatched_code? = verification.authentication_code != entered_code

  def resolve_and_update
    verification.transaction do
      result = resolve_user
      verification.update!(verified_at: current_time, user: result[:user])
      attempt.succeeded!
      result
    end
  end

  def ensure_valid_attempt
    raise FailedVerification, "Attempt(id=#{attempt.id}) expired" if expired?
    raise FailedVerification, "Attempt(id=#{attempt.id}) mismatched code" if mismatched_code?
  end

  def resolve_user
    user = User.find_or_initialize_by(phone_number: verification.phone_number)
    user_created = user.new_record?
    user.save! if user_created
    { user:, user_created: }
  end
end

call{ user:, user_created: } を返すため、そのままフラッシュへ積んだり、result => { user:, user_created: } と展開してビューレイヤーへ渡せるナリ。検証に失敗した場合は FailedVerification で揃えておき、コントローラー側で rescue_from すれば例外の粒度を統一できます。すべてトランザクション内で完結するので、非同期ジョブに委ねる必要もありませんナリ。さらに rescue_from で握った際には flash[:alert]flash[:verification_error] に情報を積むだけで済み、Inertia へ一貫したエラーメッセージを届けられるナリ。

CallLater / Delayed の補足

ここでの CallLater / Delayed は、Delayed Job で昔から使われている SomeJob.delay.call や Sidekiq の Extensions とまったく同じ発想の薄いラッパーです。実装の掘り下げは付録で触れますが、意識すべきポイントは次の 2 つだけです。

  • ドメインオブジェクトで include CallLater.define(...) と書けば call / call_later が揃う
  • *_later を呼ぶと ActiveJob が生成され、同期・非同期の切り替えをコントローラーが気にする必要がなくなる

Endless メソッドでメッセージパッシングを整える

以下は一例ナリ。Endless メソッドで ActiveRecord への委譲を 1 行にまとめ、weak_parameters が返す整形済みハッシュをそのまま渡してメッセージパッシングを冪等に保つ構成を示すナリ。

# app/controllers/mypage/verifications_controller.rb
class Mypage::VerificationsController < ApplicationController
  validates(:create) { string :phone_number, required: true, strong: true }
  validates(:update) { string :authentication_code, required: true, strong: true }

  rescue_from(ActiveRecord::RecordInvalid) do |error|
    redirect_to new_mypage_verification_path, alert: error.message
  end
  rescue_from(User::Verification::Attempt::Verifier::FailedVerification) do |error|
    redirect_to mypage_verification_path(params[:id]), alert: error.message
  end
  rescue_from(ActiveRecord::RecordNotFound) do
    redirect_to new_mypage_verification_path, alert: "Verification not found"
  end

  def new = render(inertia: true)
  def show = render(inertia: true, props:)

  def create
    redirect_to User::Verification
      .create!(validated_params(:create))
      .tap { |verification| verification.code_sender.call_later }
      .then { |verification| mypage_verification_path(verification) }
  end

  def update
    User::Verification
      .find(params[:id]).attempts
      .create!(payload: validated_params(:update))
      .verifier.call
      .tap { |result| sign_in result.fetch(:user) }
      .then { |result| redirect_to mypage_root_path, flash: { verification: result } }
  end

  def props = { verification: User::Verification.find(params[:id]) }
end

validated_params(:create){ phone_number: ... } を返し、そのまま create! に渡せるナリ。tapcode_sender.call_later を挟んでも戻り値は変わらないため、Endless メソッドの 1 行構成を保ったまま非同期 SMS 送信をキックできます。validated_params(:update){ authentication_code: ... } を返すので、Attempt の payload に貼り付けるだけで済むナリ。

update では User::Verification.find(params[:id]).attempts.create!(…)verifier.call を返し、続けて sign_in(Devise 想定)でセッションを確立したうえで mypage_root_path へ遷移するナリ。失敗時は ActiveRecord::RecordInvalid / FailedVerification / ActiveRecord::RecordNotFoundrescue_from で握り、エラーメッセージをそのまま Inertia 側に流せるナリ。

ActiveRecord::RecordInvalid を素直に握っておくナリ

独自例外を増やさなくても、rescue_from(ActiveRecord::RecordInvalid) を添えておけば検証エラーをその場で握れます。Endless メソッドと組み合わせてもレスキューの粒度は変わらず、redirect_to の引数だけに集中できるナリ。

アーキテクチャのポイント

モデルが中心

すべてのドメインロジックはモデル経由でアクセスします。

User::Verification.find(params[:id]).attempts.create!(
  payload: { authentication_code: params[:authentication_code] }
).verifier.call

この設計により、コントローラーは「モデルとドメインロジックを繋ぐ薄い層」になります。

モデルの下層にドメインロジックを配置

ドメインロジッククラスはモデルの直下に配置します。

User::Verification                   # モデル本体
User::Verification::Attempt          # 試行を永続化して verifier を提供
User::Verification::CodeSender       # モデルに直接束縛
User::Verification::Attempt::Verifier # Attempt が保持する検証ロジック

これにより、「どのモデルに関連するロジックか」が一目瞭然になります。

Service クラスを使わない

Service クラスは責務が曖昧になりがちです。

# ❌ Service クラス(責務が曖昧)
UserRegistrationService.new(params).call

# ✅ モデル経由のドメインロジック(責務が明確)
User::Verification.find(params[:id]).attempts.create!(
  payload: { authentication_code: params[:authentication_code] }
).verifier.call

CallLater ヘルパーのおさらい

Delayed Job の delay や Sidekiq Extensions と同じノリで、同じ呼び出し口から同期/非同期を選び分けられるようになります。

# 同期実行
user_verification.code_sender.call

# 非同期実行
user_verification.code_sender.call_later

待機時間を指定することも可能です。

user_verification.code_sender.call_later(wait: 5.minutes)

テストでも威力を発揮します。

# 同期実行でテスト
it 'uses the pre-generated verification code' do
  expect { user_verification.code_sender.call }
    .not_to change { user_verification.reload.authentication_code }
end

# 非同期実行でテスト
it 'enqueues SMS verification job' do
  expect { user_verification.code_sender.call_later }
    .to have_enqueued_job(User::Verification::CodeSender::DelayedCallJob)
end

# Attempt 経由で検証するテスト
it 'verifies via persisted attempt payload' do
  expect {
    user_verification.attempts.create!(
      payload: { authentication_code: user_verification.authentication_code }
    ).verifier.call
  }.to change { user_verification.reload.verified_at }.from(nil)
end

Controller と Model で Namespace を分ける

Mypage::VerificationsController   # Controller namespace
User::Verification                 # Model namespace

これにより、Controller の関心事と Model の関心事が明確に分離されます。

View を Inertia 側へ寄せる

Inertia.js でレスポンスを返す前提にすると、Rails 側では render inertia: true, props: ... だけで済みます。ERB を介さず props の組み立てだけに集中できるため、中途半端な ViewModel や Decorator に頼る必要がありません。レスポンスの複雑さはフロントエンドに閉じ込め、Rails 側はドメイン処理と状態遷移に特化させます。

付録: CallLater / Delayed 実装の概略

Delayed Job の delay や Sidekiq の SomeJob.perform_async を好きなクラスに生やす拡張を、アプリ側で薄く書き直しただけの構成です。

module CallLater
  module Initializer
    def initialize(record) = @record = record

    protected

    attr_reader :record
  end

  def self.define(model, method_name: :call, queue_as: :default, wait: nil, attempts: nil)
    Module.new do
      include Initializer
      include Delayed.define(self, method_name, queue_as:, wait:, attempts:)

      define_singleton_method(:find) { |id| new(model.find(id)) }
    end
  end
end
module Delayed
  def self.define(namespace, name = :call, queue_as: :default, wait: nil, attempts: nil)
    job = Class.new(ApplicationJob) do
      retry_on(StandardError, wait:, attempts:) if wait && attempts
      queue_as queue_as
      define_method(:perform) { |record| record.public_send(name) }
    end

    job_name = "Delayed#{name.to_s.delete_suffix('!').camelize}Job"
    namespace.const_set(job_name, job)

    Module.new do
      define_method("#{name}_later") do |**options|
        job.set(options).perform_later(self)
      end
    end
  end
end

CallLater.define がドメインオブジェクトに call / call_later を注入し、Delayed.define が ActiveJob を生成することで、アプリ側は「呼ぶか、遅らせて呼ぶか」だけを宣言すればよくなります。

メリット

  1. コントローラーがシンプル: ビジネスロジックがゼロになる
  2. テストしやすい: ドメインロジックを独立してテストできる
  3. 再利用しやすい: モデル経由でどこからでも呼び出せる
  4. 変更に強い: ロジックの変更がコントローラーに影響しない
  5. 可読性が高い: 宣言的な記述で処理の流れが明確
  6. 非同期処理が簡単: call と call_later で切り替えるだけ

まとめ

Endless メソッドを活用し、コントローラーからビジネスロジックを完全に排除することで、驚くほどシンプルなコードが実現できます。

ポイントは以下の通りです。

  1. モデルが中心: すべてのドメインロジックはモデル経由でアクセス
  2. モデルの下層に配置: ドメインロジックをモデルに直接束縛
  3. Service クラスを使わない: モデルから移譲したドメインロジッククラスを使う
  4. CallLater ヘルパー: 同期・非同期の切り替えを宣言的に
  5. weak_parameters: 宣言的なバリデーション
  6. Namespace で分離: Controller と Model の関心事を分ける

これにより、コントローラーは「ドメインロジックを呼び出すだけの薄い層」となり、保守性・テスタビリティが大幅に向上します。

GitHubで編集を提案

Discussion