3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】Fat Controllerを回避する。「単一責任」と「DRY」を守るために使っている実装パターン

Posted at

はじめに

この記事は、デイトラプログラミングコース Advent Calendar 2025の第17日目です。

別のAdventカレンダー記事で未経験1年目の立場から「未来の負債を減らすマインド」について書きました。 今回はその実践編として、「具体的にどうコードに落とし込んでいるか」をまとめます。

Railsの基本方針は「Controllerの責任を処理の呼び出しに留める(SkinnyController)」だと理解しています。 しかし、開発が進むとModelすら肥大化したり、責務が曖昧になる場面に遭遇しました。

そこで、「単一責任の原則」と「DRY」を守るために使える機能をサンプルコード付きで紹介します。

注意点

  • 解説までしっかり記載すると1項目1記事にすべきくらいの文量になってしまうので、解説は端的にします
  • サンプルコードは実コードをAIで元のコードがわからなくなる程度に変更しています

複雑なデータ操作を切り出す

Form Object

【使い所】

  • 複雑なネストした子モデルの保存をバリデーションつきで行う
  • このフォームから送信された場合だけ走らせたいバリデーションがある …など

Bad Example: 無理やりControllerで頑張っている例

悪い例として、担当した機能追加案件で、親モデルと子モデル(複数)と孫モデル(子モデルに対して更に複数)をcreateする機能の開発を任せていただいた際のコードをお見せします…

【問題点】

  • バリデーションのためだけに「ダミーモデル(@dummy_form_model)」を作っている
  • params のハッシュを手動でループして解析している
  • エラーメッセージを子オブジェクトから親オブジェクトへ手動でコピーしている
  • 保存処理もController内にベタ書きされており、可読性が低い
# app/controllers/bulk_trips_controller.rb
class BulkTripsController < ApplicationController
  def create
    # ❌ 1. エラー表示用のためだけに、保存しないダミーモデルを用意している
    @dummy_form_model = Customer.new

    # ❌ 2. パラメータを手動で掘り起こしてループ処理
    trips_params = params.dig(:customer, :trips_attributes) || {}

    trips_params.each_value do |trip_param|
      # 一時的なオブジェクトをビルド
      trip = @dummy_form_model.trips.build(date: trip_param[:date])

      # 孫要素(地点)も手動でループしてビルド
      (trip_param[:positions_attributes] || {}).each_value do |pos_param|
        trip.positions.build(address: pos_param[:address])
      end

      # ❌ 3. バリデーションエラーを手動で親に「詰め替え」ている
      # 本来モデルがやるべき仕事をコントローラーが奪っている状態
      unless trip.valid?
        trip.errors.full_messages.each do |message|
          @dummy_form_model.errors.add(:base, message)
        end
      end
    end

    # エラーがあれば再レンダリング(早期リターン)
    if @dummy_form_model.errors.any?
      render :new and return
    end

    # ❌ 4. トランザクションと保存処理がコントローラーに記述されている
    begin
      ActiveRecord::Base.transaction do
        # ...(省略:再度ループを回して、実際にDBへ保存する複雑な処理)...
        save_complex_trips!(trips_params) 
      end
      redirect_to success_path, notice: "登録しました"
    rescue => e
      @dummy_form_model.errors.add(:base, "保存に失敗しました")
      render :new
    end
  end
end

これ以上ないくらいFat…

「今後はこんなクソコードを生み出すまい…」と決意して、別案件で似たようなタスクを担当した際に作ったのが以下のコードです。

Example: Form Objectの導入

【ポイント】

  • バリデーションの委譲: 子モデル(Courses)の検証をForm内で手動で行い、エラーがあれば親(Form)に追加する
  • Viewへの配慮: valid? の前に assign_attributes することで、バリデーションエラー時も入力値を保持して画面に戻れるようにする
  • トランザクション: 複数のテーブル更新を save メソッド内に閉じ込める
