10
1

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プログラマをやっていますが、恥ずかしながら先日初めてActiveRecord::DelegatedTypeという機能がRailsにあることを知りました。

が、DelegatedTypeとは何なのか、何が嬉しいのかがよくわからなかったので、自分でサンプルアプリを作りながらDelegatedTypeについて学んでみたので、この記事ではその内容をまとめます。

僕と同じように「DelegatedType、わかるようでわからんなあ」と思っている方は、本記事が参考になるかもしれません!

TL;DR(最初に結論)

  • 「似て非なるモデル」を複数定義しなければいけないときは、クラスの継承が選択肢に挙がる
  • しかし、クラスの継承をSTIで実装すると巨大なテーブルができあがり、モデルごとに使われないカラムが大量に発生する
  • 継承以外のアプローチとしてコンポジション(composition)がある
  • has_oneを使ってコンポジションを実現する場合、モデルごとにテーブルが分割されて無駄はなくなるが、抽象的にモデルを扱えない
  • DelegatedTypeを使えばhas_oneと同じような利点を享受しつつ、抽象的にモデルを扱える!

作成したサンプルアプリ

DelegatedTypeを学習するために作成したサンプルアプリはこんな画面です。

Screenshot 2026-06-22 at 19.37.46.png

サンプルアプリの要件

このサンプルアプリは「給湯器」を一覧表示するRailsアプリです。
給湯器には以下の3種類があります。

  • ガス給湯器
  • 石油給湯器
  • エコキュート

各給湯器は共通項目として、以下の項目を持ちます。

  • 製品名
  • 発売日

また、3種類の給湯器は構造が異なるため、それぞれ異なる入力項目を持ちます。

  • ガス給湯器=対応ガス種、ガス消費量
  • 石油給湯器=対応燃料種、燃料消費
  • エコキュート=最大電流、貯湯量

Screenshot 2026-06-23 at 9.09.40.png
Image: https://www.noritz.co.jp/product/kyutou_bath/

もしSTIを使うとしたら?

共通のスーパークラスとして給湯器クラスを定義し、それを継承するガス給湯器クラスや石油給湯器クラスを定義するとしたら、RailsではSTI(Single Table Inheritance=単一テーブル継承)を使うことになります。

STIを使う場合のクラス構成

# 給湯器(スーパークラス)
class WaterHeater < ApplicationRecord
end

# ガス給湯器クラス(給湯器クラスのサブクラス)
class GasWaterHeater < WaterHeater
end

# 石油給湯器クラス(給湯器クラスのサブクラス)
class OilWaterHeater < WaterHeater
end

# エコキュートクラス(給湯器クラスのサブクラス)
class EcoCute < WaterHeater
end 

STIを使う場合のテーブル設計

create_table "water_heaters" do |t|
  t.string  "type",             null: false
  
  # --- 全種別共通 ---
  t.string  "name",             null: false
  t.date    "released_on"
  
  # --- ガス給湯器だけが使う ---
  t.integer "gas_type"
  t.integer "gas_consumption"
  
  # --- 石油給湯器だけが使う ---
  t.integer "fuel_type"
  t.decimal "fuel_consumption", precision: 4, scale: 1
  
  # --- エコキュートだけが使う ---
  t.integer "tank_capacity"
  t.integer "max_current"
  t.datetime "created_at",      null: false
  t.datetime "updated_at",      null: false
end

STIの問題点

このサンプルアプリはごく小規模なものなので「これならSTIでもいいのでは?」と思うかもしれません。
ですが、実務を想定すると、以下のような事態が想定されます。

  • ガス給湯器だけが使う項目や石油給湯器だけが使う項目がそれぞれ大量にある
  • 将来的にガスでも石油でもエコキュートでもない、第4、第5の給湯器が追加される可能性がある

そうなると、water_heatersテーブルに大量のカラムが追加されて無駄の多いテーブルになってしまいます(石油給湯器はガス給湯器やエコキュートのカラムを使わないし、エコキュートもガス給湯器や石油給湯器のカラムを使わない)。

