😺

ActiveRecordの3つのポリモーフィズム設計パターン:STI、Delegated Type、ポリモーフィック関連の違いと使い所まとめ

2024/09/05に公開

masaki です。
ポリモーフィズム(多態性)は、プログラミングにおいて異なるオブジェクトを同じインターフェースで扱うための便利な仕組みです。
Rails では、この概念が ActiveRecord に組み込まれており、複雑なデータモデルをシンプルに管理することができます。
これにより、共通の機能を持つ複数のモデルを効率的に設計でき、コードの再利用性が高まります。
ソーシャルPLUS のバックエンドでもさまざまなユースケースでポリモーフィズムのパターンを使い分けています。

今日は Rails の ActiveRecord で利用できる 3 つの主要なポリモーフィズム設計パターンである「STI(Single Table Inheritance)」、「Delegated Type」、そして「ポリモーフィック関連 (Polymorphic Associations)」について、それぞれの特徴とどんなシーンで使うとよいかについてまとめてみたいと思います。

この記事の対象者

  • Rails と ActiveRecord について知見がある
  • ActiveRecord のポリモーフィズム設計パターンの違いや使い所を知りたい

STI(Single Table Inheritance)とは?

STI はその名前のとおり「一つのテーブルを継承するパターン」で、複数の子モデルのデータを一つのテーブルで管理します。
この方法を使うと、親モデルと子モデルがすべて同じテーブルに格納され、各レコードにはどの子モデルに属するかを示す type カラムが追加されます。

メリットは複数のモデルのデータを一つのテーブルで管理できるため、モデルが増えてもテーブルが増えず、データベース構造がシンプルに保てる、ということです。
また、コード面では親モデルのバリデーションやメソッドなどのコードを子モデルで共有でき重複したコードを書く必要がないため、コードの再利用性が高くなります。

デメリットは「テーブルが肥大化する」「モデル間の違いを扱いづらい」などです。
最初は全く同じ属性を持つモデルだとしても、プロダクトが成長していくに従い、個別の属性を持たせたくなることが多いでしょう。
そうすると一方のモデルでは必要な属性でも、別のモデルでは不要な属性だったり、ということが発生します。
これが STI において最も嫌われるパターンではないでしょうか。
不要なカラムが増えると取得したオブジェクトが肥大化するなどパフォーマンスの面でもデメリットになります。

以下は STI で実装した場合のテーブル構造と Ruby コードのサンプルです。

カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
name string NOT NULL 従業員の名前
type string NOT NULL STI を実現するためのカラム (Manager または Developer)
programming_language string - Developer 専用のカラム(Managerには NULL)
budget_approval_limit int - Manager 専用のカラム(Developerには NULL)
class Worker < ApplicationRecord
  # Worker としての必須メソッド
  def work_hard!
    raise RuntimeError
  end
end

class Manager < Worker
  def work_hard!
    # Manager だけが使用する budget_approval_limit カラムを用いたメソッド
  end
end

class Developer < Worker
  def work_hard!
    # Developer だけが使用する programming_language カラムを用いたメソッド
  end
end

Worker.first.work_hard!

テーブル上は programming_languagebudget_approval_limitのカラムが全てのレコードに存在しますが、コード上では、ManagerDeveloper が必要なカラムのみを使用しています。
このように子モデルで特有のデータが増えていくに従い不要なカラムが生まれ、テーブルが肥大化するというデメリットがあります。

以上のことから STI に向いているのは「モデル間の属性の違いが無い、または極めて少ない」かつ「将来的に属性を変更するケースが少ない」といったケースが向いているのではないかと思います。

Delegated Type とは?

Delegated Type は似たモデルで一部だけ異なる属性を持たせたいといった場合に実装される設計パターンで、Rails 6.1 から導入されました。
一つのテーブルで異なるモデルのデータを共有する STI と違い、共通データを持つテーブルと個別のデータを持つテーブルでデータを管理できることがポイントです。

メリットはデータが個別のテーブルに分かれているため、整合性が保たれやすいことです。
STI のように不要なカラムを追加する必要はありません。