FormObject
# app/forms/school_registration_form.rb
class SchoolRegistrationForm
  include ActiveModel::Model

  # 親モデル(School)の属性
  attr_accessor :name, :address, :phone_number, :is_published
  # 子モデル(Course)の属性・その他タグ等
  attr_accessor :courses_attributes, :tag_ids

  # フォーム内で扱う実態としてのモデル
  attr_reader :school

  # 親モデル側のバリデーションはここに書ける
  validates :name, presence: { message: 'は必須項目です' }
  validates :address, presence: true
  
  # 子モデルのバリデーションを呼び出す
  validate :validate_children_models

  def initialize(attributes = {}, school: nil)
    @school = school || School.new
    super(attributes)
    # フォーム初期化時に配列を保証しておく
    @courses_attributes ||= []
  end

  def save
    # 重要: valid?の前に値をセットしないと、エラー時にViewへ入力値が戻らない
    @school.assign_attributes(formatted_attributes)
    
    return false unless valid?

    ActiveRecord::Base.transaction do
      @school.save!
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    # 万が一のDBレベルのエラーもFormのエラーとして扱う
    e.record.errors.each { |attr, message| errors.add(attr, message) }
    false
  end

  private

  # 保存用に属性を整形する
  def formatted_attributes
    {
      name: name,
      address: address,
      phone_number: phone_number,
      is_published: is_published,
      # ネストした属性としてそのまま渡す
      courses_attributes: courses_attributes,
      # 空文字を除去して渡す
      tag_ids: tag_ids.reject(&:blank?)
    }
  end

  # 子モデル(Course)のバリデーションを手動で実行するロジック
  def validate_children_models
    # courses_attributes をループして検証用のインスタンスを作る
    courses_attributes.each_value.with_index(1) do |attrs, idx|
      # buildだと親のsave時に意図せず保存されるリスクがあるため、newで検証用インスタンスを作成
      course = Course.new(attrs)
      
      unless course.valid?
        # 子モデルのエラーメッセージをFormのエラーとして追加
        course.errors.full_messages.each do |message|
          errors.add(:base, "コース#{idx}件目: #{message}")
        end
      end
    end
  end
end
Controller
class SchoolsController < ApplicationController
  def create
    @form = SchoolRegistrationForm.new(school_params)

    if @form.save
      redirect_to schools_path, notice: 'スクールを登録しました'
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def school_params
    params.require(:school).permit(
      :name, :address, :phone_number, :is_published,
      tag_ids: [],
      courses_attributes: [:title, :price, :description]
    )
  end
end

Serviceクラス

【使いどころ】

  • 外部APIと連携する(決済など)
  • 複数のモデルを跨ぐ業務ロジックを実装する …など

※チームでルールを何も設けずにServiceクラス化していくとカオスになりやすい面もあるようです
 詳細は最下部の参考を御覧ください

Bad Example: モデルにロジックがベタ書きされている

親モデルにhas_manyの関係を持つ子モデルがあり、親オブジェクトに紐づく子オブジェクト(複数)の並び順をいい感じに調整したい、という要件に対応した例です。

「スライドの並び順(position)」を管理するためだけの長いロジックが鎮座し、本来のモデルの定義が見えにくくなっています。

【問題点】

  • 並び順の管理ロジックだけでモデルが埋め尽くされ、可読性が低い
  • モデルに依存した実装になっているため、使い回しが効きにくい
    • slidesというアソシエーションやpositionというカラムが前提になっている
