投稿日:2026/7/28
更新日:2026/7/28

STI は「似ているけど少しずつ違うモデル」を 1つのテーブル + type カラム でまとめる設計。仕組み自体は拍子抜けするほど単純で、難しいのは「いつ使ってよくて、いつ破綻するか」のほう。ここでは通知(メール・SMS・プッシュ)を例に、その線引きまで通しで見る。
type カラム 1本。Rails が保存時に自動で書き、取得時に自動で読む通知を送る機能を作る。仕様はこう。
| 種類 | やること |
|---|---|
| メール通知 | 件名と本文をメールで送る |
| SMS通知 | 本文を SMS で送る |
| プッシュ通知 | 本文をアプリにプッシュする |
3つとも「宛先ユーザーがいて、本文があって、送信日時が記録される」という点は完全に同じ。違うのは送り方だけ。
素直にテーブルを3つ作るとこうなる。
email_notifications (id, user_id, body, sent_at)
sms_notifications (id, user_id, body, sent_at)
push_notifications (id, user_id, body, sent_at)
中身が同じ表が3つ並ぶ。そしてすぐ困ることになる。
# 「このユーザーへの通知を、種類を問わず新しい順に20件」
# → 3テーブルを引いて、Ruby側でマージして、ソートし直す……
種類をまたいだ一覧・検索・ページネーションが、途端に面倒になる。
type で区別するSTI(Single Table Inheritance/単一テーブル継承)は、この3つを1枚の表に入れる。
notifications
| id | type | user_id | body | sent_at |
|---|---|---|---|---|
| 1 | EmailNotification | 1 | 本日のまとめです | 2026-07-28 09:00 |
| 2 | SmsNotification | 1 | 認証コードは 1234 | 2026-07-28 09:05 |
| 3 | PushNotification | 2 | 新着メッセージ | 2026-07-28 09:10 |
増えた列は type の1つだけ。 ここにクラス名の文字列がそのまま入る。これが STI のすべてと言っていい。
マイグレーションで type(string)を持たせる。
create_table :notifications do |t|
t.string :type, null: false # ← これが STI の目印
t.references :user, null: false, foreign_key: true
t.text :body, null: false
t.datetime :sent_at
t.timestamps
end
add_index :notifications, [:type, :created_at]
モデルは、親を1つ作って継承するだけ。
# app/models/notification.rb
class Notification < ApplicationRecord
belongs_to :user
validates :body, presence: true
def deliver!
raise NotImplementedError
end
end
# app/models/email_notification.rb
class EmailNotification < Notification
def deliver!
NotificationMailer.generic(user, body).deliver_later
touch(:sent_at)
end
end
# app/models/sms_notification.rb
class SmsNotification < Notification
validates :body, length: { maximum: 70 } # SMS だけの制約
def deliver!
SmsClient.send(to: user.phone_number, text: body)
touch(:sent_at)
end
end
# app/models/push_notification.rb
class PushNotification < Notification
def deliver!
PushClient.push(user.device_token, body)
touch(:sent_at)
end
end
self.table_name の指定も、type への代入も書かなくていい。Rails は「親が ApplicationRecord を継承したモデルで、テーブルに type 列がある」と分かれば、あとは自動でやる。
type は誰が書いて、誰が読むのかSTI の挙動は、この往復さえ掴めば全部説明がつく。
EmailNotification.create!(user: user, body: "本日のまとめです")
INSERT INTO notifications (type, user_id, body, ...)
VALUES ('EmailNotification', 1, '本日のまとめです', ...)
type に 'EmailNotification' という文字列が自動で入る。
Notification.all
# => [#<EmailNotification id: 1>, #<SmsNotification id: 2>, #<PushNotification id: 3>]
親クラスで引いたのに、返ってくるのはそれぞれのサブクラスのインスタンス。Rails が type の文字列を constantize してクラスを決めているため。
だからループが素直に書ける。
Notification.where(sent_at: nil).find_each(&:deliver!)
# それぞれ自分の deliver! が呼ばれる(メールはメール、SMS は SMS)
ここが STI の旨味。
「1回のクエリで全種類を取ってきて、種類ごとに違う処理を走らせる」が自然に書ける。テーブルを分けていると、この形は書けない。
EmailNotification.count
SELECT COUNT(*) FROM notifications WHERE type = 'EmailNotification'
where(type: ...) を自分で書く必要はない。サブクラス経由のクエリには必ず type の条件が付く。
種類をまたいだ操作が、全部ふつうの ActiveRecord で書けるようになる。
# 種類を問わず新着20件(テーブル1つなので当然できる)
Notification.order(created_at: :desc).limit(20)
# 種類ごとの件数([group と集計関数(GROUP BY・HAVING)](/blog/group%20%E3%81%A8%E9%9B%86%E8%A8%88%E9%96%A2%E6%95%B0%EF%BC%88GROUP%20BY%E3%83%BBHAVING%EF%BC%89) の話がそのまま使える)
Notification.group(:type).count
# => { "EmailNotification" => 120, "SmsNotification" => 45, "PushNotification" => 300 }
# 関連も1本で済む
class User < ApplicationRecord
has_many :notifications # 3種類まとめて取れる
end
テーブルを3つに分けていたら、この一覧もこの集計も、UNION か Ruby 側マージが必要になる。
ここからが本題。STI は、種類ごとに固有の属性が増えた瞬間に崩れる。
仕様変更が来たとする。
STI は1テーブルなので、列を足す先は1箇所しかない。
notifications
| id | type | body | subject | icon_url | link_url |
|---|---|---|---|---|---|
| 1 | EmailNotification | 本日のまとめです | お知らせ | NULL | NULL |
| 2 | SmsNotification | 認証コードは 1234 | NULL | NULL | NULL |
| 3 | PushNotification | 新着メッセージ | NULL | /icon.png | /messages/9 |
見ての通り NULL だらけになる。しかも困るのは見た目だけではない。
NOT NULL 制約が付けられない — 「メールなら subject 必須」を DB で表現できず、検証がアプリ側だけになる(DB で守りたいなら CHECK制約について のように type を条件に含めた CHECK 制約が要る)判断の目安。
共通の属性が大半なら STI。種類ごとの固有属性が主役なら STI ではない。
「NULL になる列が増えてきた」は、STI から乗り換える合図。
| 手法 | テーブル構成 | 向いているケース |
|---|---|---|
| STI | 1つ(type 列) |
属性はほぼ共通、振る舞いだけ違う |
| Delegated Types | 親1つ + 種類ごとの子テーブル | 共通部分も固有部分も両方それなりにある |
| ポリモーフィック関連 | 完全に別テーブル同士を1つの関連で束ねる | 元々別物を、同じ場所に紐付けたいだけ |
| テーブル分割 | 種類ごとに独立 | 共通点が薄く、まとめて扱う要件もない |
Rails 6.1 以降の delegated_type。共通部分は親テーブル、固有部分は子テーブルに置く。
class Notification < ApplicationRecord
delegated_type :deliverable, types: %w[EmailDelivery SmsDelivery PushDelivery]
end
class EmailDelivery < ApplicationRecord # 独自テーブル。subject を NOT NULL にできる
end
notifications (id, user_id, body, sent_at, deliverable_type, deliverable_id)
email_deliveries (id, subject) ← 固有属性はここ
push_deliveries (id, icon_url, link_url)
「一覧は1テーブルで引きたい」という STI の利点を残したまま、固有属性を NULL なしで持てる。 代償は JOIN が要ること(JOINとGROUP BYを3人のユーザーで理解する)と、構成が一段複雑になること。
判断はシンプルで、固有属性が出てきたら delegated types を検討するでいい。
type にはクラス名が文字列で入っているつまり クラス名のリネーム = データ移行。
# EmailNotification → MailNotification に改名したい
Notification.where(type: "EmailNotification").update_all(type: "MailNotification")
名前空間を切ると Notifications::Email のような文字列がそのまま入るので、後から構成を変えると影響が大きい。STI のクラス名は、最初に落ち着いた名前を付ける。
enum と迷ったら「種類を持つ」だけなら enum で十分で、STI は要らない。
| 使いどころ | |
|---|---|
enum |
種類によって振る舞いは変わらない(表示ラベルや絞り込みだけ) |
| STI | 種類ごとにメソッドの実装を変えたい(deliver! の中身が違う) |
case type when ... end を書き始めたら、STI に寄せる合図。
type を含めるサブクラス経由のクエリには必ず WHERE type = ? が付く。既存のインデックスの先頭に type を足しておかないと効かない。
add_index :notifications, [:type, :user_id, :created_at]
type 以外にしたいときtype は Ruby の予約語感があるので嫌われがちだが、変更は可能。
class Notification < ApplicationRecord
self.inheritance_column = :kind
end
逆に「type という列を STI と無関係に使いたい」場合は、self.inheritance_column = nil で STI を無効化する。既存 DB を扱うときにたまに必要になる。
開発環境(eager load off)で Notification.subclasses が空になることがある。「サブクラス一覧を回す」ような実装は、Rails.application.eager_load! を前提にするか、そもそも定数で持つ。
type カラム1本。保存時に Rails がクラス名を書き、取得時に文字列からクラスを復元するWHERE type = ? が付くNOT NULL 制約も付けられなくなるenum との使い分けは「メソッドの実装を種類ごとに変えたいか」で決める迷ったら、テーブルを紙に書いてみるのが早い。列がスカスカにならないなら STI、なるなら分ける。