0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsアプリで学ぶデザインパターン - Value Object (1/7)

0
Last updated at Posted at 2026-03-16

はじめに

本記事では 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)なオブジェクトです。
IntegerString と同様に、「同じ値であれば同じオブジェクトとして扱う」という考え方を
ドメイン固有の概念に適用します。

このリファクタリングでは、レポートに登場する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

問題点

  1. 知識の分散: / 1000.0 という「グラム→kg変換」の知識があちこちに書かれる
  2. 等値比較が難しい: プリミティブな数値では「この体重は同じか」を意味的に比較できない
  3. テストしにくい: 変換ロジックのテストにモデル全体が必要になる

解決策: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つの特徴

  1. 不変性(Immutability): freeze によりオブジェクト生成後の変更を禁止
  2. 値ベースの等値性: オブジェクトのIDではなく、値が同じなら等しい
  3. ドメイン知識のカプセル化: 単位変換・フォーマット・計算を一か所に集約

使いどころ

  • 単位を持つ数値(重さ、長さ、金額、温度)
  • 複数の属性をまとめて扱いたい概念(住所、期間、座標)
  • 変更されることのないドメインの値
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?