# app/models/presentation.rb
class Presentation < ApplicationRecord
  has_many :slides

  # ❌ 並び順の管理ロジックだけでモデルが埋め尽くされている

  # 1. 入力された数値の正規化(範囲外なら最大値などを返す)
  def normalize_position(new_position, action:)
    total = slides.count
    if new_position <= 0
      1
    elsif new_position <= total
      new_position
    else
      total + (action == :create ? 1 : 0)
    end
  end

  # 2. 作成時の並び替え(割り込む場所にスペースを作る)
  def reorder_before_create(new_position)
    total = slides.count
    return if new_position > total

    Slide.transaction do
      target_positions = (new_position..total).to_a
      # 指定位置以降のスライドを全て+1する
      slides.where(position: target_positions).each do |slide|
        slide.update!(position: slide.position + 1)
      end
    end
  end

  # 3. 更新時の並び替え(移動による玉突き事故を防ぐ)
  def reorder_on_update(slide, new_position)
    old_position = slide.position
    return if new_position == old_position

    Slide.transaction do
      if new_position > old_position
        # 後ろに動かす場合: 間のスライドを前に詰める(-1)
        target_positions = (old_position..new_position).to_a
        slides.where(position: target_positions).each do |s|
          s.update!(position: s.position - 1)
        end
      else
        # 前に動かす場合: 間のスライドを後ろにずらす(+1)
        target_positions = (new_position..old_position).to_a
        slides.where(position: target_positions).each do |s|
          s.update!(position: s.position + 1)
        end
      end
      # 最後に自身を更新(呼び出し元で行う場合は不要だが、ロジックとしてここに含む場合)
      slide.update!(position: new_position)
    end
  end

  # 4. 削除後の並び替え(欠番を埋める)
  def reorder_after_delete
    Slide.transaction do
      slides.order(:position).each.with_index(1) do |slide, idx|
        slide.update(position: idx)
      end
    end
  end
end

Example: Serviceクラスでの実装

【ポイント】

  • モデルが業務ロジックで汚れない
  • 抽象化され、あらゆるモデルで利用可能
