mybook

Decorator パターン — 機能を動的に追加する

「モデルが700行になってしまいました…」

ある月曜の朝、ケンタはチームの朝会でついに白旗を上げた。

「えっと…Userモデルが700行を超えてしまいました。スクロールしても全体が見えなくて、どこに何が書いてあるかわからない状態です」

チームの沈黙が重かった。山田さんが静かにコードを開いた。

# app/models/user.rb(一部抜粋)
class User < ApplicationRecord
  # 260行目あたりから表示系メソッドが始まる
  def display_name
    name.presence || email.split("@").first
  end
 
  def formatted_joined_at
    joined_at.strftime("%Y年%m月%d日")
  end
 
  def avatar_url
    if avatar.attached?
      Rails.application.routes.url_helpers.rails_blob_url(avatar)
    else
      "https://via.placeholder.com/100?text=#{display_name[0]}"
    end
  end
 
  def membership_badge
    case membership_type
    when "premium" then "👑 プレミアム会員"
    when "corporate" then "🏢 法人会員"
    else "一般会員"
    end
  end
 
  def profile_completion_rate
    fields = [:name, :bio, :phone_number, :avatar]
    filled = fields.count { |f| send(f).present? }
    (filled.to_f / fields.length * 100).to_i
  end
 
  # さらに続く...
end

「ケンタ君、これはよくある問題だよ」と山田さんは言った。「Fat Model問題と呼ばれる。そして解決策はDecoratorパターンだ。」

ケンタは「Decorator?」と首をかしげた。「デコレーター、ケーキを飾り付けるやつですか?」

「まさにその感覚が正しい。今日はその話をしよう。」

「表示ロジックはどこに書くの?」

その日の夕方、ケンタはユーザープロフィールページをリファクタリングしていた。

「ユーザーの表示名って、登録名があれば登録名、なければメールアドレスの@より前を使うんですが、これってモデルのどこに書くべきでしたか?」

山田さんは答えた。「表示用のロジックをモデルに書くのは避けたい。モデルはデータと振る舞いを管理するもの。表示(View)の関心事は別のクラスに分けよう。それがDecoratorパターンだ。」

Decorator パターンとは

Decorator パターンは、既存のオブジェクトをラップし、元のオブジェクトを変更せずに機能を動的に追加するパターンだ。

比喩1:コーヒーのカスタマイズ — コーヒー(基本オブジェクト)に、ミルク(Decorator)、シロップ(Decorator)、ホイップクリーム(Decorator)を重ねていく。各トッピングは「コーヒーとして振る舞いつつ、何かを追加する」。

比喩2:スマートフォンのケース — 本体(iPhone)はそのままで、耐衝撃ケース、リングホルダー、財布機能を重ねていける。本体は一切変わらない。

比喩3:クリーニング後の服 — シャツにアイロン、のり付け、丁寧な折り畳みを施す。素材や形は変わらないが「付加価値」がついていく。

Loading diagram...

継承との違い: 継承はコンパイル時(クラス定義時)に決まるが、Decoratorは実行時に動的に機能を追加できる。また、複数のDecoratorを重ね合わせることができる。

INFO

Decoratorパターンのポイントは「元のオブジェクトを変えない」こと。UserモデルはDBとのやり取りに集中し、表示の責任はDecoratorが担う。これが単一責任の原則(SRP)だ。

問題のあるコード(Before)

Fat Modelの典型的なパターンを見てみよう。

# app/models/user.rb — モデルに表示ロジックが混入している
class User < ApplicationRecord
  validates :email, presence: true
 
  # ドメインロジック(正しい)
  def premium?
    membership_type == "premium"
  end
 
  def can_access_premium_content?
    premium? && !subscription_expired?
  end
 
  # 表示ロジック(モデルに書くべきでない)
  def display_name
    name.presence || email.split("@").first
  end
 
  def avatar_url
    if avatar.attached?
      Rails.application.routes.url_helpers.rails_blob_url(avatar)
    else
      "https://via.placeholder.com/100?text=#{display_name[0]}"
    end
  end
 
  def formatted_joined_at
    joined_at.strftime("%Y年%m月%d日")
  end
 
  def membership_badge
    case membership_type
    when "premium" then "👑 プレミアム会員"
    when "corporate" then "🏢 法人会員"
    else "一般会員"
    end
  end
 
  def profile_completion_rate
    fields = [:name, :bio, :phone_number, :avatar]
    filled = fields.count { |f| send(f).present? }
    (filled.to_f / fields.length * 100).to_i
  end
