はじめに
本記事では fit_patterns (githubリポジトリ) を元に、デザインパターンを学習していく。
ブランチ構成
- main
- あえてアンチパターンを実装
- feature/01~15
- 個別のデザインパターンでリファクタリング
- refactor-example
- デザインパターンによるリファクタリング完成版
プロジェクト(fit_patterns)概要
体重、ワークアウトを記録するフィットネス管理アプリ
下記のユースケースに沿ってデザインパターンを実装する事で、実践的なパターンを学習する。
(よくあるサンプルコードは抽象的すぎて「分かったが、結局どこで使うの?」とイメージが湧きにくい...)
[データ登録]
↓
[レポート生成]
↓
[通知]
↓
[保存]
↓
[監査ログ]
技術スタック
| 項目 | バージョン |
|---|---|
| Ruby | 3.2.7 |
| Rails | 8.0.x |
| DB | PostgreSQL 16 (Docker) |
| 認証 | Devise |
| UI コンポーネント | ViewComponent |
| テスト | RSpec / FactoryBot / shoulda-matchers |
対象読者
- Railsのコードを読める方
(Rails特有のクラス等の説明は対象外)
扱うパターン一覧
アンチパターン
問題を生みやすい悪い設計・実装のパターン
デザインパターン
再利用可能な良い設計パターン。
この記事シリーズでは、以下の15パターンを7本の記事で解説します。
| 記事 | 何を解決するか |
|---|---|
| 記事1: Value Object | ロジックの分散 |
| 記事2: Validator Object + Form Object | インラインバリデーション |
| 記事3: Query Object + Service Object | インラインクエリ・集計ロジック |
| 記事4: Adapter + ActiveJob + Pub/Sub | 通知のハードコード・同期処理 |
| 記事5: Workflow + Command | ファットコントローラ・トランザクション管理 |
| 記事6: Policy Object + Concern + Callback | 認可・共通スコープ・監査ログ |
| 記事7: Presenter + ViewComponent | モデルへの表示ロジック混入 |
Value Object パターン
対応PR: PR1: Value Object パターン導入
概要
Value Object は、値そのものを表す不変(immutable)なオブジェクトです。
Integer や String と同様に、「同じ値であれば同じオブジェクトとして扱う」という考え方を
ドメイン固有の概念に適用します。
このリファクタリングでは、レポートに登場する4つのドメイン値を専用クラスに切り出します。
| クラス | 表す値 | 単位変換 |
|---|---|---|
Period |
期間(開始日〜終了日) | 日数計算、範囲チェック |
BodyWeight |
体重 | グラム → kg |
BodyFatRate |
体脂肪率 | basisPoints → % |
Calories |
カロリー | — |
問題:アンチパターン
main ブランチのコントローラを見ると、単位変換とフォーマット処理が散在しています。
# app/controllers/weekly_reports_controller.rb (main ブランチ)
# インライン 集計
avg_weight_g = if weight_entries.any?
(weight_entries.sum(:weight_g) / weight_entries.count.to_f).round
end
さらにモデルにも表示メソッドが直書きされています。
# app/models/weekly_report.rb (main ブランチ)
def formatted_weight
avg_weight_g ? "#{avg_weight_g / 1000.0} kg" : "データなし"
end
def formatted_fat
avg_body_fat_bp ? "#{avg_body_fat_bp / 100.0}%" : "データなし"
end
問題点
-
知識の分散:
/ 1000.0という「グラム→kg変換」の知識があちこちに書かれる - 等値比較が難しい: プリミティブな数値では「この体重は同じか」を意味的に比較できない
- テストしにくい: 変換ロジックのテストにモデル全体が必要になる
解決策:Value Object の導入
# app/value_objects/body_weight.rb
class BodyWeight
attr_reader :grams
def initialize(grams)
@grams = grams
freeze # 不変性を保証
end
def to_kg
grams / 1000.0
end
def formatted
"#{to_kg} kg"
end
def ==(other)
other.is_a?(BodyWeight) && grams == other.grams
end
end
# app/value_objects/body_fat_rate.rb
class BodyFatRate
attr_reader :basis_points
def initialize(basis_points)
@basis_points = basis_points
freeze
end
def to_percent
basis_points / 100.0
end
def formatted
"#{to_percent}%"
end
def ==(other)
other.is_a?(BodyFatRate) && basis_points == other.basis_points
end
end
# app/value_objects/period.rb
class Period
attr_reader :start_date, :end_date
def initialize(start_date:, end_date:)
@start_date = start_date
@end_date = end_date
freeze
end
def self.current_week
new(start_date: Date.current.beginning_of_week, end_date: Date.current.end_of_week)
end
def days
(end_date - start_date).to_i + 1
end
def covers?(date)
to_range.cover?(date)
end
def to_range
start_date..end_date
end
def ==(other)
other.is_a?(Period) && start_date == other.start_date && end_date == other.end_date
end
end
# app/value_objects/calories.rb
class Calories
attr_reader :kcal
def initialize(kcal)
@kcal = kcal
freeze
end
def formatted
"#{kcal} kcal"
end
def +(other)
Calories.new(kcal + other.kcal)
end
def ==(other)
other.is_a?(Calories) && kcal == other.kcal
end
end
Before / After 比較
単位変換
# Before: コントローラやモデルに直書き
avg_weight_g / 1000.0 # どこでも書かれる
avg_body_fat_bp / 100.0 # 知識が分散
# After: 値オブジェクトにカプセル化
weight = BodyWeight.new(70_000)
weight.to_kg # => 70.0
weight.formatted # => "70.0 kg"
等値比較
# Before: プリミティブな比較(意味が不明確)
report.avg_weight_g == 70_000
# After: 値ベースの意味ある比較
BodyWeight.new(70_000) == BodyWeight.new(70_000) # => true
BodyWeight.new(70_000) == BodyWeight.new(60_000) # => false
期間の表現
# Before: 2つの Date をバラバラに渡す
def create_report(period_start, period_end)
(period_end - period_start).to_i # 計算が散在
end
# After: Period オブジェクトにまとめる
period = Period.new(start_date: Date.new(2024, 1, 1), end_date: Date.new(2024, 1, 7))
period.days # => 7
period.covers?(Date.new(2024, 1, 3)) # => true
まとめ
Value Object の3つの特徴
-
不変性(Immutability):
freezeによりオブジェクト生成後の変更を禁止 - 値ベースの等値性: オブジェクトのIDではなく、値が同じなら等しい
- ドメイン知識のカプセル化: 単位変換・フォーマット・計算を一か所に集約
使いどころ
- 単位を持つ数値(重さ、長さ、金額、温度)
- 複数の属性をまとめて扱いたい概念(住所、期間、座標)
- 変更されることのないドメインの値