class ReorderService
  include ActiveModel::Model

  # 概要
  # - 追加・更新・削除時に並び順を適切に整理するためのServiceです
  #
  # 挙動
  # - 対象インスタンスの兄弟インスタンスの並び順をupdateします※DB反映あり
  # - 対象インスタンスの保存は行いません
  # - 返り値はtrue/false です
  # - 内部的にはincrement!を使用しているため、バリデーションはスキップされます
  #
  # 重要な注意点
  # - 必ず対象インスタンスをsaveする前にこのServiceクラスを呼ぶこと
  # - 並べ替えの最中に一時的にDB上の並び順カラムが重複する瞬間が存在するため、ユニーク制約は設けないこと
  # - new_order_numは文脈によって指しているものが違うためインスタンス変数化すると危険


  # 呼び出し例: 
  # ReorderService.call(instance: @slide, number_field: :position, parent: :presentation, action: :create, scope: { deleted_at: nil })
  def self.call(instance:, number_field:, parent: nil, action:, scope: nil)
    new(instance: instance, number_field: number_field, parent: parent, action: action, scope: scope).call
  end

  def initialize(instance:, number_field:, parent:, action:, scope: nil)
    # 作成・更新対象のインスタンス
    @instance = instance
    # 並び順を保持しているフィールド名
    @number_field = number_field.to_sym
    if parent
      # 親が存在する場合の対象親インスタンス
      @parent = @instance.public_send(parent)
      # 親で呼び出すアソシエーション名
      @collection_name = @instance.class.model_name.collection.to_sym
    end
    # 条件分岐に使用するsymbol、:create, :update, :destroyを想定
    @action = action.to_sym
    # インスタンスを含む対象スコープのコレクション
    base_relation = parent ?
      @parent.public_send(@collection_name) :
      @instance.class.all

    @collection = scope.present? ? base_relation.where(**scope) : base_relation
    # コレクションの数=並び順の最大値
    @total = @collection.count
  end

  def call
    return if !@instance || !@number_field || !@action
    set_normalized_number

    case @action
    when :create
      reorder_before_create
    when :update
      reorder_before_update
    when :destroy
      reorder_before_destroy
    end
  end

  private

  def set_normalized_number
    new_order_num = @instance.public_send(@number_field).to_i

    # インスタンスにセットされた値が0以下の場合は1にする
    if new_order_num <= 0
      order_num = 1
    # インスタンスにセットされた値が対象コレクションを超える場合は最大値に抑える
    elsif new_order_num > @total
      order_num = @total + (@action == :create ? 1 : 0)
    # インスタンスにセットされた値が1~最大値の場合はそのままにする
    else
      order_num = new_order_num
    end

    @instance.public_send("#{@number_field}=", order_num.to_i)
  end

  def reorder_before_create
    new_order_num = @instance.public_send(@number_field).to_i
    # createかつ正規化された並び順が最大値を超えている場合(@total + 1の場合)は何もしない
    return true if new_order_num > @total

    target_orders = (new_order_num..@total).to_a
    # whereでDBを見に行くため、@instanceはtarget_collectionがら除外される
    target_collection = @collection.where(@number_field=>target_orders).order(@number_field=>:desc)

    # increment!が仮に失敗する場合はエラーが返る
    begin
      target_collection.each do |other_instance|
        other_instance.increment!(@number_field)
      end
    rescue => e
      Rails.logger.warn(e.full_message)
      return false
    end

    true
  end

  def reorder_before_update
    # DB上の値を見る, 渡ってくるインスタンスは正しい前提
    new_order_num = @instance.public_send(@number_field).to_i
    old_order_num = @instance.class.find(@instance.id).public_send(@number_field).to_i

    # 並び順の変更がないときはそのまま帰す
    return true if new_order_num == old_order_num

    begin
      # 新しい並び順が元の順番より後ろの順番を指すときは、新しい並び順よりも前のインスタンスを前に詰めるような処理をする
      if new_order_num > old_order_num
        target_orders = (old_order_num + 1..new_order_num).to_a
        target_collection = @collection.where(@number_field=>target_orders).order(@number_field=>:asc)
        target_collection.each do |target_instance|
          target_instance.decrement!(@number_field)
        end
      # 新しい並び順が元の順番より前の順番を指すときは、新しい並び順よりも後ろのインスタンスを後ろに移すような処理をする
      else
        target_orders = (new_order_num..old_order_num - 1).to_a
        target_collection = @collection.where(@number_field=>target_orders).order(@number_field=>:desc)
        target_collection.each do |target_instance|
          target_instance.increment!(@number_field)
        end
      end
    rescue => e
      Rails.logger.warn(e.full_message)
      return false
    end

    true
  end

  def reorder_before_destroy
    instance_order_num = @instance.public_send(@number_field).to_i
    # 末尾のときのインスタンスを削除するのであれば処理は不要
    return true if instance_order_num == @total

    # 並べ替えが必要なのは、削除するインスタンスよりも後ろに存在するもの
    target_orders = (instance_order_num + 1..@total).to_a
    target_collection = @collection.where(@number_field=>target_orders).order(@number_field=>:asc)

    begin
      target_collection.each do |target_instance|
        target_instance.decrement!(@number_field)
      end
    rescue => e
      Rails.logger.warn(e.full_message)
      return false
    end

    true
  end
end

共通処理をDRYにする

Concern

【使いどころ】

  • 複数のモデル/コントローラーで、「全く同じ機能・ロジック」を使いたいとき
  • 今回の例のように、Admin画面とOperator画面で「基本的なCRUD処理は同じだが、権限や一部の機能だけ違う」とき

Bad Example: 権限ごとにコントローラーを作り、同じコードが散在している

「権限ごとに共通処理もあるけど、特定の権限じゃないと行わせたくない処理がある」という要件はあるあるだと思います。Concernの存在を知らないときには、全く同じコードをコピペしてしまっていて、修正するときに複数箇所書き換える必要がありました…

【問題点】

  • 共通処理がコピペされているため、仕様変更時に修正範囲が増える
  • 修正漏れなどのバグの温床になる
# app/controllers/admins/schools_controller.rb
class Admins::SchoolsController < Admins::BaseController
  # ❌ 全く同じロジックがOperatorsControllerにも書かれている...
  def create
    @form = SchoolRegistrationForm.new(school_params)
    if @form.save
      redirect_to admins_school_path(@form.school), notice: "作成しました"
    else
      render :new
    end
  end

  # ... update, index 等もコピペコードが続く ...
end

