Rails 8 STI: When and How to Use It

Single Table Inheritance (STI) is one of those Rails features that sparks strong opinions. Used correctly, it eliminates duplication and creates elegant model hierarchies. Used poorly, it leads to bloated tables and confusing code. This guide covers when STI makes sense, how to implement it properly in Rails 8, and when to reach for alternatives.

Understanding the Problem STI Solves

Consider an application managing different types of notifications: email notifications, SMS notifications, and push notifications. Each shares common attributes (recipient, message, sent_at) but has type-specific behavior and perhaps a few unique fields.

Without STI, developers face three options: create separate tables with duplicated columns, use a single table with a manual type column and scattered conditionals, or build a complex polymorphic setup. STI offers a fourth path—one table, multiple Ruby classes, with Rails handling the mapping automatically.

Basic STI Implementation

Setting up STI requires a type column in the database and a class hierarchy in Ruby. The parent class inherits from ApplicationRecord, and child classes inherit from the parent.

# db/migrate/20260110120000_create_notifications.rb
class CreateNotifications < ActiveRecord::Migration[8.0]
  def change
    create_table :notifications do |t|
      t.string :type, null: false
      t.references :user, null: false, foreign_key: true
      t.string :recipient
      t.text :message
      t.json :metadata
      t.datetime :sent_at
      t.datetime :failed_at
      t.string :failure_reason

      t.timestamps
    end

    add_index :notifications, :type
    add_index :notifications, [:user_id, :type]
    add_index :notifications, :sent_at
  end
end

The type column stores the class name as a string. Rails uses this to instantiate the correct class when loading records. Adding an index on type is essential since queries often filter by notification type.

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

  validates :recipient, presence: true
  validates :message, presence: true, length: { maximum: 1000 }

  scope :sent, -> { where.not(sent_at: nil) }
  scope :pending, -> { where(sent_at: nil, failed_at: nil) }
  scope :failed, -> { where.not(failed_at: nil) }

  def send_notification
    raise NotImplementedError, "Subclasses must implement #send_notification"
  end

  def mark_as_sent!
    update!(sent_at: Time.current)
  end

  def mark_as_failed!(reason)
    update!(failed_at: Time.current, failure_reason: reason)
  end
end

# app/models/email_notification.rb
class EmailNotification < Notification
  validates :recipient, format: { with: URI::MailTo::EMAIL_REGEXP }

  def send_notification
    NotificationMailer.notify(self).deliver_later
    mark_as_sent!
  rescue StandardError => e
    mark_as_failed!(e.message)
  end

  def subject
    metadata&.dig("subject") || "Notification"
  end
end

# app/models/sms_notification.rb
class SmsNotification < Notification
  validates :recipient, format: { with: /\A\+?[1-9]\d{1,14}\z/ }
  validates :message, length: { maximum: 160 }

  def send_notification
    SmsGateway.send(to: recipient, body: message)
    mark_as_sent!
  rescue SmsGateway::DeliveryError => e
    mark_as_failed!(e.message)
  end
end

# app/models/push_notification.rb
class PushNotification < Notification
  validates :recipient, presence: true # device token

  def send_notification
    PushService.deliver(
      token: recipient,
      title: metadata&.dig("title") || "New Notification",
      body: message
    )
    mark_as_sent!
  rescue PushService::InvalidToken
    mark_as_failed!("Invalid device token")
  end
end

Each subclass overrides send_notification with type-specific logic while inheriting common functionality. The base class defines the interface, and subclasses provide implementations.

Querying STI Models

Rails makes querying STI models intuitive. Querying the parent class returns all types; querying a subclass returns only that type.

# app/controllers/notifications_controller.rb
class NotificationsController < ApplicationController
  def index
    # Returns ALL notification types
    @notifications = current_user.notifications.recent
  end
end

# app/controllers/admin/email_notifications_controller.rb
module Admin
  class EmailNotificationsController < ApplicationController
    def index
      # Returns ONLY EmailNotification records
      # Rails automatically adds WHERE type = 'EmailNotification'
      @notifications = EmailNotification.pending.order(created_at: :desc)
    end

    def retry_failed
      EmailNotification.failed.find_each(&:send_notification)
      redirect_to admin_email_notifications_path, notice: "Retrying failed emails"
    end
  end
end

This automatic scoping works with all Active Record methods, including joins, includes, and complex queries.

Using the Metadata Column Effectively

STI tables often accumulate type-specific columns that remain null for other types. A metadata JSON column provides flexibility without table bloat.

# app/models/email_notification.rb
class EmailNotification < Notification
  # Store email-specific data in metadata
  def subject
    metadata&.dig("subject")
  end

  def subject=(value)
    self.metadata = (metadata || {}).merge("subject" => value)
  end

  def cc_addresses
    metadata&.dig("cc") || []
  end

  def cc_addresses=(addresses)
    self.metadata = (metadata || {}).merge("cc" => Array(addresses))
  end
end

# Usage in a service or controller
email = EmailNotification.new(
  user: current_user,
  recipient: "[email protected]",
  message: "Your order has shipped!"
)
email.subject = "Order Update"
email.cc_addresses = ["[email protected]"]
email.save!

This approach keeps the notifications table lean while allowing each type to store relevant data. For MySQL, the JSON column provides query capabilities when needed.

Common STI Pitfalls and Solutions

Pitfall 1: Sparse columns. Adding columns used by only one subclass leads to mostly-null data. Use the metadata JSON column for type-specific attributes, reserving regular columns for shared data.

Pitfall 2: Diverging validation logic. When subclasses have vastly different validation requirements, STI becomes unwieldy. If EmailNotification needs ten validations and SmsNotification needs two completely different ones, separate tables might be cleaner.

Pitfall 3: Changing types. STI makes it awkward to change a record's type. Updating the type column directly works but bypasses Rails:

# This works but skips validations and callbacks
notification.update_column(:type, "PushNotification")

# Safer approach: create new, destroy old
Notification.transaction do
  new_notification = PushNotification.create!(
    notification.attributes.except("id", "type", "created_at", "updated_at")
  )
  notification.destroy!
  new_notification
end

Pitfall 4: N+1 queries with associations. When subclasses have different associations, eager loading requires care:

# app/models/email_notification.rb
class EmailNotification < Notification
  has_many :attachments, foreign_key: :notification_id
end

# Loading all notifications with attachments only for emails
Notification.includes(:user).then do |notifications|
  email_ids = notifications.select { |n| n.is_a?(EmailNotification) }.map(&:id)
  attachments = Attachment.where(notification_id: email_ids).group_by(&:notification_id)
  # Manual association
end

When to Avoid STI

STI works best when subclasses share 70% or more of their attributes and behavior. Consider alternatives when:

  • Subclasses have mostly different columns—use separate tables
  • The type hierarchy exceeds two levels—complexity compounds quickly
  • Records frequently change types—polymorphic associations handle this better
  • Subclasses have different association structures—separate tables provide clarity

Polymorphic associations offer flexibility when the "types" are truly different entities. Delegated types (introduced in Rails 6.1) provide a middle ground, storing shared attributes in one table and type-specific attributes in separate tables.

Summary

Single Table Inheritance provides an elegant solution for modeling similar entities that share most attributes and behavior. Success with STI depends on keeping the hierarchy shallow, using JSON metadata for type-specific attributes, and recognizing when models have diverged enough to warrant separate tables. When the types genuinely belong together—like notification channels or payment methods—STI reduces duplication and leverages Ruby's inheritance for cleaner code.

10 claps
← Back to Blog