RUBY ON RAILS · 21 MIN READ ·

Rails Delegated Types: Replacing Single Table Inheritance Without the Mess

Rails delegated types explained: ditch STI for clean per-type tables. Real migrations, query patterns, N+1 traps, and when STI is still the right call.

Rails Delegated Types: Replacing Single Table Inheritance Without the Mess

The model was called Notification. When I first opened the schema, the table had 52 columns. The type column had eight values in production: EmailNotification, SmsNotification, PushNotification, InAppNotification, SlackNotification, WebhookNotification, DigestNotification, and SystemNotification. Of those 52 columns, any given row used at most 14. The other 38 were NULL. The model file was 430 lines of case type branching, callbacks wrapped in if email_notification?, and scopes that broke silently whenever someone added a ninth type without finding every branch.

The team had reached for Single Table Inheritance because it is the first thing Rails suggests when you need multiple variants of a thing. STI is not wrong — it is the right choice for a narrow set of problems. But it has a well-known failure mode, and that codebase was living inside it.

After nineteen years of Rails I have watched this pattern cause real damage: bloated tables, confused queries, and models that are functionally impossible to reason about. Rails 6.1 shipped a first-class answer: Rails delegated types. It is one of the most underused features in modern Rails, and this post is the practical guide I hand teams when they hit the STI wall.

What STI Does and Where It Breaks

Single Table Inheritance stores every variant of a type in one table, with a type string column that Rails uses to decide which subclass to instantiate. The appeal is obvious: one migration, one table, and the full scope of polymorphic Rails queries just works.

The failure mode has two forms.

The first is the null column explosion. Every type-specific attribute has to live in the shared table, so columns that only apply to one or two types end up as NULL for every other type. The Postgres docs do not say this, but a wide table with many sparse columns does take real disk space in the dead-tuple heap and adds noise to EXPLAIN ANALYZE output. More practically, it confuses every developer who runs SELECT * FROM notifications and tries to understand which columns matter for which types.

The second is the model boundary collapse. STI puts every variant’s logic in subclasses that share a table, which means validations, callbacks, and scopes must constantly defend against cross-type pollution. A before_save that makes sense for EmailNotification gets inherited by SmsNotification unless you guard it. A database constraint that should apply only to WebhookNotification cannot be expressed at the database level because the column has to be nullable for every other type. You end up pushing validations that should be database constraints into Ruby, and into fragile if type == 'X' guards that break on refactors.

The rule of thumb I use: STI is the right choice when you have two or three types, the variants share 80%+ of their columns, and the per-type logic is minimal. The moment you hit four or five types with meaningful per-type data, you are heading into trouble.

Delegated Types: The Core Idea

Rails delegated types splits the representation cleanly in two:

  1. A shared table holds every attribute that is meaningful across all variants — timestamps, ownership, status, anything you query across types.
  2. A per-type table holds every attribute specific to that variant. No nulls. No column compromise. Just the columns that belong there.

The shared table has two special columns: a _type string and an _id integer. This is a polymorphic association under the hood, but Rails manages the foreign key logic and adds a clean query API on top. The “delegate” part means you declare which methods from the shared model should forward to the type-specific record.

Setting Up a Real Example: A Notifications System

Say you are building a SaaS app that sends notifications through multiple channels. All notifications share: a user, a title, a read_at timestamp, and a created_at. But each channel has its own fields:

  • EmailNotification: to_address, from_address, email_message_id, opened_at
  • SmsNotification: phone_number, twilio_message_sid
  • PushNotification: device_token, fcm_message_id, badge_count
  • InAppNotification: action_url, icon, dismissable

Here is the migration for the shared table:

class CreateNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :notifications do |t|
      t.references :user, null: false, foreign_key: true
      t.string  :notifiable_type, null: false
      t.bigint  :notifiable_id,   null: false
      t.string  :title,           null: false
      t.datetime :read_at
      t.timestamps

      t.index [:notifiable_type, :notifiable_id], unique: true
    end
  end