# app/controllers/operators/schools_controller.rb
class Operators::SchoolsController < Operators::BaseController
  # ❌ コピペコードの温床。DRYじゃない!
  def create
    @form = SchoolRegistrationForm.new(school_params)
    if @form.save
      redirect_to operators_school_path(@form.school), notice: "作成しました"
    else
      render :new
    end
  end
end

Example: 共通のアクションをConcernに切り出す

【ポイント】

  • CRUDのような標準的なアクションはConcernにまとめる
  • リダイレクト先(パス)のような「コントローラーごとに違う部分」だけを、各コントローラーでオーバーライド(定義)できるようにし、柔軟性をもたせる

▼Concern

# app/controllers/concerns/school_common_actions.rb
module SchoolCommonActions
  extend ActiveSupport::Concern

  def index
    @q = School.ransack(params[:q])
    @schools = @q.result.page(params[:page])
  end

  def new
    @form = SchoolRegistrationForm.new
  end

  def create
    @form = SchoolRegistrationForm.new(school_params)
    if @form.save
      # redirect_pathは各コントローラーで定義したメソッドを呼ぶ
      redirect_to show_path(@form.school), notice: "作成しました"
    else
      render :new
    end
  end

  # ... edit, update も同様に共通化 ...

  private

  def school_params
    params.require(:school).permit(:name, :address, :phone_number)
  end

  # デフォルトの実装(必要に応じてオーバーライドさせる前提)
  def show_path(school)
    raise NotImplementedError, "You must implement #{self.class}##{__method__}"
  end
end

▼Admin

# app/controllers/admins/schools_controller.rb
class Admins::SchoolsController < Admins::BaseController
  
  # 共通ロジックを読み込む
  include SchoolCommonActions

  # 無記述だと何をしているかわかりにくいので、コメントアウトで明示
  # def index = super
  # def show = super
  # def new = super
  # def create = super
  # def edit = super
  # def update = super

  # Admin特有のアクションだけを記述
  def destroy
    school = School.find(params[:id])
    school.destroy!
    redirect_to admins_schools_path, notice: "削除しました"
  end

  def import_csv
    # CSVインポート処理...
  end

  private

  # def school_form_params = super

  # Concernから呼ばれる「このコントローラー専用のパス」を定義
  def show_path(school)
    admins_school_path(school)
  end
end

▼Operator

# app/controllers/operators/schools_controller.rb
class Operators::SchoolsController < Operators::BaseController
  include SchoolCommonActions

  # Operatorは削除やCSVインポートができないため、単純にincludeするだけで実装完了!
  # def index = super
  # def show = super
  # def new = super
  # def create = super
  # def edit = super
  # def update = super

  private

  # 差分となるパスだけ定義する
  def show_path(school)
    operators_school_path(school)
  end
end

備考

  • 継承でも似たようなことが可能ですが、権限管理のBaseControllerの上にこの共通処理を置くことになるので、このプロジェクトでは要件にあわずConcernを使用しました
  • チームで運用する上で、ルールがないとカオス化しそうです。以下はルールの例です
    • 業務ロジックはServiceクラスで切り出す
    • モデル定義やControllerに記述すべき内容で共通化が必要な場合のみConcernを検討する
    • Controllerの場合、上記のように操作対象のモデルが同じ場合のみConcernを活用する

Custom Validator

【使いどころ】

  • メールアドレス、電話番号、郵便番号など、複雑なフォーマットチェックをモデルから追い出したいとき
  • 複数のモデル(User, Admin, Contactなど)で同じバリデーションロジックを使い回したいとき

Bad Example: モデル内に正規表現が埋め込まれている

【問題点】

  • UserモデルとAdminモデルの両方で、同じような正規表現を書いている
  • 万が一メールアドレスの仕様変更があった場合、修正する必要がある
# app/models/user.rb
class User < ApplicationRecord
  # ❌ 読みづらい正規表現がモデルに鎮座している
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  
  validates :email, presence: true, format: { with: VALID_EMAIL_REGEX }
end

