第25章|今さら学ぶ「設計・リファクタリング」
📚 シリーズ目次はこちら → 「今さら学ぶ」シリーズ — はじめに
🗺️ KnowledgeNoteの設計を確認 → 設計マップ
この章でわかること
- Fat Controller / Fat Model — 一人に仕事を押し付けない
- デザインパターン(Service / Form / Query / Decorator)— 仕事を分担する専門チーム
- Concern — 複数モデル/コントローラで共通の処理をモジュールにまとめる
- 命名の重要性 — コードは「未来の自分への手紙」
- リファクタリングの進め方 — テストがあるから安心して変えられる
🏠 たとえ話で掴む「設計」
Railsの設計問題は 会社の組織 にたとえるとわかりやすいです。
スタートアップの初期は、社長が営業・経理・人事・開発を全部やっています。でも会社が大きくなると、1人で全部やるのは無理。 部署を作って仕事を分担 しないと回りません。
Railsアプリも同じです。最初はコントローラに全てのロジックを詰め込んでも動きます。でもアプリが大きくなると、 責務を分割しないとコードが読めなくなります。
| 会社の問題 | Railsの問題 |
|---|---|
| 社長が全部やる(ワンオペ) | Fat Controller |
| 経理部長に全部任せる | Fat Model |
| 部署を作って分担する | Service / Form / Query 等 |
| マニュアルを共有する | Concern(共通処理) |
| 名刺の肩書きが的確 | 命名が適切 |
🏗️ 設計とは何か — 技術的な定義
ソフトウェア設計とは、 コードの責務をどう分割し、どう組み合わせるかを決めること です。
MVCフレームワークであるRailsは、すでに「Model・View・Controller」という責務分割を提供しています(→ 第9章)。しかしアプリが成長すると、MVC の3つだけでは責務が収まらなくなります。
たとえば「記事を公開する」という処理には、記事の保存、タグの紐付け、フォロワーへの通知、メール送信が含まれます。これをすべてコントローラに書くと Fat Controller になり、モデルに書くと Fat Model になります。
設計の原則として最も重要なのは 単一責任の原則(SRP: Single Responsibility Principle) です。「1つのクラスは、1つの理由でしか変更されるべきでない」という考え方で、これを意識すると自然に責務の分割が進みます。
Railsで使われる主な責務分割パターンは以下のとおりです。
| パターン | 責務 | MVCだけだと何に入るか |
|---|---|---|
| Service Object | 業務ロジック | Controller or Model |
| Form Object | 複数モデルにまたがるフォーム | Controller |
| Query Object | 複雑な検索条件 | Model(scope) |
| Decorator | 表示用のロジック | View helper |
| Concern | 複数モデル共通の振る舞い | 各Modelに重複コピー |
🏋️ Fat Controller — コントローラの肥大化
# ❌ Fat Controller — コントローラにビジネスロジックを詰め込んでいる
class ArticlesController < ApplicationController
def create
@article = current_user.articles.build(article_params)
@article.status = :published if params[:publish]
# タグの処理(ここにあるべきじゃない)
tag_names = params[:tag_names].split(",").map(&:strip)
tag_names.each do |name|
tag = Tag.find_or_create_by(name: name)
@article.tags << tag unless @article.tags.include?(tag)
end
if @article.save
# 通知の処理(ここにあるべきじゃない)
current_user.followers.each do |follower|
Notification.create(user: follower, notifiable: @article, action_type: "published")
end
# メール送信(ここにあるべきじゃない)
NotificationMailer.new_article(current_user, @article).deliver_later
redirect_to @article, notice: "記事を投稿しました"
else
render :new, status: :unprocessable_entity
end
end
end
このコントローラは「タグの処理」「通知の作成」「メール送信」までやっています。50行、100行と膨らんでいく典型的なFat Controllerです。
🛠️ Service Object — 専門チームに仕事を切り出す
Service Object は、特定の業務ロジックを1つのクラスに切り出すパターンです。
# app/services/article_publish_service.rb
class ArticlePublishService
def initialize(article:, tag_names: "", user:)
@article = article
@tag_names = tag_names
@user = user
end
def call
ActiveRecord::Base.transaction do
save_article!
attach_tags!
notify_followers! if @article.published?
end
true
rescue ActiveRecord::RecordInvalid
false
end
private
def save_article!
@article.save!
end
def attach_tags!
names = @tag_names.split(",").map(&:strip).reject(&:blank?)
names.each do |name|
tag = Tag.find_or_create_by!(name: name)
@article.tags << tag unless @article.tags.include?(tag)
end
end
def notify_followers!
@user.followers.find_each do |follower|
Notification.create!(user: follower, notifiable: @article, action_type: "published")
end
NotificationMailer.new_article(@user, @article).deliver_later
# deliver_laterによる非同期メール送信は(→ [第29章](https://qiita.com/harapeco-mgn/items/420a7d80595504b0444d)で詳しく扱います)
end
end
# ✅ スッキリしたコントローラ
class ArticlesController < ApplicationController
def create
@article = current_user.articles.build(article_params)
service = ArticlePublishService.new(
article: @article,
tag_names: params[:tag_names],
user: current_user
)
if service.call
redirect_to @article, notice: "記事を投稿しました"
else
render :new, status: :unprocessable_entity
end
end
end
コントローラは「窓口」に戻り、ビジネスロジックはServiceに移りました。
📋 その他のデザインパターン
Form Object — 複数モデルにまたがるフォーム
# app/forms/user_registration_form.rb
class UserRegistrationForm
include ActiveModel::Model
attr_accessor :name, :email_address, :password, :password_confirmation
validates :name, presence: true
validates :email_address, presence: true
validates :password, presence: true, confirmation: true
def save
return false unless valid?
user = User.create!(
name: name,
email_address: email_address,
password: password,
password_confirmation: password_confirmation
)
# デフォルトロールの付与
user.roles << Role.find_by(name: "member")
user
rescue ActiveRecord::RecordInvalid
false
end
end
Query Object — 複雑な検索条件をまとめる
# app/queries/article_search_query.rb
class ArticleSearchQuery
def initialize(scope = Article.all)
@scope = scope
end
def call(params)
@scope = filter_by_status(@scope, params[:status])
@scope = filter_by_tag(@scope, params[:tag])
@scope = filter_by_keyword(@scope, params[:keyword])
@scope = sort_by(@scope, params[:sort])
@scope
end
private
def filter_by_status(scope, status)
status.present? ? scope.where(status: status) : scope.published
end
def filter_by_tag(scope, tag_name)
tag_name.present? ? scope.joins(:tags).where(tags: { name: tag_name }) : scope
end
def filter_by_keyword(scope, keyword)
return scope unless keyword.present?
scope.where("title ILIKE ?", "%#{sanitize_sql_like(keyword)}%")
end
def sort_by(scope, sort)
case sort
when "oldest" then scope.order(created_at: :asc)
else scope.order(created_at: :desc)
end
end
def sanitize_sql_like(string)
# LIKE のワイルドカード文字をエスケープ
string.gsub(/[%_\\]/) { |m| "\\#{m}" }
end
end
💡 PostgreSQLでは
LIKEは大文字小文字を区別するため、ILIKE(大文字小文字を無視)を使うのが一般的です。また、ユーザー入力をLIKE句に渡す場合は%や_をエスケープしないと意図しない検索結果になります。
パターンの使い分け
| パターン | 使いどころ | ファイルの置き場 |
|---|---|---|
| Service | 「記事を公開する」等の業務ロジック | app/services/ |
| Form | 複数モデルにまたがるフォーム | app/forms/ |
| Query | 複雑な検索・フィルター条件 | app/queries/ |
| Decorator | 表示用のロジック(ヘルパーの代替) | app/decorators/ |
🔗 Concern — 共通処理をモジュールにまとめる
Concern は、複数のモデルやコントローラで共通の処理を モジュール として切り出す仕組みです。
# app/models/concerns/likeable.rb
module Likeable
extend ActiveSupport::Concern
included do
has_many :likes, as: :likeable, dependent: :destroy
end
def liked_by?(user)
likes.exists?(user: user)
end
def likes_count
likes.count
end
end
# app/models/article.rb
class Article < ApplicationRecord
include Likeable # ← これだけで liked_by? と likes_count が使える
end
# app/models/comment.rb
class Comment < ApplicationRecord
include Likeable # ← 同じ機能をコメントにも
end
Concern の使いすぎ注意
Concernは便利ですが、 「モデルの行数を減らすためだけに」使うと、処理の全体像が見えにくくなります。
✅ Concernが適切な場面
→ 複数モデルで共通の振る舞い(Likeable、Publishable 等)
❌ Concernが不適切な場面
→ 1つのモデルでしか使わない処理を「行数削減」のために切り出す
→ 切り出す先が増えすぎて、全体の処理を追えなくなる
📛 命名の重要性
コードは「未来の自分への手紙」です。半年後の自分が読んで理解できるかどうかは、命名にかかっています。
# ❌ 何をしているかわからない命名
def proc(d)
d.map { |x| x.v > 100 ? x : nil }.compact
end
# ✅ 読めば意味がわかる命名
def filter_high_value_articles(articles)
articles.select { |article| article.likes_count > 100 }
end
| 対象 | 良い命名 | 悪い命名 |
|---|---|---|
| メソッド | published_articles |
get_data |
| 変数 | current_user |
u |
| クラス | ArticlePublishService |
ArticleHelper2 |
| boolean |
published? / following?
|
flag / check
|
🔄 リファクタリングの進め方
リファクタリング とは、外部から見た振る舞い(機能)を変えずに、コードの内部構造を改善することです。
「振る舞いを変えない」ことを保証するのが テスト です(→ 第22章)。テストがない状態でリファクタリングすると、意図せず機能を壊すリスクがあります。
リファクタリングの手順
① テストを書く(まだなければ追加する)
↓
② テストが通ることを確認(Green)
↓
③ コードを小さく変更する(1ステップずつ)
↓
④ テストが通ることを確認(Green のまま)
↓
⑤ ③〜④を繰り返す
重要なのは 小さく変更する ことです。一気に大きく書き換えると、テストが失敗したときにどの変更が原因かわからなくなります。
具体例:Fat Controller → Service Object
# ① まず現在のFat Controllerに対するテストを書く(or 確認する)
RSpec.describe "POST /articles", type: :request do
it "記事が作成され、タグが紐付き、通知が送られる" do
user = create(:user)
follower = create(:user)
follower.follow(user)
sign_in(user)
expect {
post articles_path, params: {
article: { title: "テスト記事", body: "本文" },
tag_names: "Ruby, Rails"
}
}.to change(Article, :count).by(1)
.and change(Notification, :count).by(1)
article = Article.last
expect(article.tags.pluck(:name)).to contain_exactly("Ruby", "Rails")
end
end
# ② テストがGreenであることを確認
# ③ ArticlePublishService を作成し、コントローラから呼ぶ形に書き換え
# ④ テストがGreenのままであることを確認
# → 外から見た振る舞いは変わっていない。内部構造だけ改善された
💼 面接で聞かれたら?
Q:Fat Controllerとは何ですか?どう対処しますか?
「Fat Controllerとは、コントローラにビジネスロジックを詰め込みすぎた状態です。本来コントローラは「リクエストを受けてレスポンスを返す」という窓口業務に専念すべきですが、タグ処理・通知作成・メール送信などの処理まで書いてしまうと肥大化します。対処としては、Service Objectに業務ロジックを切り出し、コントローラはServiceを呼ぶだけにします。単一責任の原則に従い、1クラス1責務に整理することが重要です。」
深掘りされたら:
- 「Concernとは?」→ 複数モデルで共通する処理をモジュールとして切り出す仕組み。
extend ActiveSupport::Concernで定義し、includeで使う。ただし使いすぎると処理の全体像が見えにくくなる。- 「Service / Form / Query の使い分けは?」→ Serviceは業務ロジック、Formは複数モデルにまたがるフォーム、Queryは複雑な検索条件のカプセル化に使う。
- 「リファクタリングの進め方は?」→ まずテストを書き、テストがGreenであることを確認してから、小さく構造を変更する。テストが常にGreenのまま進めることで、機能を壊さずに内部構造を改善できる。
🔗 もっと深く知りたい人へ(1次情報リンク)
- Rails ガイド:Active Support のコア拡張機能(Concern) — Concernの公式解説
- Rails API:ActiveSupport::Concern — Concernのリファレンス
まとめ
- ✅ Fat Controllerは「社長のワンオペ」。ビジネスロジックをServiceに切り出す
- ✅ 設計の基本は単一責任の原則。1クラス1責務に整理する
- ✅ Service(業務ロジック)/ Form(複合フォーム)/ Query(検索条件)で責務を分離
- ✅ Concernは複数モデルの共通処理に使う。1箇所でしか使わない処理には不向き
- ✅ 命名は「未来の自分への手紙」。意図が伝わる名前をつける
- ✅ リファクタリングはテストがあって初めて安心してできる。小さく変更し、常にGreenを保つ
📚 シリーズ目次:「今さら学ぶ」シリーズ — はじめに