YASD-TECH
YASD TECH
# Rails

STI(単一テーブル継承)を3種類の通知で理解する

投稿日:2026/7/28

更新日:2026/7/28

ttitleImage

STI(単一テーブル継承)を、3種類の通知で理解する

STI は「似ているけど少しずつ違うモデル」を 1つのテーブル + type カラム でまとめる設計。仕組み自体は拍子抜けするほど単純で、難しいのは「いつ使ってよくて、いつ破綻するか」のほう。ここでは通知(メール・SMS・プッシュ)を例に、その線引きまで通しで見る。

概要

  • STI の実体は type カラム 1本。Rails が保存時に自動で書き、取得時に自動で読む
  • 向いているのは「データはほぼ同じ、振る舞いだけ違う」ケース
  • 属性が種類ごとにバラバラなら STI は破綻する(NULL だらけになる)。そのときは delegated types か別テーブル

出発点 — 3種類の通知をどう置くか

通知を送る機能を作る。仕様はこう。

種類 やること
メール通知 件名と本文をメールで送る
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つ並ぶ。そしてすぐ困ることになる。

ruby
# 「このユーザーへの通知を、種類を問わず新しい順に20件」
# → 3テーブルを引いて、Ruby側でマージして、ソートし直す……

種類をまたいだ一覧・検索・ページネーションが、途端に面倒になる。


STI の答え — 表は1つ、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 のすべてと言っていい。


Rails のコード

マイグレーションで type(string)を持たせる。

ruby
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つ作って継承するだけ

ruby
# 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 の挙動は、この往復さえ掴めば全部説明がつく。

保存するとき — Rails がクラス名を書き込む

ruby
EmailNotification.create!(user: user, body: "本日のまとめです")
sql
INSERT INTO notifications (type, user_id, body, ...)
VALUES ('EmailNotification', 1, '本日のまとめです', ...)

type'EmailNotification' という文字列が自動で入る。

取り出すとき — 文字列からクラスを復元する

ruby
Notification.all
# => [#<EmailNotification id: 1>, #<SmsNotification id: 2>, #<PushNotification id: 3>]

親クラスで引いたのに、返ってくるのはそれぞれのサブクラスのインスタンス。Rails が type の文字列を constantize してクラスを決めているため。

だからループが素直に書ける。

ruby
Notification.where(sent_at: nil).find_each(&:deliver!)
# それぞれ自分の deliver! が呼ばれる(メールはメール、SMS は SMS)

ここが STI の旨味。
1回のクエリで全種類を取ってきて、種類ごとに違う処理を走らせる」が自然に書ける。テーブルを分けていると、この形は書けない。

サブクラスで引くと、勝手に絞り込まれる

ruby
EmailNotification.count
sql
SELECT COUNT(*) FROM notifications WHERE type = 'EmailNotification'

where(type: ...) を自分で書く必要はない。サブクラス経由のクエリには必ず type の条件が付く


STI で嬉しくなる場面

種類をまたいだ操作が、全部ふつうの ActiveRecord で書けるようになる。

ruby
# 種類を問わず新着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 は、種類ごとに固有の属性が増えた瞬間に崩れる。

仕様変更が来たとする。

  • メール通知には件名が要る
  • プッシュ通知にはアイコン URL遷移先 URL が要る
  • SMS には何も要らない

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 制約が要る)
  • 列が種類の数だけ増える — 種類を1つ足すたびに、他の種類にとって無意味な列が増える
  • どの列が誰のものか分からなくなる — スキーマを見ても対応関係が読めない

判断の目安。
共通の属性が大半なら STI。種類ごとの固有属性が主役なら STI ではない。
「NULL になる列が増えてきた」は、STI から乗り換える合図。


代替案の選び方

手法 テーブル構成 向いているケース
STI 1つ(type 列) 属性はほぼ共通、振る舞いだけ違う
Delegated Types 親1つ + 種類ごとの子テーブル 共通部分も固有部分も両方それなりにある
ポリモーフィック関連 完全に別テーブル同士を1つの関連で束ねる 元々別物を、同じ場所に紐付けたいだけ
テーブル分割 種類ごとに独立 共通点が薄く、まとめて扱う要件もない

Delegated Types はどう違うか

Rails 6.1 以降の delegated_type。共通部分は親テーブル、固有部分は子テーブルに置く。

ruby
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 を検討するでいい。


実務でハマるポイント

1. type にはクラス名が文字列で入っている

つまり クラス名のリネーム = データ移行

ruby
# EmailNotification → MailNotification に改名したい
Notification.where(type: "EmailNotification").update_all(type: "MailNotification")

名前空間を切ると Notifications::Email のような文字列がそのまま入るので、後から構成を変えると影響が大きい。STI のクラス名は、最初に落ち着いた名前を付ける。

2. enum と迷ったら

「種類を持つ」だけなら enum で十分で、STI は要らない。

使いどころ
enum 種類によって振る舞いは変わらない(表示ラベルや絞り込みだけ)
STI 種類ごとにメソッドの実装を変えたいdeliver! の中身が違う)

case type when ... end を書き始めたら、STI に寄せる合図。

3. インデックスに type を含める

サブクラス経由のクエリには必ず WHERE type = ? が付く。既存のインデックスの先頭に type を足しておかないと効かない。

ruby
add_index :notifications, [:type, :user_id, :created_at]

4. 列名を type 以外にしたいとき

type は Ruby の予約語感があるので嫌われがちだが、変更は可能。

ruby
class Notification < ApplicationRecord
  self.inheritance_column = :kind
end

逆に「type という列を STI と無関係に使いたい」場合は、self.inheritance_column = nil で STI を無効化する。既存 DB を扱うときにたまに必要になる。

5. サブクラスが読み込まれていないと取りこぼす

開発環境(eager load off)で Notification.subclasses が空になることがある。「サブクラス一覧を回す」ような実装は、Rails.application.eager_load! を前提にするか、そもそも定数で持つ。


まとめ

  • STI の実体は type カラム1本。保存時に Rails がクラス名を書き、取得時に文字列からクラスを復元する
  • サブクラス経由のクエリには自動で WHERE type = ? が付く
  • 向いているのは「属性はほぼ共通、振る舞いだけ違う」ケース。1クエリで全種類を取り、種類ごとの処理を走らせられるのが最大の利点
  • 破綻するのは「種類ごとの固有属性が増える」ケース。NULL だらけになり、NOT NULL 制約も付けられなくなる
  • NULL 列が増えてきたら delegated types へ。共通部分は1テーブル、固有部分は子テーブル
  • enum との使い分けは「メソッドの実装を種類ごとに変えたいか」で決める

迷ったら、テーブルを紙に書いてみるのが早い。列がスカスカにならないなら STI、なるなら分ける。

Index

  • STI(単一テーブル継承)を、3種類の通知で理解する
  • 概要
  • 出発点 — 3種類の通知をどう置くか
  • STI の答え — 表は1つ、type で区別する
  • Rails のコード
  • type は誰が書いて、誰が読むのか
  • 保存するとき — Rails がクラス名を書き込む
  • 取り出すとき — 文字列からクラスを復元する
  • サブクラスで引くと、勝手に絞り込まれる
  • STI で嬉しくなる場面
  • STI が破綻するとき
  • 代替案の選び方
  • Delegated Types はどう違うか
  • 実務でハマるポイント
  • 1. type にはクラス名が文字列で入っている
  • 2. enum と迷ったら
  • 3. インデックスに type を含める
  • 4. 列名を type 以外にしたいとき
  • 5. サブクラスが読み込まれていないと取りこぼす
  • まとめ