Railsのクラス設計


前提

  • shimbacoの個人開発で採用しているオレオレクラス設計です
  • 基本的にSorbetで型付けしています
    • Controllerやテストなど、型付けにあまりうまみを感じない箇所は適当にやっています

依存関係

各クラスの概要

Controller

  • 1 Controllerにつき1アクションを定義する
    • コールバックやプライベートメソッドがどのアクションから呼ばれるかを明確にするため
  • 参照系コントローラーの基本的な流れ
    • Recordを取得
    • RepositoryでModelに変換
    • ModelをViewに渡してレンダリング
  • 更新系コントローラーの基本的な流れ
    • Formで業務エラーをチェック
    • Serviceを実行
    • Viewをレンダリング
  • 更新系コントローラーでは業務エラーのハンドリングを2種類の方法で行う
    • Formによる検証
    • Serviceで発生する例外の補足
      • 例えば ActiveRecord::RecordNotUnique は永続化処理を行うときに発生するため、Formでは補足できない
      • ActiveRecord::RecordNotUnique は業務エラーとして処理すべきエラーなので、Serviceで発生する業務エラーに関係する例外は補足し、業務エラーとしてFormに吸収する

コード例

参照系:

# typed: strict
# frozen_string_literal: true

module Pages
  class ShowController < ApplicationController
    include ControllerConcerns::Authenticatable

    before_action :restore_user_session

    sig { returns(T.untyped) }
    def call
      page_record = PageRecord.find(params[:page_id])
      page = PageRepository.new.to_model(page_record:)

      render Pages::ShowView.new(page:)
    end
  end
end

更新系:

# typed: true
# frozen_string_literal: true

module Pages
  class UpdateController < ApplicationController
    sig { returns(T.untyped) }
    def call
      page_record = PageRecord.find(params[:page_id])
      form = Pages::EditForm.new(form_params)

      if form.invalid?
        return render_edit_view(page_record:, form:)
      end

      Pages::UpdateService.new.call(
        page_record:,
        title: form.title,
        body: form.body
      )

      redirect_to page_path(page_record)
    rescue ApplicationService::RecordNotUniqueError => e
      form.not_nil!.errors.add(e.attribute, e.message)
      render_edit_view(page_record:, form:)
    end

    private def render_edit_view(page_record:, form:)
      page = PageRepository.new.to_model(page_record:)
      render(
        Pages::EditView.new(page:, form:),
        status: :unprocessable_entity
      )
    end
  end
end

View

  • ViewComponentを使用する
    • Viewの中でどんなデータを扱うのかを明確にするため
  • ViewはRecordには依存しない
    • Viewからデータベースにアクセスするのを防ぐため
  • 命名規則
    • クラス名は (モデル名の複数形)::(アクション名)View とする

コード例

クラス:

# typed: strict
# frozen_string_literal: true

module Pages
  class ShowView < ApplicationView
    sig { params(page: Page).void }
    def initialize(page:)
      @page = page
    end

    sig { returns(Page) }
    attr_reader :page
    private :page
  end
end

テンプレート:

<h1>
  <%= @page.title %>
</h1>

<%= @page.body %>

Component

  • Viewと同じく
    • ViewComponentを使用する
    • Recordには依存しない
  • 命名規則
    • クラス名は (UIの部品名の複数形)::(名詞)Component とする
  • 基本的に定義するインスタンスメソッドはプライベートにする

コード例

# typed: strict
# frozen_string_literal: true

module Icons
  class TopicComponent < ApplicationComponent
    sig { params(topic: Topic, size: String, class_name: String).void }
    def initialize(topic:, size: "16px", class_name: "")
      @topic = topic
      @size = size
      @class_name = class_name
    end

    sig { returns(::Topic) }
    attr_reader :topic
    private :topic

    sig { returns(String) }
    attr_reader :size
    private :size

    sig { returns(String) }
    attr_reader :class_name
    private :class_name

    sig { returns(String) }
    private def icon_name
      topic.visibility_public? ? "globe" : "lock"
    end
  end
end