end

And the per-type tables:

class CreateEmailNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :email_notifications do |t|
      t.string   :to_address,       null: false
      t.string   :from_address,     null: false
      t.string   :email_message_id
      t.datetime :opened_at
      t.timestamps
    end
  end
end

class CreateSmsNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :sms_notifications do |t|
      t.string :phone_number,       null: false
      t.string :twilio_message_sid
      t.timestamps
    end
  end
end

class CreatePushNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :push_notifications do |t|
      t.string  :device_token, null: false
      t.string  :fcm_message_id
      t.integer :badge_count, default: 0, null: false
      t.timestamps
    end
  end
end

class CreateInAppNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :in_app_notifications do |t|
      t.string  :action_url
      t.string  :icon
      t.boolean :dismissable, null: false, default: true
      t.timestamps
    end
  end
end

Each per-type table has only the columns that make sense for it. phone_number is NOT NULL in sms_notifications because it always has to exist there — that constraint cannot be expressed in the STI approach without complex validations.

The Model Setup

The shared model declares delegated_type:

# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :user

  delegated_type :notifiable,
                 types: %w[EmailNotification SmsNotification PushNotification InAppNotification],
                 dependent: :destroy

  scope :unread, -> { where(read_at: nil) }
  scope :read,   -> { where.not(read_at: nil) }

  def mark_read!
    update!(read_at: Time.current)
  end
end

Each type-specific model includes a concern that Rails does not require but I always add:

# app/models/concerns/notifiable.rb
module Notifiable
  extend ActiveSupport::Concern

  included do
    has_one :notification, as: :notifiable, touch: true
  end
end

# app/models/email_notification.rb
class EmailNotification < ApplicationRecord
  include Notifiable

  validates :to_address, :from_address, presence: true
  validates :to_address, format: { with: URI::MailTo::EMAIL_REGEXP }
end

# app/models/sms_notification.rb
class SmsNotification < ApplicationRecord
  include Notifiable

  validates :phone_number, presence: true,
            format: { with: /\A\+[1-9]\d{7,14}\z/ }
end

# app/models/push_notification.rb
class PushNotification < ApplicationRecord
  include Notifiable

  validates :device_token, presence: true
end

# app/models/in_app_notification.rb
class InAppNotification < ApplicationRecord
  include Notifiable
end

The validations are clean and isolated. SmsNotification validates phone format; EmailNotification validates email format. Neither knows the other exists, and neither contaminates the shared model with type-specific guards.

Querying Delegated Types

Rails generates scope methods automatically from the types: list:

# All notifications for a user
user.notifications.unread

# Only email notifications — generated scope
Notification.email_notifications

# Only push notifications
Notification.push_notifications

# Type predicates
notif = Notification.first
notif.email_notification?   # => true or false
notif.notifiable_class      # => EmailNotification
notif.email_notification    # => #<EmailNotification ...> or nil

The scope names are the underscored model names. EmailNotification becomes email_notifications, PushNotification becomes push_notifications. Simple enough.

The part that trips up almost everyone is eager loading. Because the type-specific record is a polymorphic association, a naive loop over notifications fires one query per row:

# N+1 — do not do this
Notification.unread.each do |n|
  puts n.notifiable.to_address if n.email_notification?
end

The fix is includes:

# One query for notifications, one per type in the result set
Notification.unread.includes(:notifiable)

Rails groups the polymorphic IDs by type and issues one query per type that appears in the result set. If you have 100 unread notifications and 60 are email, 30 are in-app, and 10 are SMS, you get four queries total: one for notifications, one for email_notifications, one for in_app_notifications, one for sms_notifications. That is the behaviour you want.

For the indexed dashboard query with pagination, add the type column to a composite index:

add_index :notifications, [:user_id, :read_at, :notifiable_type]

I go into index strategy in more depth in Rails database indexing strategies — the composite index principle there applies directly.

Delegating Methods to the Type-Specific Record