デメリットは各タイプごとに個別のテーブルを用意する必要があるため、データベース設計が複雑になることです。
データが複数のテーブルに分かれるため、複数のモデルにまたがるクエリが必要な場合、処理がやや複雑になる可能性があります。
また、DB のデータ構造の仕組み上、共通データを持つテーブル(workers)に個別データを持つテーブル(managers developers)への外部キー制約を付与できないため、関連データの整合性について気をつける必要があります。

先ほど STI で実装した設計を Delegated Type に置き換えてみましょう。

  • workers テーブル
カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
name string NOT NULL 従業員の名前
workerable_type string NOT NULL Manager または Developer を示すカラム
workerable_id int NOT NULL managers または developers テーブルの外部キー
  • managers テーブル
カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
budget_approval_limit int NOT NULL Manager の予算承認限度額
  • developers テーブル
カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
programming_language string NOT NULL Developer のプログラミング言語
class Worker < ApplicationRecord
  delegated_type :workerable, types: %w[Manager Developer]
  delegate :work_hard!, to: :workerable
end

module Workerable
  extend ActiveSupport::Concern

  included do
    has_one :worker, as: :workerable
  end

  def work_hard!
    raise RuntimeError
  end
end

class Manager < ApplicationRecord
  include Workerable

  def work_hard!
    # Manager だけが使用する budget_approval_limit カラムを用いたメソッド
  end
end

class Developer < ApplicationRecord
  include Workerable

  def work_hard!
    # Developer だけが使用する programming_language カラムを用いたメソッド
  end
end

Worker.first.work_hard!

この構造では、workers テーブルは workerable_typeworkerable_id を使って、managersdevelopers テーブルと関連付けられます。
それぞれの特有のデータは、個別のテーブル(managersdevelopers)に保存され、Workerの共通データはworkersテーブルに保存されます。
また、Workerable なモデルの実装を delegateWorker に移譲させることで、STI と同じように #work_hard! という振る舞いを持たせることが可能です。

以上のことから「STI の代替として共通の属性以外に個別の属性がある」といったケースが Delegated Type に向いていると思います。
STI で実装後、「継承先のモデルごとに個別の属性が必要となる」というケースが発生したときに最も検討すべき設計パターンだと思います。
ソーシャルPLUS でも、最初は STI で実装し、途中で Delegated Type に移行したケースがありました。
移行した話などは別のブログで紹介するかもしれません。

ポリモーフィック関連 (Polymorphic Associations) とは?

ポリモーフィック関連 (Polymorphic Associations) は、Rails の ActiveRecord で提供されるもう一つのポリモーフィズムの方法です。
ポリモーフィック関連を使うと、複数の異なるモデルが同じ関連を持つことができるため、同じ機能を複数のモデルに簡単に追加できます。
たとえば、Memoモデルがあり、PostPhotoに対してメモを追加できるようにしたい場合、ポリモーフィック関連を使うことで、Memoモデルがどのモデルにも関連付けられるようになります。

メリットは一つのモデルが複数の異なるモデルに関連付けられるため、柔軟なデータ構造が可能になることです。
たとえばコメントやタグ付けなど、共通の機能を複数のモデルに適用するのに便利です。

デメリットは Delegated Type と同じく複数のモデルにまたがるクエリが必要な場合、処理がやや複雑になる可能性などがあります。
また、Delegated Type と同じく仕組み上、外部キー制約を付与することはできません。

以下は、Memoモデルがポリモーフィック関連を使用して、PostPhotoに関連付けられている例です。

  • posts テーブル
カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
title string NOT NULL タイトル
content string NOT NULL コンテンツ
  • photos テーブル
カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
title string NOT NULL タイトル
url string NOT NULL 写真の URL
  • memos テーブル