Form

  • 業務エラーのチェックを行う
  • 共通で使うバリデーションはモジュールに定義する
    • なるべくロジックが散らばらないようにするため
  • 命名規則
    • クラス名は (モデル名の複数形)::(名詞)Form とする
      • モデルごとにどのようなフォームがあるか一覧しやすくするため

コード例

# typed: strict
# frozen_string_literal: true

module UserSessions
  class CreationForm < ApplicationForm
    attribute :email, :string
    attribute :password, :string

    validates :email, email: true, presence: true
    validates :password, presence: true
    validate :authentication

    sig { void }
    private def authentication
      user_record = UserRecord.find_by(email:)

      unless user_record&.user_password_record&.authenticate(password)
        errors.add(:base, :unauthenticated)
      end
    end
  end
end

Validator

  • カスタムバリデータを定義する

コード例

# typed: strict
# frozen_string_literal: true

class UrlValidator < ActiveModel::EachValidator
  extend T::Sig

  sig { params(record: ApplicationForm, attribute: Symbol, value: String).void }
  def validate_each(record, attribute, value)
    return if value.blank?

    unless Url.new(value).valid?
      record.errors.add(attribute, :url)
    end
  end
end

Job

  • ロジックはJobの中には書かない
    • テストを書きやすくするため
    • 基本的にServiceを呼ぶだけとする

コード例

# typed: strict
# frozen_string_literal: true

class GenerateExportFilesJob < ApplicationJob
  queue_as :default

  sig { params(export_record_id: T::DatabaseId).void }
  def perform(export_record_id:)
    export_record = ExportRecord.find(export_record_id)

    Spaces::GenerateExportFilesService.new.call(export_record:)

    nil
  end
end

Service

  • データの永続化を行う
  • トランザクションはこの中で開始する
  • ビジネスロジックは書かない
    • Model, Recordに定義したメソッドを呼び出すだけにする
    • 永続化されるまでの処理の流れを追いやすくするため
  • 命名規則
    • クラス名は (モデル名の複数形)::(動詞)Service とする
      • モデルごとにどのような操作ができるか一覧しやすくするため
  • 業務エラーの扱い
    • ActiveRecord::RecordNotUnique など、業務エラーとして扱いたい例外がトランザクション内で発生することがある
    • ApplicationService でそれら例外を補足・Controllerなどで扱いやすい形に変換する処理を定義する

コード例

ApplicationService:

# typed: strict
# frozen_string_literal: true

class ApplicationService
  extend T::Sig

  class RecordNotUniqueError < StandardError
    extend T::Sig

    sig { returns(Symbol) }
    attr_reader :attribute

    sig { params(message: String, attribute: Symbol).void }
    def initialize(message:, attribute: :base)
      super(message)
      @attribute = attribute
    end
  end

  sig do
    type_parameters(:T)
      .params(block: T.proc.returns(T.type_parameter(:T)))
      .returns(T.type_parameter(:T))
  end
  private def with_transaction(&block)
    ApplicationRecord.transaction(&block)
  rescue ActiveRecord::RecordNotUnique => e
    message = I18n.t("services.errors.messages.uniqueness")

    # PostgreSQLの場合のエラーメッセージ例:
    # "PG::UniqueViolation: ERROR: duplicate key value violates unique constraint "index_pages_on_slug""
    case e.message
    when /index_spaces_on_identifier/
      raise RecordNotUniqueError.new(message:, attribute: :identifier)
    else
      # 予期しない一意性制約違反はシステムエラーとして扱う
      raise
    end
  end
end

ApplicationServiceを継承したクラス:

# typed: strict
# frozen_string_literal: true

module Accounts
  class CreateService < ApplicationService
    class Result < T::Struct
      const :user_record, UserRecord
    end

    sig { params(email: String, password: String).returns(Result) }
    def call(email:, password:)
      user_record = with_transaction do
        ur = UserRecord.create!(email:)
        ur.create_password_record!(password:)
        ur
      end

      WelcomeMailer.welcome(user_record_id: user_record.id)
        .deliver_later

      Result.new(user_record:)
    end
  end
end

Mailer

  • Action Mailerを普通に使う