create_table "water_heaters" do |t|
  t.string  "type",             null: false
  
  # --- 全種別共通 ---
  t.string  "name",             null: false
  t.date    "released_on"
  
  # --- ガス給湯器だけが使う ---
  t.integer "gas_type"
  t.integer "gas_consumption"
  # 他にもカラムがいろいろ
  # ...
  # ...
  
  # --- 石油給湯器だけが使う ---
  t.integer "fuel_type"
  t.decimal "fuel_consumption", precision: 4, scale: 1
  # 他にもカラムがいろいろ
  # ...
  # ...
  
  # --- エコキュートだけが使う ---
  t.integer "tank_capacity"
  t.integer "max_current"
  t.datetime "created_at",      null: false
  t.datetime "updated_at",      null: false
  # 他にもカラムがいろいろ
  # ...
  # ...

  # --- ○○の給湯器だけが使う ---
  # カラムがいろいろ
  # ...
  # ...

  # --- △△の給湯器だけが使う ---
  # カラムがいろいろ
  # ...
  # ...
end

この問題をDelegatedTypeを使って解決しましょう。

DelegatedTypeの基本的な考え方

DelegatedTypeでは継承(inheritance)ではなく委譲(delegation)を使います。
is-aではなく、has-aの関係になるので、委譲の代わりにコンポジション(composition)と呼んでもよいかもしれません。

たとえば、このサンプルアプリでコンポジションを使うなら、各給湯器の上位概念として製品(Product)を用意し、以下のように「製品が給湯器を持つ」という関係を持たせます。

Product has a heater

ここでいう給湯器(heater)は抽象的な概念です。
実際には上で挙げたようなガス給湯器や石油給湯器、エコキュートが給湯器の具体的なオブジェクト(STIのときに定義したサブクラス)になります。

Product has a gas water heater

Or

Product has an oil water heater

Or

Product has an eco cute

もしhas_oneで実現するとしたら?

DelegatedTypeを説明する前にまず、DelegatedTypeなしでコンポジションを実現する方法を考えてみましょう。
この場合、次のようにhas_oneを使ったクラス設計になると思います。

# 共通項目の製品名と発売日を保持する
class Product < ApplicationRecord
  # どれか1つだけが紐付く
  has_one :gas_water_heater
  has_one :oil_water_heater
  has_one :eco_cute
end

# 対応ガス種とガス消費量を保持する
class GasWaterHeater < ApplicationRecord
  belongs_to :product
end

# 対応燃料種と燃料消費を保持する
class OilWaterHeater < ApplicationRecord
  belongs_to :product
end

# 最大電流と貯湯量を保持する
class EcoCute < ApplicationRecord
  belongs_to :product
end

こうするとガス給湯器や石油給湯器、エコキュートがそれぞれ別のテーブルになるため、STIのように「巨大な給湯器テーブル」を作成しなくて済みます。

create_table "products" do |t|
  t.string "name", null: false
  t.date   "released_on"
  t.timestamps
end

create_table "gas_water_heaters" do |t|
  t.references "product", null: false, foreign_key: true
  t.integer    "gas_type"
  t.integer    "gas_consumption"
  t.timestamps
end

create_table "oil_water_heaters" do |t|
  t.references "product", null: false, foreign_key: true
  t.integer    "fuel_type"
  t.decimal    "fuel_consumption", precision: 4, scale: 1
  t.timestamps
end

create_table "eco_cutes" do |t|
  t.references "product", null: false, foreign_key: true
  t.integer    "tank_capacity"
  t.integer    "max_current"
  t.timestamps
end

has_oneを使用するときの問題点

しかし、これだとProductモデルは「GasWaterHeater、OilWaterHeater、EcoCuteのいずれか1つを持つ」という設計になるため、

Product has a heater

というように抽象的に給湯器モデルを扱うことができません(具体的なモデルを常に考慮する必要がある)。

たとえば、N+1問題を起こさないように製品の一覧を取得しようとすると、以下のように各モデルの名前を列挙する必要があります。

@products = Product.includes(:gas_water_heater, :oil_water_heater, :eco_cute)

テーブル設計上の無駄はなくなりましたが、Railsのコードとして見たときは少し不格好です。

加えて、「ProductにはGasWaterHeater、OilWaterHeater、EcoCuteのいずれか1つが紐付く」というのは運用上のルールであって、DB上の制約ではないため、何かの事故で「GasWaterHeaterとOilWaterHeaterの両方を持つProduct」が作られる可能性もゼロとは言えません。

そこでDelegatedType!

さあ、ようやくDelegatedTypeの出番がやってきました。
DelegatedTypeを使えば、

Product has a heater