# app/models/contact.rb
class Contact < ApplicationRecord
  # ❌ 別のモデルでも同じ正規表現をコピペして使っている...
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i

  validates :email, presence: true, format: { with: VALID_EMAIL_REGEX }
end

Example: バリデーションロジックをクラスに切り出す

  • ActiveModel::EachValidatorを活用し、モデル側ではemail: true と書くだけで済むようになる
    ※ActiveModel::Validatorもありますが、個人的には共通化目的ならEachValidatorのほうが使いやすく感じています

▼Validatorクラス

# app/validators/email_validator.rb
require 'uri'

class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    # 空文字の場合はpresenceバリデーションに任せるためスルー
    return if value.blank?

    unless value.match(URI::MailTo::EMAIL_REGEXP)
      record.errors.add(attribute, options[:message] || 'の形式を確認してください')
    end
  end
end

▼Model

# app/models/user.rb
class User < ApplicationRecord
  # ✅ たったこれだけ!意味も明確になり、他のモデルでも再利用可能に。
  validates :email, presence: true, email: true
end

Viewのロジックを分離する

Helper

【使いどころ】

  • 日付のフォーマット変換、金額のカンマ区切り、テキストの切り詰めなど、「特定のモデルに依存しない汎用的な表示処理」
  • どの画面でも使うようなHTMLタグの生成ロジック(SEOタグなど)

Example: 日本の市外局番フォーマット処理をHelperに定義

意外とメンテナンスされている手頃なgemがなく、AIでも正確なコーディングができない市外局番フォーマットのサンプルです。
フォームからの入力時にはハイフンを削除する処理をConcernに切り出して、表示する際にはHelperを通す、といった運用がいい感じです。

【ポイント】

  • モデルに依存せず使用できる
  • ViewにもModelにも書きたくないロジックを外に出せる
module TelNumberHelper
  # 総務省令和4年3月1日版の市外局番一覧から取得
  FIVE_DIGIT_AREA_CODES = %w[
    01267 01372 01374 01377 01392 01397 01398 01456 01457 01466
    01547 01558 01564 01586 01587 01632 01634 01635 01648 01654
    01655 01656 01658 04992 04994 04996 04998 05769 05979 07468
    08387 08388 08396 08477 08512 08514 09802 09912 09913 09969
  ].freeze

  FOUR_DIGIT_AREA_CODES = %w[
    0123 0124 0125 0126 0133 0134 0135 0136 0137 0138 0139 0142
    0143 0144 0145 0146 0152 0153 0154 0155 0156 0157 0158 0162
    0163 0164 0165 0166 0167 0172 0173 0174 0175 0176 0178 0179
    0182 0183 0184 0185 0186 0187 0191 0192 0193 0194 0195 0197
    0198 0220 0223 0224 0225 0226 0228 0229 0233 0234 0235 0237
    0238 0240 0241 0242 0243 0244 0246 0247 0248 0250 0254 0255
    0256 0257 0258 0259 0260 0261 0263 0264 0265 0266 0267 0268
    0269 0270 0274 0276 0277 0278 0279 0280 0282 0283 0284 0285
    0287 0288 0289 0291 0293 0294 0295 0296 0297 0299 0422 0428
    0436 0438 0439 0460 0463 0465 0466 0467 0470 0475 0476 0478
    0479 0480 0493 0494 0495 0531 0532 0533 0536 0537 0538 0539
    0544 0545 0547 0548 0550 0551 0553 0554 0555 0556 0557 0558
    0561 0562 0563 0564 0565 0566 0567 0568 0569 0572 0573 0574
    0575 0576 0577 0578 0581 0584 0585 0586 0587 0594 0595 0596
    0597 0598 0599 0721 0725 0735 0736 0737 0738 0739 0740 0742
    0743 0744 0745 0746 0747 0748 0749 0761 0763 0765 0766 0767
    0768 0770 0771 0772 0773 0774 0776 0778 0779 0790 0791 0794
    0795 0796 0797 0798 0799 0820 0823 0824 0826 0827 0829 0833
    0834 0835 0836 0837 0838 0845 0846 0847 0848 0852 0853 0854
    0855 0856 0857 0858 0859 0863 0865 0866 0867 0868 0869 0875
    0877 0879 0880 0883 0884 0885 0887 0889 0892 0893 0894 0895
    0896 0897 0898 0920 0930 0940 0942 0943 0944 0946 0947 0948
    0949 0950 0952 0954 0955 0956 0957 0959 0964 0965 0966 0967
    0968 0969 0972 0973 0974 0977 0978 0979 0980 0982 0983 0984
    0985 0986 0987 0993 0994 0995 0996 0997
  ].freeze

  def format_japanese_phone(number)
    # 空か最初が0以外ならそのまま返す
    return number if number.nil? || number[0] != '0' || number.length < 10

    first_five = number[0, 5]
    first_four = number[0, 4]

    case
    when number.match(/\A(0(?:5|7|8|9)0)(\d{4})(\d{4})\z/) || number.match(/\A(0(?:3|6))(\d{4})(\d{4})\z/) || number.match(/\A(0120)(\d{3})(\d{3})\z/)
      "#{$1}-#{$2}-#{$3}"
    when FIVE_DIGIT_AREA_CODES.include?(first_five) && number.length == 10
      "#{first_five}-#{number[5, 1]}-#{number[6, 4]}"
    when FOUR_DIGIT_AREA_CODES.include?(first_four) && number.length == 10
      "#{first_four}-#{number[4, 2]}-#{number[6, 4]}"
    when number.length == 10
      "#{number[0, 3]}-#{number[3, 3]}-#{number[6, 4]}"
    else
      number
    end
  end
