YOUTRUST流 Rails のクラス設計を公開

こんにちは、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 にはサービス毎のビジネスロジックを記載します。

具体的には次のような処理を記載します。

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コーディングエージェントの利用費補助もあります!)

herp.careers