Railsのクラス設計
前提
依存関係
各クラスの概要
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に依存しないようにする
クラス設計で許容していること
- 依存関係が下のレイヤーから上のレイヤーに行くことがあっても良いことにする
- 解決したい問題が解決できていれば良しとする
- 完璧を目指さない
参考
- Upgrow: Railsアプリの保守性を高めるためのShopifyのアプローチ
- https://speakerdeck.com/daikimiura/upgrow
- ModelをModelとRecordに分けると良さそうというアイデアを得た
- Layered Design for Ruby on Rails Applications
- https://www.packtpub.com/en-us/product/9781801813785
- レイヤードアーキテクチャの理解が深まった