コード例

# typed: true
# frozen_string_literal: true

class ExportMailer < ApplicationMailer
  sig { params(export_id: T::DatabaseId, locale: String).void }
  def succeeded(export_id:, locale:)
    @export = ExportRecord.find(export_id)
    @space = @export.space_record

    I18n.with_locale(locale) do
      mail(
        to: @export.queued_by_record.not_nil!.user_record.not_nil!.email,
        subject: default_i18n_subject
      )
    end
  end
end

Policy

  • 権限の管理を行うクラス
  • POROで定義する

コード例

# typed: strict
# frozen_string_literal: true

class SpaceMemberPolicy < ApplicationPolicy
  sig do
    params(
      user_record: T.nilable(UserRecord),
      space_member_record: T.nilable(SpaceMemberRecord)
    ).void
  end
  def initialize(user_record: nil, space_member_record: nil)
    @user_record = user_record
    @space_member_record = space_member_record
  end

  sig { returns(T::Boolean) }
  def joined_space?
    !space_member_record.nil?
  end

  sig { params(space_record: SpaceRecord).returns(T::Boolean) }
  def can_update_space?(space_record:)
    # ...
  end

  sig { returns(T::Boolean) }
  def can_create_topic?
    # ...
  end

  sig { returns(T.nilable(UserRecord)) }
  attr_reader :user_record
  private :user_record

  sig { returns(T.nilable(SpaceMemberRecord)) }
  attr_reader :space_member_record
  private :space_member_record
end

Model

  • 構造体やPOROだけが格納されている
    • Modelからデータベースにアクセスすることはない
  • belongs_to にあたる参照は nilable とする
    • 不必要にデータベースにクエリが飛ばないようにするため
    • テストを書くとき不必要なモデルの定義を省略できるようにするため

コード例

# typed: strict
# frozen_string_literal: true

class Page < T::Struct
  const :database_id, T::DatabaseId
  const :title, String
  # ... 他のプロパティ
end

Repository

  • RecordをModelに変換する中間層となるクラス
  • ModelがRecordに依存しないようにするため、中間層が必要だった
    • Model.from_record(record) といったメソッドを定義するとModelがRecordに依存してしまうため、そうはしない
  • 命名規則
    • クラス名は (モデル名)Repository とする
    • モデル1つにつき1つのRepositoryを定義する

コード例

# typed: strict
# frozen_string_literal: true

class PageRepository < ApplicationRepository
  sig { params(page_record: PageRecord).returns(Page) }
  def to_model(page_record:)
    Page.new(
      database_id: page_record.id,
      title: page_record.title,
      # ... 他のプロパティをマッピング
    )
  end
end

Record

  • データベースにアクセスするクラス
  • DBのテーブル1つにつき1つのRecordを定義する

コード例

class PageRecord < ApplicationRecord
  self.table_name = "pages"

  belongs_to :topic_record, foreign_key: :topic_id
  has_many :revision_records,
    class_name: "PageRevisionRecord",
    foreign_key: :page_id

  scope :published, -> { where.not(published_at: nil) }

  sig { returns(T::Boolean) }
  def published?
    published_at.present?
  end
end

解決したい問題

トランザクションの入れ子

  • 入れ子になったトランザクションはロールバックするときの挙動の理解が難しくなる

解決方法

  • トランザクションはServiceでのみ張る
  • ServiceからServiceを参照しないようにする

Recordのコールバックを使用することによる可読性やテスタビリティの低下

  • 特に副作用があったりオブジェクトを破壊的に変更するようなコールバックで問題が起きやすい

解決方法

  • Service内でのトランザクションの前後で処理を呼び出す

ViewやComponentからデータベースにアクセスしてしまう

  • N+1の解決をするとき影響範囲が広くなりがちになる

解決方法

  • ViewやComponentがRecordに依存しないようにする

クラス設計で許容していること

  • 依存関係が下のレイヤーから上のレイヤーに行くことがあっても良いことにする
    • 解決したい問題が解決できていれば良しとする
    • 完璧を目指さない

参考

Rails

ホーム