end

▼View

# app/views/users/show.html.slim
p "電話番号: #{format_japanese_phone(@user.phone_number)}"

# app/views/companies/index.html.slim
td = format_japanese_phone(company.tel)

備考

  • 後述のDecoratorとの使い分けについては以下のように考えています
    • 特定のモデルで使用することが前提ならDecorator
    • 汎用的に使用する可能性があるならHelper

Decorator

【使いどころ】

  • モデルのデータを「表示用に加工」したいとき(氏名の結合、住所の連結など)
  • 「このセクションを表示すべきか?」といった、Viewのための複雑な条件分岐判定を行いたいとき

※Decoratorはgemを入れるやり方もありますが、個人的にはmoduleとして切り出して、Modelでincludeするほうが複数のModelで共通利用できてよいのでは?と考えています。gemを増やしたくもないし…

Bad Example: Viewにロジックが漏れ出している

【問題点】

  • 「学費情報が一つでもあれば、学費セクションを表示したい」という要件をViewに直接書くと可読性が低くなる
/ app/views/schools/show.html.slim

/ ❌ 条件が長すぎて、何を判定しているのかパッと分からない
- if @school.admission_fee.present? || @school.tuition_fee.present? || @school.materials_fee.present? || @school.other_fees.present?
  section#fees
    h2 学費情報
    / ...

/ ❌ 文字列の結合もViewでやると汚くなる
p = @school.prefecture.name + @school.city.name + @school.street_address

Example: 判定ロジックをDecoratorにカプセル化する

【ポイント】

  • 表示ロジックをモジュールとして切り出している
  • Viewは「学費情報ある?」と聞くだけで済む

▼Decorator

# app/decorators/school_common_decorator.rb
module SchoolCommonDecorator
  # 住所を連結して表示
  def display_address
    "#{school_prefecture.name}#{school_city.name}#{street_address}"
  end

  # 学費に関する情報が1つでも入力されているか判定
  def any_fee_info_present?
      admission_fee.present? || tuition_fee.present? || materials_fee.present? || 
      facility_fee.present? || other_fees.present? || total_fee.present? || 
      fee_notes.present? || school_fees.present?
  end

  # アクセス情報があるか
  def any_access_info_present?
    school_prefecture.present? || school_city.present? || 
    street_address.present? || access_info.present?
  end

  # ... 他の判定メソッドも同様 ...
end

▼View

/ app/views/schools/show.html.slim