end

問題点:

  • モデルがどんどん太る(Fat Model問題)
  • 表示形式が変わるたびにモデルのテストが必要
  • 絵文字や文字列フォーマットがビジネスロジックと混在
  • モデルをRails以外で使うとき(バッチ処理など)に不要なメソッドが邪魔

WARNING

Fat Modelは「技術的負債の温床」だ。今日1つの表示メソッドを追加するのに10分かかる。半年後は同じ作業に1時間かかる。700行のモデルは全員の生産性を下げる。

Draper Gem によるDecoratorの実装

Draper はRailsでDecoratorパターンを実現するための定番gemだ。

# Gemfile
gem "draper"
bundle install
rails generate decorator User
# app/decorators/user_decorator.rb が生成される
# app/decorators/user_decorator.rb
class UserDecorator < Draper::Decorator
  delegate_all  # 元のUserモデルのメソッドは全て委譲
 
  # 表示ロジックだけここに書く
  def display_name
    object.name.presence || object.email.split("@").first
  end
 
  def avatar_url
    if object.avatar.attached?
      h.rails_blob_url(object.avatar)
    else
      h.image_url("default_avatar.png")
    end
  end
 
  def formatted_joined_at
    object.joined_at.strftime("%Y年%m月%d日")
  end
 
  def membership_badge
    case object.membership_type
    when "premium"   then "プレミアム会員"
    when "corporate" then "法人会員"
    else "一般会員"
    end
  end
 
  def profile_completion_rate
    fields = [:name, :bio, :phone_number, :avatar]
    filled = fields.count { |f| object.send(f).present? }
    (filled.to_f / fields.length * 100).to_i
  end
end

INFO

h はビューヘルパーへのアクセスを提供する。h.image_urlh.link_toなどが使える。Decoratorはビューとモデルの橋渡し役なので、ヘルパーを使えることが重要。

コントローラでDecoratorを適用する

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def show
    @user = User.find(params[:id]).decorate
    # または: @user = UserDecorator.decorate(User.find(params[:id]))
  end
 
  def index
    @users = User.all.decorate
    # コレクション全体を一括でデコレートできる
  end
end

Viewでそのまま使う

<%# app/views/users/show.html.erb %>
<div class="profile">
  <img src="<%= @user.avatar_url %>" alt="<%= @user.display_name %>">
  <h1><%= @user.display_name %></h1>
  <span class="badge"><%= @user.membership_badge %></span>
  <p>登録日: <%= @user.formatted_joined_at %></p>
  <div class="progress" style="width: <%= @user.profile_completion_rate %>%">
    プロフィール完成度: <%= @user.profile_completion_rate %>%
  </div>
  <%# モデルのメソッドも委譲されるのでそのまま使える %>
  <% if @user.premium? %>
    <span>プレミアムコンテンツ利用中</span>
  <% end %>
</div>

INFO

モデルのメソッド(premium?, emailなど)も delegate_all のおかげでそのまま使える。ViewはDecoratorが本物のUserかどうか気にしなくていい。

WARNING

コレクションをデコレートするとき User.all.decorate は問題ないが、User.all.map(&:decorate) は遅い。Draperは decorate_collection という専用メソッドを持っている。大量データには注意。

Draperを使わない純粋Rubyの実装

Draperなしでも同じことができる。SimpleDelegator を使うと委譲が楽だ。

# app/decorators/user_decorator.rb
require "delegate"
 
class UserDecorator < SimpleDelegator
  # SimpleDelegatorを継承すると、元オブジェクトの全メソッドが自動委譲される
  # def_delegatorsを明示的に書く必要がない
 
  def display_name
    name.presence || email.split("@").first
    # SimpleDelegatorのおかげで name, email が直接呼べる
  end
 
  def membership_badge
    case membership_type
    when "premium"   then "プレミアム会員"
    when "corporate" then "法人会員"
    else "一般会員"
    end
  end
 
  def formatted_joined_at
    joined_at.strftime("%Y年%m月%d日")
  end
 
  # 元のオブジェクトへのアクセスは __getobj__ で
  def original_user
    __getobj__
  end