One of the most useful patterns is delegating a common interface to the type record so callers never need to know which type they are dealing with:

# app/models/notification.rb
class Notification < ApplicationRecord
  belongs_to :user

  delegated_type :notifiable,
                 types: %w[EmailNotification SmsNotification PushNotification InAppNotification],
                 dependent: :destroy

  delegate :channel_identifier, to: :notifiable
  delegate :delivery_status,    to: :notifiable, allow_nil: true
end

Each type implements the interface:

class EmailNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = to_address
  def delivery_status    = email_message_id ? :delivered : :pending
end

class SmsNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = phone_number
  def delivery_status    = twilio_message_sid ? :delivered : :pending
end

class PushNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = device_token
  def delivery_status    = fcm_message_id ? :delivered : :pending
end

class InAppNotification < ApplicationRecord
  include Notifiable

  def channel_identifier = action_url || "(in-app)"
  def delivery_status    = :delivered
end

Now a notifications table renderer does not branch on type at all:

# In a view or presenter
notifications.each do |n|
  render_row(title: n.title, channel: n.channel_identifier, status: n.delivery_status)
end

This is the pattern I reach for the most. The shared model defines the contract, the type records implement it, and the caller is blissfully unaware of the variant.

Creating Records

The creation pattern is slightly more verbose than STI, which is the trade-off you accept:

# Build both objects and let the shared model manage the link
notification = Notification.create!(
  user: current_user,
  title: "Your receipt for order #1234",
  notifiable: EmailNotification.new(
    to_address: current_user.email,
    from_address: "billing@example.com"
  )
)

# Or create the type-specific record first
email_notif = EmailNotification.create!(
  to_address: params[:email],
  from_address: "noreply@example.com"
)
notification = Notification.create!(
  user: current_user,
  title: "Verify your email",
  notifiable: email_notif
)

I prefer the first form — the shared record and the type record are created in a single transaction, so you cannot end up with an orphaned email_notifications row that has no notifications parent.

Migrating From STI: The Playbook

If you have an existing STI model and want to move to delegated types, the migration has three phases. The zero-downtime constraints mean you cannot do this in one big bang — I cover the general approach in Rails zero-downtime migrations and the safety tooling in Rails Strong Migrations.

Phase 1: Add the new tables and the polymorphic columns without touching the existing STI table.

class AddDelegatedTypesToNotifications < ActiveRecord::Migration[8.0]
  def change
    add_column :notifications, :notifiable_type, :string
    add_column :notifications, :notifiable_id,   :bigint
    add_index  :notifications, [:notifiable_type, :notifiable_id], unique: true

    create_table :email_notifications do |t|
      t.string   :to_address,   null: false
      t.string   :from_address, null: false
      t.timestamps
    end

    # … other per-type tables
  end
end

Phase 2: Backfill. Run a Rake task or a one-off job that walks the existing STI rows, creates the type-specific record, and fills in the polymorphic columns:

# lib/tasks/migrate_notifications.rake
namespace :db do
  task migrate_notifications_to_delegated_types: :environment do
    Notification.where(notifiable_type: nil).find_each do |n|
      type_record = case n.type
      when "EmailNotification"
        EmailNotification.create!(
          to_address:   n.read_attribute(:to_address),
          from_address: n.read_attribute(:from_address)
        )
      when "SmsNotification"
        SmsNotification.create!(phone_number: n.read_attribute(:phone_number))
      # …
      end

      n.update_columns(
        notifiable_type: type_record.class.name,
        notifiable_id:   type_record.id
      )
    end
  end
end

Use find_each for batching and update_columns to skip callbacks and validations — you are moving data, not running business logic. See the notes on Rails callbacks for why skipping them here is the right call.

Phase 3: Flip the model and remove the old columns in a follow-up deploy once the backfill is complete and the new code has been validated in production.