の関係をRailsのコードとして自然に表現できます。
以下がそのコード例です。

class Product < ApplicationRecord
  delegated_type :heater,
    types: %w[GasWaterHeater OilWaterHeater EcoCute]
end

上のようにdelegated_typeを定義すると、GasWaterHeaterまたはOilWaterHeaterまたはEcoCuteのいずれかをheaterという名前で参照できます。

gas_product = Product.find_by(name: 'GX-24')
gas_product.heater #=> #<GasWaterHeater:0x00000001051562c8...>
gas_product.heater.gas_type #=> "city_gas"

oil_product = Product.find_by(name: 'OL-46')
oil_product.heater #=> #<OilWaterHeater:0x00000001133aefd0...>
oil_product.heater.fuel_type #=> "kerosene"

eco_cute_product = Product.find_by(name: 'EQ-370')
eco_cute_product.heater #=> #<EcoCute:0x00000001128958d8...>
eco_cute_product.heater.max_current #=> 18

N+1問題を避けながら製品一覧を取得する場合は、以下のようにincludes(:heater)だけで済みます。
has_oneのときのように各モデルを列挙しなくて済むのでとても簡潔です。

@products = Product.includes(:heater)

給湯器種別に応じたクラス定義

GasWaterHeater、OilWaterHeater、EcoCuteの各クラス定義は以下のようになります。

class GasWaterHeater < ApplicationRecord
  has_one :product, as: :heater
end

class OilWaterHeater < ApplicationRecord
  has_one :product, as: :heater
end

class EcoCute < ApplicationRecord
  has_one :product, as: :heater
end

「もしhas_oneで実現するとしたら?」の項で紹介したコード例ではbelongs_toを使っていましたが、こちらはhas_oneになっています。

# 「もしhas_oneで実現するとしたら?」のコード例
class Product < ApplicationRecord
  has_one :gas_water_heater
  # ...
end

# GasWaterHeater側がbelongs_to :product
class GasWaterHeater < ApplicationRecord
  # 👇 belongs_toを使っている
  belongs_to :product
end
# DelegatedTypeを使う場合のコード例
class Product < ApplicationRecord
  delegated_type :heater,
    types: %w[GasWaterHeater ...]
end

# GasWaterHeater側がhas_one :product
class GasWaterHeater < ApplicationRecord
  # 👇 has_oneを使っている
  has_one :product, as: :heater
end

つまり、「もしhas_oneで実現するとしたら?」のときは、

  • 関連元がProductで、関連先がGasWaterHeater(Product has_one GasWaterHeater)

という関連だったのが、DelegatedTypeを使う場合は、

  • 関連元がGasWaterHeaterで、関連先がProduct(GasWaterHeater has_one Product)

というふうに逆転している点に注意してください。

各モデルのテーブル定義

テーブル定義は以下のようになります。
先ほど説明したように関連が逆転しているため、productsテーブル側にreferencesが設定される点に注意してください。

create_table :products do |t|
  t.string :name, null: false
  t.date :released_on
  t.references :heater, polymorphic: true, null: false
  t.timestamps
end

create_table :gas_water_heaters do |t|
  t.integer :gas_type, null: false
  t.integer :gas_consumption
  t.timestamps
end

create_table :oil_water_heaters do |t|
  t.integer :fuel_type, null: false
  t.decimal :fuel_consumption, precision: 4, scale: 1
  t.timestamps
end

create_table :eco_cutes do |t|
  t.integer :tank_capacity
  t.integer :max_current
  t.timestamps
end

データ作成時のコード例

関連元がGasWaterHeaterやOilWaterHeaterで、関連先がProductになっているということは、Productよりも先にGasWaterHeaterやOilWaterHeaterのレコードを保存しておかないといけない、ということです。

Railsでは以下のようなコードを書くことで、2つのレコードを同時に保存できます。

# GasWaterHeater → Productの順番でレコードが保存される
Product.create!(
  name: "GX-24",
  released_on: Date.new(2024, 4, 1),
  heater: GasWaterHeater.new(gas_type: :city_gas, gas_consumption: 30500)
)

DelegatedTypeを使う場合のcontrollerとviewの実装例

冒頭で紹介した「製品一覧」画面を再掲します。

Screenshot 2026-06-22 at 19.37.46.png