end
# 使う側
user = User.find(params[:id])
decorated = UserDecorator.new(user)
 
decorated.display_name       # => "田中ケンタ"
decorated.premium?           # => true(SimpleDelegatorが委譲)
decorated.email              # => "kenta@example.com"
decorated.is_a?(User)        # => false(注意!)
decorated.__getobj__.is_a?(User) # => true

WARNING

SimpleDelegator で作ったDecoratorは is_a?(User)false を返す。ActiveRecordのポリモーフィズムやSTIを使っている箇所でこれが問題になることがある。型チェックが必要な場所では Draper の方が安全だ。

Decoratorを積み重ねる

複数のDecoratorを重ねて、段階的に機能を追加できる。

Loading diagram...
# 管理者向けには追加情報を表示
class AdminUserDecorator < UserDecorator
  def display_name
    "#{super} (ID: #{id})"  # UserDecoratorのdisplay_nameを拡張
  end
 
  def admin_notes
    internal_notes.presence || "ノートなし"
  end
 
  def last_login_summary
    return "未ログイン" unless last_login_at
 
    days_ago = ((Time.current - last_login_at) / 1.day).to_i
    "#{days_ago}日前(#{last_login_at.strftime('%m/%d %H:%M')})"
  end
end
# コントローラでの使い分け
class UsersController < ApplicationController
  def show
    user = User.find(params[:id])
 
    @user = if current_user.admin?
              AdminUserDecorator.new(user)
            else
              UserDecorator.new(user)
            end
  end
end
<%# テンプレートは同じものを使える %>
<h1><%= @user.display_name %></h1>
<%# 管理者なら "田中ケンタ (ID: 42)"、一般なら "田中ケンタ" %>
 
<% if @user.respond_to?(:admin_notes) %>
  <p>管理メモ: <%= @user.admin_notes %></p>
<% end %>

WARNING

Decoratorを深く積み重ねすぎると、「どのDecoratorがこのメソッドを提供しているか」がわかりにくくなる。3層以上のDecorator継承は避け、その場合はFacadeパターンか、Decoratorの合成を検討しよう。

APIレスポンスのDecorator — Serializerとの比較

Decoratorはビュー層だけでなく、APIレスポンスの整形にも使える。

# app/decorators/user_api_decorator.rb
class UserApiDecorator < SimpleDelegator
  def as_json(options = {})
    {
      id: id,
      display_name: name.presence || email.split("@").first,
      email: email,
      membership_label: membership_label,
      joined_at: joined_at.iso8601
    }
  end
 
  private
 
  def membership_label
    case membership_type
    when "premium"   then "プレミアム会員"
    when "corporate" then "法人会員"
    else "一般会員"
    end
  end
end
# コントローラ
class Api::V1::UsersController < ApplicationController
  def show
    user = User.find(params[:id])
    render json: UserApiDecorator.new(user).as_json
  end
end

DecoratorとSerializerの使い分け:

観点DecoratorSerializer(ActiveModelSerializers等)
目的表示ロジックの追加APIシリアライズに特化
用途HTML View + API 両対応API専用
学習コスト低い(Rubyの知識だけ)高い(DSL習得が必要)
柔軟性高い制約あり

INFO

小規模APIならDecoratorで十分。APIが成長して複数バージョンが必要になったり、スパースフィールドセットが必要になったりしたら、dedicated Serializerライブラリへの移行を検討しよう。

ログ出力・キャッシュをDecoratorで追加する

Decoratorはビジネスロジックを変えずに横断的な関心事を追加できる。

# ログ機能をDecoratorで追加
class LoggingUserDecorator < SimpleDelegator
  def initialize(user, logger: Rails.logger)
    super(user)
    @logger = logger
  end
 
  def update_profile(attributes)
    @logger.info("[User] プロフィール更新開始 user_id=#{id}")
    result = __getobj__.update(attributes)
    if result
      @logger.info("[User] プロフィール更新成功 user_id=#{id}")
    else
      @logger.warn("[User] プロフィール更新失敗 user_id=#{id} errors=#{errors.full_messages}")
    end
    result
  end
end

INFO

ログ出力やキャッシュをDecoratorで追加することで、本来のビジネスロジック(Userモデル)を一切変えずに横断的な関心事を追加できる。これはDecoratorパターンの真骨頂だ。

