こんにちは、YOUTRUSTのやまでぃです。(YOUTRUST)
前回の記事より約8ヶ月振りの登場です。
今回は YOUTRUST 流の Rails クラス設計〜2026年最新版〜についてご紹介します。(以前の記事)
レポジトリ構成について
まず最初に YOUTRUST が提供する各サービスのレポジトリについて説明します。
YOUTRUST では社内外に向けて複数のサービス(キャリアSNS、社内用管理画面、クライアント向け管理画面など)を開発・運用しており、それらのサービスの Web フロントエンドとバックエンドは一つのレポジトリ内で次のように管理されています。
- app(Rails)
- キャリアSNSやクライアント向け管理画面などのバックエンド
- backend_admin/app(Rails)
- 社内用管理画面のバックエンド
- shared_app/app(Rails Engine)
- サービス間で共通のクラス(Model、Command、Queryなど)
- frontend(React)
- キャリアSNSのフロントエンド
- frontend_admin(React)
- 社内用管理画面のフロントエンド
- frontend_client_web(React)
- ...
※ スマホ向け App フロントエンド(Flutter)は別レポジトリで管理しています。バックエンドは Web と共通です。
一つの Rails プロジェクトで複数サービスを管理しているので、サービス毎に必要なクラス(ControllerやUseCaseなど)については、名前空間を付けることで設置ディレクトリを分けるようにしています。(例. Sns::HogeController、Admin::HogeController、ClientWeb::HogeControllerなど)
※ 将来的には backend_admin のように、他サービスも backend_sns や backend_client_web に Rails 分割していく予定です。( cf. backend_admin の分割記事)
Rails クラス設計
YOUTRUSTでは次のようなクラス分類を行っています。
- Model
- Command(データ更新ロジック)
- UseCase(各サービス毎のビジネスロジック)
- Query(データ参照ロジック)
- Controller(更新)
- Controller(取得:条件にマッチするID取得)
- Controller(取得:ID指定によるリソース取得)
- Notification(制御、詳細)
- Job, JobLogic
- Task, TaskLogic
順に説明していきます。
Model
Model は薄くしています。
データ取得 or データ更新ロジックは Query や Command に記述するようにしています。
class Post < ApplicationRecord # リレーションや has_many :post_comments has_many :post_likes # enum 定義などの最低限の使い方をしています。 enum :scope, { scope_private: 0, scope_public: 1, } # バリデーションは基本的に行わない(Command に記述) # validate :validate_title_length # コールバックも使わない(Commandに記述) # after_create :create_some_other_resource # 条件判定や便利系のメソッドは定義して使います。 def accessible_by?(target_user:) ... end def liked_by?(target_user:) post_likes.exists(user: target_user) end end
Command
Command にはデータ更新ロジックを記述します。
例えば「つながりリクエスト」リソースに対する更新ロジックは「送信(作成)」「承認」「拒否」「取り消し(削除)」などがありますが、これらのバリデーションはすべて異なるので、Model だと条件付きバリデーションが多用されてしまい、理解が難しくなってしまいます。
なので Model にすべての更新パターンのロジックを記述するのではなく、その更新パターン毎に単一責務のCommandを用意して管理するようにしています。
また、Command には各サービスに依存しない普遍的な更新ロジックを記述するようにして、各サービスのUseCaseから共通で使用するようにしています。(UseCaseはサービス依存だが、Commandはリソース依存)
# データ更新ロジック:つながりリクエストの承認 class AcceptFriendRequestCommand include Command::Base # 共通モジュール # バリデーションは run 前に実行される validate :validate_friend_request def initialize(friend_request:) @friend_request = friend_request @from_user = friend_request.from_user @to_user = friend_request.to_user end def run # つながりリクエストのステータス更新 @friend_request.status_accepted! # つながり成立 become_friends! # つながり候補削除 delete_friend_candidates end private def become_friends! @from_user.user_friends.create!(friend_user: @to_user) @to_user.user_friends.create!(friend_user: @from_user) end def delete_friend_candidates @from_user.friend_candidates.where(candidate_user: @to_user).delete_all @to_user.friend_candidates.where(candidate_user: @from_user).delete_all end # Commandにはサービスに依存しない普遍的なバリデーションを記述する。 def validate_friend_request if !@friend_request.status_pending? errors.add(:friend_request, :invalid) end end end
module Command::Base extend ActiveSupport::Concern include ActiveModel::Model module ClassMethods def run(**args) new(**args).tap { |command| command.valid? && command.run } end end def run raise NotImplementedError end def success? errors.none? end end
UseCase
UseCase にはサービス毎のビジネスロジックを記載します。
具体的には次のような処理を記載します。
- DBトランザクション管理
- 権限/認可のバリデーション
- Command 呼び出し
- 通知処理(NotificationJob)
class AcceptFriendRequestUseCase include UseCase::Base validate :validate_operation_user def initialize(operation_user:, friend_request:) @operation_user = operation_user @friend_request = friend_request end def run # DBトランザクション User.transaction do # 排他制御のためのロック lock_users with_confirm_success! do AcceptFriendRequestCommand.run(friend_request: @friend_request) end end end def on_success enqueue_notification_job end private # UseCaseでは権限/認可のバリデーションを行う。 def validate_operation_user if @operation_user != @friend_request.to_user errors.add(:operation_user, :forbidden) end end def lock_users # ソート済みユーザーIDの順にロックを取得 User.lock_users(@friend_request.from_user, @friend_request.to_user) end def enqueue_notification_job NotificationJob.perform_later( 'accept_friend_request', operation_user: @operation_user, friend_request: @friend_request, ) end end
module UseCase::Base extend ActiveSupport::Concern include ActiveModel::Model module ClassMethods def run(**args) new(**args).tap do |use_case| use_case.valid? && use_case.run if use_case.success? use_case.on_success else use_case.on_failure end end end end def on_success end def on_failure end def success? errors.none? end def valid_or_rollback! valid? || raise_rollback end def raise_rollback raise ActiveRecord::Rollback end def with_confirm_success!(error_key: :base, &command_block) command = command_block.call if !command.success? errors.add(error_key.to_sym, :invalid) raise_rollback end command end end
Query
Query にはデータ取得系のロジックを記載します。
Query は母集団取得/フィルター/ソートなどに分類することができ、なるべくこれらを一つのクラス内に混在させず、別クラスに分けて呼び出す形にしています。(Query が 他のQueryを呼び出して良い)
- 母集団取得(例. [] → [A, B, C]):(Fetch)XxxQuery
- フィルター(例. [A, B, C] → [A, C]):FilterXxxQuery
- ソート(例. [A, C] → [C, A]):SortXxxQuery
class SearchUserIdsQuery include QueryRunnable def initialize(operation_user:, sort_type: nil, keyword: nil, limit: 10) @operation_user = @operation_user @sort_type = sort_type @keyword = keyword @limit = limit end def run # 母集団取得 user_ids = fetch_population_user_ids # ソート user_ids = sort_user_ids(user_ids) # フィルター user_ids = filter_user_ids(user_ids) user_ids end private def fetch_population_user_ids @operation_user.friend_user_ids + @opration_user.friend_of_friend_user_ids end def sort_user_ids(user_ids) # 他のQueryを呼び出しても良い SortUserIdsQuery.run(user_ids: user_ids, sort_type: @sort_type) end def filter_user_ids(user_ids) FilterUserIdsQuery.run(user_ids: user_ids, keyword: @keyword, limit: @limit) end end
Controller
Controller は HTTP の世界とアプリケーションの世界の間に立つクラスです。
YOUTRUSTでは Controller を次の3種類に分類しています。
- 更新系
- UseCaseを呼び出してデータ更新を行います。
- 取得系(IDリスト)
- Query を使用してIDリストを返します。
- 同じリソースに対して、利用シーン毎に複数パターンのControllerが存在します。
- 取得系(リソース)
- 指定されたIDのリソースを返します。
- 同じリソースに対して、一つのみ存在します。
# 更新系 Controller の例 class Api::FriendRequest::AcceptController < Api::ApplicationController before_action :authenticate_user! # @route /api/friend_requests/:friend_request_id/accept def create use_case = AcceptFriendRequestUseCase.run( operation_user: current_user, friend_request: friend_request, ) if use_case.success? @friend_request = use_case.friend_request else head :bad_request end end end
# IDリスト取得系 Controller の例(利用シーン毎に複数パターンあり) # パターン1: つながり User のIDリスト取得API class Api::UserFriendIdsController < Api::ApplicationController before_action :authenticate_user! # @route GET /api/user_friend_ids def index user_ids = SearchUserIdsQuery.run( operation_user: current_user, sort_type: params[:sort_type], keyword: params[:keyword], limit: params[:limit], ) # APIのリソースIDはすべて暗号化している。 @user_ids = user_ids.map { |user_id| User.encrypt_id(user_id) } end end # パターン2: メンション可能な User 候補のIDリスト取得API class Api::ChatRoom::UserMentionCandidateIdsController < Api::ApplicationController before_action authenticate_user! # @route GET /api/chat_rooms/:chat_room_id/user_mention_candidate_ids def index chat_room = fetch_chat_room user_ids = UserMentionCandidateIdsQuery.run( operation_user: current_user, chat_room: chat_room, ) @user_ids = user_ids.map { |user_id| User.encrypt_id(user_id) } end private def fetch_chat_room # パラメーターで渡される各種リソースIDは暗号化されているので復号して使う。 current_user.chat_rooms.find_by_encrypted_id(params[:chat_room_id]) end end
# リソース取得系 Controller の例(リソース毎に一つ) class Api::UsersController < Api::ApplicationController before_action :authenticate_user! # @route GET /api/users/:user_ids def show @users = User .where(id: param_user_ids) .preload(*preload_args) .sort_by { |user| param_user_ids.index(user.id) } # 指定順にソート end private def param_user_ids # 'xxx,yyy,zzz' のようにカンマ区切りで指定されている。 params[:user_ids].split(',').map { |enc_id| User.decrypt_id(enc_id) } end def preload_args [:user_skills, :user_friends, :user_companies] end end
Notification
YOUTRUSTでは通知クラスを2種類に分けています。
- 制御クラス
- 母集団取得、送信判定、重複送信防止、詳細クラスの呼び出し。
- 詳細クラス
- 各通知チャネル(Push、Slack、メールなど)固有の送信ロジック。
また、通知クラスの処理時間は長くなる場合があるので、すべての制御クラスは NotificationJob によって非同期実行されます。(主に UseCase クラスが NotificationJob を呼び出します)
class NotificationJob < ApplicationJob # 制御クラスの対応マップ KLASS_BY_TYPE = { accept_friend_request: Notification::AcceptFriendRequest, like_post: Notification::LikePost, ... }.freeze def perform(type, **args) KLASS_BY_TYPE.fetch(type.to_sym) .with_resend_control(job_id) # 重複送信防止のための準備 .run(**args) end end
class Notification::Base attr_accessor :resend_control_key def self.with_resend_control(key) ResendControlNotification.new(self, key) end def self.run(**args) new(**args).tap(&:run) end def run raise NotImplementedError end # 制御クラス内の各詳細クラス実行時に呼び出し、Jobリトライ時の重複送信を防ぐ。 def with_sendable_check(type:, target_key: nil, tag: nil, &block) if resend_control_key.blank? return block.call end params = { key: resend_control_key, # 基本的に Job ID が入る。 notification_type: type, # 通知種類(Push、Slack、メールなど) target_key: target_key, # 通知対象リソースを一意に表すキー(ユーザーID等) tag: tag.presence || self.class.name } if NotificationHistory.exists?(params) return end block.call NotificationHistory.create(params) end class ResendControlNotification def initialize(klass, key) @klass = klass @key = key end def run(**args) @klass.new(**args).tap do |instance| instance.resend_control_key = @Key instance.run end end end end
# 制御クラスの例:チャットルームのメッセージ通知 class Notification::ChatRoomNewMessage < Notification::Base def initialize(chat_room:, chat_room_comment:) @chat_room = chat_room @chat_room_comment = chat_room_comment end def run # 母集団取得 to_users.each do |to_user| # 送信判定 if !to_user.disabled_slack_notifications?(:chat_room_new_message) send_slack_notification(to_user: to_user) end send_web_socket_notification end end private def to_users @chat_room.users.filter { |user| user != @chat_room_comment.user } end def send_slack_notification(to_user:) # リトライ時の再送防止 with_sendable_check(type: 'slack', target_key: to_user.id.to_s) do # 詳細クラスの呼び出し Notification::SlackNotifications::ChatRoomNewMessage.run( to_user: to_user, chat_room_comment: @chat_room_comment, ) end end def send_web_socket_notification(to_user:) with_sendable_check(type: 'web_socket', target_key: to_user.id.to_s) do Notification::WebSocketNotifications::ChatRoomList.run( to_user: to_user, ) end end end
# 詳細クラスの例:Slack通知 class Notification::SlackNotifications::ChatRoomNewMessage < Notification::Base def initialize(to_user:, chat_room_comment:) @to_user = to_user @chat_room_comment = chat_room_comment end def run SlackNotificationClient.run( webhook_url: @to_user.slack_webhook_integration.webhook_url, channel: @to_user.slack_webhook_integration.channel, blocks: build_blocks, ) end private def build_blocks [ { type: 'header', text: { ... } }, { type: 'section', text: { ... } }, ... ] end end # 詳細クラスの例:WebSocket通知 class Notification::WebSocketNotifications::ChatRoomList < Notification::Base def initialize(to_user:) @to_user end def run ActionCable.server.broadcast(stream_name, payload) end private def stream_name 'dummy_stream_name' end def payload { type: 'RELOAD_CHAT_ROOMS' } end end
Job
Job クラスは非同期処理開始のインターフェースとして薄く利用します。基本的には対応する JobLogic を呼び出すのみです。
JogLogic も他クラスと同様に run クラスメソッドを持つクラスで、UseCase や Query などを使用して任意のロジックを記述します。
class SomeExampleJob < ApplicationJob def perform(a:, b:) SomeExampleJobLogic.run(a: a, b: b) # 時には特定の例外を捕捉してリトライを防いだりもする。 rescue SomeExampleJobLogic::InvalidStatus => e logger.error(e.message) end end
class SomeExampleJobLogic include JobRunnable def initialize(a:, b:) @a = a @b = b end def run fetch_target_users.each do |target_user| SomeExampleUseCase.run(target_user: target_user) end end private def fetch_target_users SomeExampleQuery.run(a:, b:) end end
Task
スケジュールタスクや単発実行タスクの Rake ファイルでも、基本的には TaskLogic を呼び出すのみです。
TaskLogic も JobLogic と同様、run クラスメソッドを持ち UseCase や Query などを使用して任意のロジックを記述します。(例は省略)
namespace :scheduler do namespace :user do desc 'おすすめのつながり候補を作成する' task suggest_friend_candidates: :environment do |t| User::SuggestFriendCandidatesTaskLogic.run end end end
最後に
本記事では YOUTRUST 流の Rails クラス設計についてご紹介しました。
Claude Code や Cursor などの AI エージェントの登場により、改めて設計や言語化や例示が(今までもこれからも)大事であると感じているので、今回改めてまとめ直してみました。
良質な設計による良質なコードベースが、そのままAIエージェントにとっての良質なプロンプトとなって異次元の生産性を生み出してくれると思うので、引き続き(特にフロントエンド)設計のブラッシュアップとリファクタリングを進めて行きます。
みなさんのプロダクトの設計に本記事の内容が何か参考になればうれしいです。
以上!
▼エンジニア積極採用中です!
YOUTRUSTで一緒にプロダクト開発しませんか?
興味がある方は是非チェックよろしくお願いします!(AIコーディングエージェントの利用費補助もあります!)