class DropLegacyNotificationColumns < ActiveRecord::Migration[8.0]
  def change
    remove_column :notifications, :type
    remove_column :notifications, :to_address
    remove_column :notifications, :from_address
    remove_column :notifications, :phone_number
    # … all the STI-era columns
  end
end

Never drop columns in the same deploy as the code change. Strong Migrations will enforce this if it is configured — and it should be.

When to Keep STI

Delegated types are not always the right answer. I keep STI when:

  • There are two or three types that share virtually all their columns. ShippingAddress and BillingAddress that differ only in a type column and a default boolean are a perfect STI fit. Creating two separate tables for them is ceremony without benefit.
  • The model is small and closed. If you are confident the type list will not grow beyond three entries, STI’s simplicity wins.
  • The query pattern is almost always cross-type. Account.all and Account.where(...) that do not branch on type are perfectly efficient in STI. Delegated types always adds a JOIN when you need type-specific data.

The question I ask: “Will every type-specific attribute have to exist in the shared table, and will it be NULL for all but one type?” If the answer is yes for more than a couple of columns, delegated types is the right move.

The Performance Reality: It Is a JOIN

Reading a delegated type record always involves a JOIN or a second query. That is the cost you pay for the clean schema. In practice, it is not a problem — the queries are simple, the indexes are obvious, and a properly built index on the shared table covering [user_id, read_at] means the planner never touches the per-type tables until it has already filtered the result set to a small number of rows.

The place where performance surprises you is when you forget to includes(:notifiable) and get N+1 queries on a list page. Add a Bullet configuration or use strict_loading on the association if your team has not already adopted it:

class Notification < ApplicationRecord
  delegated_type :notifiable, types: %w[...], dependent: :destroy
end

# In config/environments/development.rb
config.active_record.strict_loading_by_default = true

With strict loading on in development, Rails raises an error the first time someone lazy-loads a delegated type association in a loop. The fix is always the same: add includes.

FAQ

What is the difference between Rails delegated types and polymorphic associations?

A polymorphic association (belongs_to :commentable, polymorphic: true) defines a relationship between models. Delegated types define a hierarchy: one “base” concept (notification, entry, payment) with multiple concrete implementations. You would use a polymorphic association when Comment can belong to either a Post or a Video — two unrelated models. You would use delegated types when Notification, EmailNotification, and SmsNotification represent the same concept at different levels of specificity.

Does Rails delegated types work with STI at the same time?

You can combine them, but you probably should not. If you find yourself reaching for both in the same hierarchy, that is usually a sign the model is doing too much. Split the concept first, then pick one inheritance strategy per hierarchy.

How do I handle forms with Rails delegated types?

Use Rails’ fields_for with the notifiable sub-record, or use a form object that wraps both the shared and type-specific attributes. The accepts_nested_attributes_for :notifiable approach works if you know the type before rendering the form; if the type is user-selected, a service object that builds both records is cleaner.

# app/forms/create_notification_form.rb
class CreateNotificationForm
  include ActiveModel::Model

  attr_accessor :user, :title, :type, :to_address, :phone_number

  def save
    type_record = case type
    when "email" then EmailNotification.new(to_address: to_address, from_address: "noreply@example.com")
    when "sms"   then SmsNotification.new(phone_number: phone_number)
    end

    Notification.create!(user: user, title: title, notifiable: type_record)
  end
end

Does DESTROY on the shared record cascade to the per-type record?

Yes, if you pass dependent: :destroy to delegated_type. This is the default I use — an orphaned email_notifications row with no corresponding notifications entry is worse than accidentally deleting too much. If you have a retention requirement for the type-specific data, use dependent: :nullify and handle the cleanup explicitly.


Dealing with a Rails codebase where STI has become the load-bearing wall nobody wants to touch? TTB Software has been untangling exactly these architectures for nineteen years. We know which migrations to run in what order, and which shortcuts will cost you on the other side.

#rails-delegated-types #rails-sti-alternative #single-table-inheritance-rails #rails-polymorphic-associations #rails-activerecord-inheritance #rails-architecture-patterns

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software