Rack MiddlewareはDecoratorである

「実はケンタ君、Railsの中核にもDecoratorパターンが使われていることを知っているか?」

「えっ、どこですか?」

Rack Middlewareだよ。」

Loading diagram...
# config/application.rb
module MyApp
  class Application < Rails::Application
    # Middlewareスタックは、リクエスト/レスポンスを包むDecoratorの連鎖
    config.middleware.use Rack::Attack        # レート制限Decorator
    config.middleware.use Rack::Cors          # CORSヘッダーDecorator
    config.middleware.insert_before 0, Rack::Deflater  # 圧縮Decorator
  end
end
# カスタムMiddlewareを作ると、Decoratorパターンそのもの
class RequestTimingMiddleware
  def initialize(app)
    @app = app  # 次のMiddleware(またはアプリ本体)を「包む」
  end
 
  def call(env)
    started_at = Time.current
    status, headers, body = @app.call(env)  # 本体への委譲
    elapsed = Time.current - started_at
 
    Rails.logger.info("Request took #{(elapsed * 1000).round}ms")
    headers["X-Request-Time"] = elapsed.to_s
 
    [status, headers, body]
  end
end

「Middlewareは全部このパターンです。@app.call(env) で本体に委譲して、前後に処理を追加している。まさにDecoratorだ。」

ケンタは目を丸くした。「普段使っているRailsの中身がこんな風になっているとは思いませんでした!」

INFO

Rack Middlewareを理解するとDecoratorパターンが直感的に掴める。before/afterの処理をラップして委譲する構造は、UserDecoratorがUserモデルをラップする構造と全く同じだ。

Decorator vs Concern vs Helper

「どれを使えばいいか混乱します」とケンタが言った。

DecoratorConcernHelper
用途特定オブジェクトの表示モデル横断の共有ロジックViewの汎用ヘルパー
テスト単体テストしやすいモデルのテストに含むヘルパーのテスト
依存モデルに依存モデルにincludeビューに依存
user.display_namehas_notificationsformat_price(1000)
ビューヘルパー使える(Draper)使えないそのもの

WARNING

Concern(ActiveSupport::Concern)はモデルのビジネスロジックを分割するためのもの。表示ロジックをConcernに書くのはDecoratorと同じ問題を引き起こす。Concernは「関心の共有」、Decoratorは「表示の追加」と使い分けよう。

どんなときにDecoratorを使うか — 判断フロー

「結局、迷ったときはどう判断すればいいですか?」とケンタが聞いた。

Loading diagram...

具体的な判断基準:

  • joined_at.strftime("%Y年%m月%d日")Decorator(表示専用)
  • membership_type == "premium"Model(ビジネスルール)
  • 複数のモデルで使う notifiable?Concern
  • number_to_currency(price)Helper(汎用フォーマット)

INFO

迷ったら「これはDBの値や状態を判断するロジックか?」と聞いてみよう。答えがYesならModel。「これはViewでどう見せるかだけの話か?」ならDecorator。

テストの書き方

Decoratorはビューから切り離されているため、単体テストが書きやすい。

# spec/decorators/user_decorator_spec.rb
require "rails_helper"
 
RSpec.describe UserDecorator do
  # Draperのspec用ヘルパーを使う
  include Draper::ViewContext
 
  let(:user) { create(:user, name: "田中ケンタ", membership_type: "premium") }
  let(:decorated) { user.decorate }
 
  describe "#display_name" do
    context "名前が登録されている場合" do
      it "登録名を返す" do
        expect(decorated.display_name).to eq("田中ケンタ")
      end
    end
 
    context "名前が未登録の場合" do
      let(:user) { create(:user, name: nil, email: "kenta@example.com") }
 
      it "メールアドレスの@より前を返す" do
        expect(decorated.display_name).to eq("kenta")
      end
    end
  end
 
  describe "#membership_badge" do
    context "プレミアム会員の場合" do
      it "プレミアム会員を返す" do
        expect(decorated.membership_badge).to eq("プレミアム会員")
      end
    end
 
    context "一般会員の場合" do
      let(:user) { create(:user, membership_type: "general") }
 
      it "一般会員を返す" do
        expect(decorated.membership_badge).to eq("一般会員")
      end
    end
  end
 
  describe "#profile_completion_rate" do
    context "全フィールドが埋まっている場合" do
      let(:user) { create(:user, :with_full_profile) }
 
      it "100を返す" do
        expect(decorated.profile_completion_rate).to eq(100)
      end
    end
  end