/ ✅ メソッド名で意図が明確になり、コードもスッキリ!
- if @school.any_fee_info_present?
  section#fees
    h2 学費情報
    / ...

p = @school.display_address

Partial (部分テンプレート)

【使いどころ】

  • 複数の画面で使う「共通のUIパーツ」
  • ループ処理の中身や、条件分岐が複雑になりすぎて「可読性が落ちているView」を整理したいとき
  • 似たようなViewだけど、権限ごとに微妙に表示する情報を変えたい、みたいなときにも有効

Example: 巨大なテーブルを行単位でPartialに切り出す

【ポイント】

  • 複雑なテーブル表示は、メインのViewに「ヘッダー定義」と「呼び出し処理」だけを残す
  • 1行ごとの複雑な描画ロジックはPartial(_project_row.html.slim)に隔離する
  • 権限情報をlocalsとして渡すことで、権限ごとの出し分けも含めて1ファイルに収められる

▼呼出し側のView

.row
  .col-12
    .overflow-scroll
      .data-table.w-100
        / テーブルヘッダー
        .data-table-row.header
          - if current_user.admin?
            .th.mw-240px クライアント名
          .th.mw-240px 案件名
          .th.mw-180px 担当者
          .th.mw-180px 予算
          .th.mw-420px 備考
          .th.mw-150px ステータス
          .th.mw-150px 契約日
          - if current_user.admin?
            .th.mw-120px 原価

        / テーブルボディ(コレクションレンダリングでPartialを呼び出し)
        / ※ @projectsの要素1つずつに対して _project_row.html.slim が描画されます
        = render partial: 'project_row', collection: @projects, as: :project, locals: { role: current_user.role }

▼行のView

.data-table-row
  / クライアント名(管理者のみ表示)
  - if role == :admin
    .td
      span.value = project.client&.name

  / 案件名
  .td
    span.text-secondary.fs-6 = project.code
    - if role.in?([:admin, :manager])
      = link_to project.name, edit_project_path(project), data: { turbolinks: false }
    - else
      p.value = project.name

  / 担当者
  .td
    span.value = project.manager_name

  / 予算
  .td
    span.value = number_to_currency(project.budget)

  / 備考(管理者・マネージャーは編集可能、一般社員は閲覧のみ)
  .td
    - if role.in?([:admin, :manager])
      .d-flex.align-items-center.edit-area
        = text_area_tag "project_memo_#{project.id}", project.memo, { class: 'form-control editable-input', data: { id: project.id, field: 'memo' } }
        i.fas.fa-pen.ms-2.edit-icon
    - else
      span.value = project.memo

  / ステータス(セレクトボックス切り替え)
  .td
    - if role == :admin
      = select_tag "status_#{project.id}", options_for_select(Project.statuses, project.status), { class: 'status-select', data: { id: project.id } }
    - else
      span.badge.bg-primary = project.status_i18n

  / 契約日
  .td
    span.value = project.contract_date.try(:strftime, "%Y年%m月%d日") || "-"

  / 原価(管理者のみ)
  - if role == :admin
    .td
      span.value = number_to_currency(project.cost)

おわりに

振り返ると、自身の判断フローは以下のようになっています。

  • 基本: LogicはModelへ、Controllerは薄く。
  • View系:
    • モデル単位の装飾 → Decorator
      -汎用的なフォーマット → Helper
      -UIパーツの共通化 → Partial
  • 共通系:
    • scopeや定義の共有ならConcern
    • 値の検証ならValidator
  • 複雑系:
    • 複数モデル更新ならForm Object
    • 外部連携や操作主体ならService

「動けばいい」から一歩進んで、「適切な場所にコードを置く」ことを意識するだけで、バグ修正や仕様変更にかかる時間が圧倒的に短くなりました。
とはいえ、DDDなどの設計やデザインパターンについてはまだまだ勉強不足なので、今後も学習を継続していきます。

参考

すべて、何度も拝読した神記事です!

全体

Form Object

Serviceクラス

Concern

Validator

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?