DelegatedTypeを利用すると、controllerとviewは以下のような実装になります。
(注:Tailwind用のclassやdivタグ等はノイズになるので、以下のコードからは省いています)

class ProductsController < ApplicationController
  def index
    @products = Product.includes(:heater).order(:released_on)
  end
end
app/views/products/index.html.erb
<h1>給湯器 製品一覧</h1>
<ul>
  <!-- products/_product.html.erbをrender -->
  <%= render @products %>
</ul>

以下のコードではheaterの実体に応じてRailsがrenderするパーシャルを切り替えてくれるため、「ガス給湯器の場合」「石油給湯器の場合」といった条件分岐が一切出てこない点に注目してください。

products/_product.html.erb
<li>
  <h2><%= product.name %></h2>
  <p><%= product.heater.model_name.human %></p>
  <p>発売日: <%= l product.released_on %></p>
  <dl>
    <!-- heaterの実体に応じて以下のいずれかのパーシャルをrender -->
    <!-- * app/views/gas_water_heaters/_gas_water_heater.html.erb -->
    <!-- * app/views/oil_water_heaters/_oil_water_heater.html.erb -->
    <!-- * app/views/eco_cutes/_eco_cute.html.erb -->
    <%= render product.heater %>
  </dl>
</li>
app/views/gas_water_heaters/_gas_water_heater.html.erb
<!-- このパーシャルはheaterがガス給湯器の場合にrenderされる -->
<dt>対応ガス種</dt>
<dd><%= t(gas_water_heater.gas_type, scope: "activerecord.attributes.gas_water_heater.gas_types") %></dd>
<dt>ガス消費量</dt>
<dd><%= gas_water_heater.gas_consumption %>kcal/h</dd>
app/views/oil_water_heaters/_oil_water_heater.html.erb
<!-- このパーシャルはheaterが石油給湯器の場合にrenderされる -->
<dt>対応燃料種</dt>
<dd><%= t(oil_water_heater.fuel_type, scope: "activerecord.attributes.oil_water_heater.fuel_types") %></dd>
<dt>燃料消費</dt>
<dd><%= oil_water_heater.fuel_consumption %>L/h</dd>
app/views/eco_cutes/_eco_cute.html.erb
<!-- このパーシャルはheaterがエコキュートの場合にrenderされる -->
<dt>最大電流</dt>
<dd><%= eco_cute.max_current %>A</dd>
<dt>貯湯量</dt>
<dd><%= eco_cute.tank_capacity %>L</dd>

DelegatedTypeについてもう少し詳しく

Rails 6.1から導入された

DelegatedTypeはRails 6.1で導入されました。

Delegated Types is an alternative to single-table inheritance. This helps represent class hierarchies allowing the superclass to be a concrete class that is represented by its own table. Each subclass has its own table for additional attributes.

https://guides.rubyonrails.org/6_1_release_notes.html#delegated-types

(ChatGPT翻訳)
Delegated Types は、単一テーブル継承の代替手段です。

これは、スーパークラス自身が専用のテーブルを持つ具象クラスとして存在しながら、クラス階層を表現できる仕組みです。

また、各サブクラスは、それぞれ固有の属性を保存するための専用テーブルを持ちます。

scopeで特定のモデルだけを取得する

「ガス給湯器の一覧だけ取得したい」という場合は、以下のようにRailsが自動的に定義するscopeが使えます。

Product.gas_water_heaters
#=> [#<Product:0x00000001294f1698
#     id: 1,
#     name: "GX-24",
#     # ...>,
#    #<Product:0x00000001294f1558
#     id: 2,
#     name: "GX-16P",
#     # ...>]

委譲先のモデルの型を判定する

取得したProductのheaterがどのモデルなのかは、以下のようなメソッドで確認できます。

product = Product.find_by(name: 'GX-24')

product.heater_type  #=> "GasWaterHeater"
product.heater_name  #=> "gas_water_heater"
product.heater_class #=> GasWaterHeater

product.gas_water_heater? #=> true
product.oil_water_heater? #=> false
product.eco_cute?         #=> false

product.gas_water_heater #=> #<GasWaterHeater:0x0000000126d463c8...>
product.oil_water_heater #=> nil
product.eco_cute         #=> nil

委譲先のモデルを同時に削除する