end

INFO

instance_double を使えば実際のDBアクセスなしに高速テストが書ける。Decoratorが表示ロジックのみに集中しているなら、DB不要のテストが自然に書ける。これもDecoratorの利点だ。

AWSでのDecorator的発想

AWSでも「既存のリソースに機能を追加する」パターンがある。

Loading diagram...

EC2インスタンス自体を変えずに、IAMロールで権限を、Security Groupでファイアウォールを、CloudWatchエージェントで監視を追加している。これはDecoratorパターンと同じ考え方だ。

AWSサービスとDecoratorの対応:

AWSサービスDecoratorとしての役割
IAM Role権限の付与(元リソースは変わらない)
Security Groupネットワークアクセス制御を追加
Lambda Layer共通コードを関数に追加
ECS サイドカーログ・監視をメインコンテナに追加

Lambda Layer は関数コードを変えずに共通ライブラリを追加。ECS サイドカー はメインコンテナを変えずに Fluentd や Datadog Agent を付加する。どちらもDecoratorの発想そのものだ。

INFO

インフラのDecoratorパターンはImmutable Infrastructureと相性がいい。「EC2インスタンスを変えずに機能を追加する」というアプローチは、コードの「元のクラスを変えずに機能を追加する」Decoratorと完全に一致している。

Decorator が深くなりすぎる問題

「Decoratorって便利ですね。全部Decoratorにすればいいんじゃないですか?」

山田さんは笑った。「その考えは危険だよ。」

# アンチパターン:深すぎるDecorator
user = LoggingDecorator.new(
  CachingDecorator.new(
    AdminUserDecorator.new(User.find(1))
  )
)
# どのDecoratorがどのメソッドを持っているか追えない

深いDecoratorの問題:

  • デバッグが難しい(どのレイヤーでエラーが出たかわかりにくい)
  • is_a? チェックが失敗する
  • メソッドの名前が衝突したとき、どちらが呼ばれるかわかりにくい

改善策は「責任を明確に分けて、必要なDecoratorだけを選ぶ」こと。3層以上は設計を見直すサインだ。

WARNING

Decoratorは2層以内に収める。それ以上になったら設計を見直すサインだ。責任が増えすぎているなら、PresenterパターンやFacadeパターンへの移行を検討しよう。

ケンタの成長と次章への橋渡し

翌週、ケンタはリファクタリングを終えた。

「山田さん、Userモデルが700行から340行になりました!表示ロジックを全部UserDecoratorに移したんです。」

山田さんは画面を見て、うなずいた。「よくできた。テストは?」

「Decoratorのspecを書きました。モデルのテストより速く動くし、viewのセットアップも不要で、書きやすかったです。」

継承は縦に積む、Decoratorは横から包む。これが今日の一番のポイントだ」と山田さんは言った。

ケンタはノートを開いて書いた:

Decoratorパターン = 元のオブジェクトをラップして機能を追加。継承より柔軟で組み合わせ自在。Railsでは表示ロジックをモデルから分離するのに使う。Rack Middlewareも同じ構造だった。

「ちなみに」と山田さんが続けた。「今日学んだデコレーター的な発想、つまり『元を変えずに動作を制御・追加する』という発想、次章のProxyパターンとも深く関係がある。Decoratorが機能の追加なら、Proxyはアクセス制御や遅延ロードだ。楽しみにしておきなさい。」

INFO

この章のまとめ

  • Decoratorはオブジェクトをラップして機能を追加する。元のクラスは変わらない
  • 継承より柔軟。複数のDecoratorを重ねたり、動的に適用できる
  • Railsでは表示ロジックをモデルから分離するためによく使う(Fat Model解消)
  • Draper gemがRailsでのDecorator実装を簡単にする
  • SimpleDelegator を使うと依存なしに実装できる
  • Rack Middlewareはアプリ全体のDecoratorパターン
  • Decorator・Concern・Helperはそれぞれ異なる用途で使い分ける
  • AWSではIAMロール・SecurityGroupが同じ発想
  • Decoratorは2層以内に収め、深くなりすぎないよう注意