カラム名 データ型 制約 説明
id int PRIMARY KEY ユニークな識別子
body text NOT NULL コメント本文
memoable_id int NOT NULL 関連する Post または Photo の ID
memoable_type string NOT NULL 関連するモデル名(Post または Photo
class Memo < ApplicationRecord
  belongs_to :memoable, polymorphic: true
end

class Post < ApplicationRecord
  has_one :memo, as: :memoable
end

class Photo < ApplicationRecord
  has_one :memo, as: :memoable
end

ポリモーフィック関連と Delegated Type の違いと使い所

ポリモーフィック関連と Delegated Type は、DB のデータ構造は同じです。
また、Delegated Type の以下のコードは、

  delegated_type :workerable, types: %w[Manager Developer]

以下のようにポリモーフィック関連で定義することとほぼ同じです。

  belongs_to :workerable, polymorphic: true

では何が異なるのでしょうか?
以下の Delegated Type に追加された機能がそのヒントになると思っています。

Worker.managers
Worker.developers
class Worker < ApplicationRecord
  delegated_type :workerable, types: %w[Manager Developer]
  accepts_nested_attributes_for :workerable
end

※ ポリモーフィック関連では belongs_to, polymorphic: true 側(前述のとおり Delegated Type の delegated_type :workerable 側と同義)からの accepts_nested_attributes_for はサポートされていません。

これらはいずれも Worker 側から ManagerDeveloper を操作する機能です。
つまり、Worker を親モデル、ManagerDeveloper を子モデルとして扱う親子関係で使うことが想定された機能です。
通常、子は親なしでは存在できませんから、ManagerDeveloperWorker とセットになって機能が完結する、ということになります。
DB 構造や relationbelongs_to has_one)の関係だけで見ると、ManagerDeveloper が親、Worker が子ですが、Delegated Type によって逆転させています。

※ 弊社佐藤が執筆した、親子関係を逆転させていることによりバリデーションに工夫が必要だった、という記事もぜひご覧ください。

https://zenn.dev/socialplus/articles/317d29c9d46da1

Delegated Type の公式ドキュメントでも、STI(継承)の代替として Delegated Type(委譲)が紹介されている理由にも納得できます。
Delegated Type は親子セットでその機能が完結する、という性質から依存関係が強い実装になりやすいですが、ポリモーフィック関連で親子の依存関係が強い実装をしてしまうとバッドプラクティスになりやすいです。

以上のことから「Delegated Type」は親子関係のセットとして実装するような依存関係が強いパターンに向いており、「ポリモーフィック関連」は共有モデルが個別モデルを補助するような依存関係が少ないパターンに向いているのではないかと思われます。

まとめ

それぞれのパターンの比較と使い所のまとめです。

項目 STI Delegated Type ポリモーフィック関連
テーブル構造 1 つのテーブルに全てのデータを格納 共通テーブルと個別テーブルに分ける 共通テーブルと個別テーブルに分ける
メリット テーブルが 1 つでシンプル、コードの再利用性が高い 共通データと個別データが分離、データの整合性が高い 1 つのモデルで複数モデルと関連可能、柔軟な構造
デメリット テーブルが肥大化し、不要なカラムが増える データベース設計が複雑、複数テーブルを跨ぐクエリが必要 クエリが複雑になる、外部キー制約がない
  • STI
    • 属性がほぼ同じモデルを 1 つのテーブルで管理したい
    • 再利用性を高くし、シンプルにしたい
  • Delegated Type
    • 共通データを管理しつつ、個別モデルごとに異なるデータを管理したい
    • STI の代替として依存関係の強い親子関係のセットで実装したい
  • ポリモーフィック関連
    • 複数の異なるモデルに共通機能を提供したい
    • 共有モデルが個別モデルを補助するような依存関係が少ない実装をしたい

ポリモーフィズムの各設計パターンには、それぞれ利点と欠点があります。
最終的には、プロジェクトの具体的なニーズに応じて、最適なパターンを選択することが重要です。
これらのパターンを理解し、適切に使いこなすことで、Rails アプリケーションの設計がより柔軟で強力なものになります。
今後のプロジェクトでこれらの知識を活用し、効果的なデータベース設計を実現してください。

SocialPLUS Tech Blog

Discussion