dependent: :destroyオプションを付けると、Productを削除したときに委譲先のモデル(GasWaterHeater、OilWaterHeater、EcoCute)も同時に削除できます。

 class Product < ApplicationRecord
   delegated_type :heater,
     types: %w[GasWaterHeater OilWaterHeater EcoCute],
+    dependent: :destroy
 end

委譲先を持たないデータを許容する

optional: trueオプションを付けると、heaterを持たないProductを作成できます。
(この場合、productsテーブル側もheater_typeheater_idのNULLを許容する必要があります)

 class Product < ApplicationRecord
   delegated_type :heater,
     types: %w[GasWaterHeater OilWaterHeater EcoCute],
+    optional: :true
 end

委譲先が変更されたタイミングで関連元のupdated_atを更新する

委譲先のクラスでは、has_one関連にtouch: trueオプションを付けることも多いです。

class GasWaterHeater < ApplicationRecord
  has_one :product, as: :heater, touch: true
end

touch: trueオプションを付けると、ガス給湯器モデルの内容が変更されたときに、関連元の製品モデルのupdated_atも同時に更新されます。
「ガス給湯器の内容が変更された=製品情報が変更された」と見なす場合は、このオプションを付けると良いでしょう。

モジュールを定義して委譲先のコードをDRYにする

委譲先のクラスではhas_oneの定義が全く同じになることが多いので、DelegatedTypeを説明する記事ではモジュールを定義して、それをincludeするサンプルコードもよく見かけます。

module Heaterable
  extend ActiveSupport::Concern

  included do
    has_one :product, as: :heater, touch: true
  end
end
 class GasWaterHeater < ApplicationRecord
-  has_one :product, as: :heater, touch: true
+  include Heaterable
 end
 
 class OilWaterHeater < ApplicationRecord
-  has_one :product, as: :heater, touch: true
+  include Heaterable
 end
 
 class EcoCute < ApplicationRecord
-  has_one :product, as: :heater, touch: true
+  include Heaterable
 end

DelegatedTypeはポリモーフィック関連を利用して実装されている

Railsに詳しい人はDelegatedTypeの説明を読んでいると、「これってポリモーフィック関連に似てない?」と思ったかもしれません。

はい、その予想は合っています!
実際、delegated_typeの実装コードを見ると、以下のようにpolymorphic: trueを付けたbelongs_to関連が定義されていることがわかります。

本記事で使用したサンプルアプリについて

このサンプルアプリのコードは以下のGitHubリポジトリに置いてあります。
手元で動かしてみたい、という方はこちらをどうぞ。

このサンプルアプリの実行環境は以下の通りです。

  • Rails 8.1.3
  • Ruby 4.0.5

なお、大半のコードはClaude Codeに書いてもらいました。

Claude Codeに渡したスクリプト

DelegatedTypeを理解するために以下のような仕様を持つサンプルアプリケーションを作りたい。

親クラスとして以下の項目を持つ給湯器クラスを定義する。

  • 製品名
  • 発売日

委譲先のクラスとして以下の3クラスを用意する。
各クラスが持つ項目は以下の通り。

ガス給湯器クラス

  • 対応ガス種(都市ガス or プロパン)
  • ガス消費量(30500kcal/hなど)

石油給湯器クラス

  • 対応燃料種(灯油 or 重油)
  • 燃料消費(4.1L/hなど)

エコキュートクラス

  • 貯湯量(370Lなど)
  • 最大電流(18Aなど)

製品一覧ページを用意し、各製品に登録された項目を出力する。

まとめ

というわけで、この記事ではサンプルアプリを使いながら、RailsのDelegatedTypeの考え方や実装例を説明してみました。

この記事で説明した内容をまとめると以下のようになります。

  • 「似て非なるモデル」を複数定義しなければいけないときは、クラスの継承が選択肢に挙がる
  • しかし、クラスの継承をSTIで実装すると巨大なテーブルができあがり、モデルごとに使われないカラムが大量に発生する
  • 継承以外のアプローチとしてコンポジション(composition)がある
  • has_oneを使ってコンポジションを実現する場合、モデルごとにテーブルが分割されて無駄はなくなるが、抽象的にモデルを扱えない
  • DelegatedTypeを使えばhas_oneと同じような利点を享受しつつ、抽象的にモデルを扱える!

本記事が僕と同じように「DelegatedTypeって何?どう嬉しいの??」と首をかしげていた人の参考になると幸いです!

参考文献

10
1
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
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?