So you're building a Rails app and you've got some models that are related but not quite the same. Maybe you have different types of users, or various kinds of content, or whatever. Rails gives you a couple of ways to handle this, and honestly, it can be pretty confusing to figure out which one to use.
Let's break down Single Table Inheritance (STI) and Delegated Types - what they are, when they're awesome, and when they'll make you want to pull your hair out.
Single Table Inheritance: The "Throw Everything in One Bucket" Approach
STI is like that one drawer in your kitchen where you put all the random stuff. It works, but it gets messy fast.
Here's the basic idea: you have one database table that stores different types of related objects. Rails figures out what type each record is using a type
column.
Let's See It in Action
# Your base class
class Vehicle < ApplicationRecord
end
# Your specific types
class Car < Vehicle
end
class Motorcycle < Vehicle
end
class Truck < Vehicle
end
And your database table looks something like this:
# Migration time!
class CreateVehicles < ActiveRecord::Migration[7.0]
def change
create_table :vehicles do |t|
t.string :type, null: false # This is the magic column
t.string :make
t.string :model
t.integer :year
t.integer :doors # Only cars need this
t.integer :cargo_capacity # Only trucks care about this
t.boolean :has_sidecar # Motorcycles only
t.timestamps
end
add_index :vehicles, :type # Don't forget this!
end
end
When you create records, Rails handles the type stuff automatically:
car = Car.create(make: "Toyota", model: "Camry", doors: 4)
# Rails sets type: "Car" behind the scenes
Car.all # Only gets cars
Vehicle.all # Gets everything
Pretty neat, right?
Why STI Can Be Great
It's Simple: One table, straightforward queries. Your brain doesn't have to work too hard.
It's Fast: No joins needed most of the time. Your database is happy.
Rails Loves It: Everything just works the way you'd expect. Associations, validations, callbacks - they all play nice.
class Vehicle < ApplicationRecord
validates :make, :model, :year, presence: true
scope :recent, -> { where(year: 2020..) }
end
class Car < Vehicle
validates :doors, presence: true, inclusion: { in: 2..5 }
end
# This stuff just works
recent_cars = Car.recent
all_recent_vehicles = Vehicle.recent
But STI Can Also Drive You Crazy
Your Table Gets Weird: As you add more types, you end up with tons of columns that are NULL
for most records. It's like having a form where most fields don't apply to you.
Database Constraints Are a Pain: Since columns need to be nullable for some types, you can't enforce much at the database level. Your data integrity depends entirely on your Rails code not screwing up.
Adding New Types Sucks: Want a new vehicle type? Hope you enjoy writing migrations to add columns that 90% of your existing records won't use.
Everything's Coupled: All your types are stuck with the same table structure. If cars need to track something completely different from motorcycles, tough luck.
Delegated Types: The "Each Thing Gets Its Own Space" Approach
Delegated Types are the Marie Kondo of Rails patterns. Everything gets its own place, and it's all very organized. They showed up in Rails 6.1, so they're the new kid on the block.
Instead of cramming everything into one table, each type gets its own table, and they're connected through what's basically a fancy polymorphic relationship.
Here's How It Works
# Your "interface" class
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Comment]
end
# Your actual implementation classes
class Message < ApplicationRecord
include Entry::Entryable
end
class Comment < ApplicationRecord
include Entry::Entryable
end
And now you've got multiple tables:
# The main entries table
class CreateEntries < ActiveRecord::Migration[7.0]
def change
create_table :entries do |t|
t.string :entryable_type, null: false
t.bigint :entryable_id, null: false
t.string :title # Shared stuff goes here
t.datetime :published_at
t.timestamps
end
add_index :entries, [:entryable_type, :entryable_id], unique: true
end
end
# Messages get their own table
class CreateMessages < ActiveRecord::Migration[7.0]
def change
create_table :messages do |t|
t.text :body
t.string :recipient
t.timestamps
end
end
end
# Comments get their own table too
class CreateComments < ActiveRecord::Migration[7.0]
def change
create_table :comments do |t|
t.text :content
t.references :post, null: false, foreign_key: true
t.integer :rating
t.timestamps
end
end
end
Working with it looks like this:
# Creating stuff
message = Message.create(body: "Hello world", recipient: "user@example.com")
entry = Entry.create(entryable: message, title: "Welcome Message")
# Getting the actual object
entry.entryable # Returns the Message
entry.message # Same thing, but more convenient
entry.message? # true
# Querying by type
Entry.messages # Just the message entries
Entry.comments # Just the comment entries
Why Delegated Types Rock
Clean Tables: Each type only has the columns it actually needs. No weird NULL
columns everywhere.
Flexibility: Want to add something to messages but not comments? Go for it. Each type can evolve on its own.
Database Integrity: You can set up proper foreign keys and constraints on each table. Your database can actually help keep your data clean.
Type Safety: Rails gives you nice convenience methods, and it's clear which types are allowed.
Easier Testing: You can test each type independently without worrying about irrelevant columns.
But Delegated Types Can Be a Pain Too
More Complex: Multiple tables, polymorphic relationships - there's more moving parts to keep track of.
Slower Queries: Want to do something across all types? You're probably looking at joins, which can be slower than STI's single-table approach.
Rails Conventions Get Weird: Some things that "just work" with STI require more thought with delegated types.
Migration Overhead: Changes to shared behavior might require touching multiple tables.
So When Should You Use What?
Go With STI When...
Your Types Are Pretty Similar: Like different kinds of users where the main difference is permissions and maybe a few extra fields.
You Query Across Types A Lot: If you're constantly doing things like "show me all the things from this week," STI will be faster.
Performance Is Everything: You need the absolute fastest queries and can live with some table messiness.
It's Not Going to Get Complicated: Your inheritance hierarchy is pretty stable and not likely to grow into a monster.
Here's a good STI example:
class User < ApplicationRecord
# All users have name, email, etc.
end
class AdminUser < User
# Just adds some admin methods and maybe a couple fields
end
class ModeratorUser < User
# Similar deal - mostly the same as regular users
end
Go With Delegated Types When...
Your Types Are Really Different: Like different kinds of notifications where email needs subject/body/headers but push notifications need badge counts and sounds.
Data Integrity Matters: You want the database to help enforce rules, not just rely on Rails code.
Types Need to Evolve Separately: Email notifications might grow a bunch of email-specific features that have nothing to do with SMS notifications.
Each Type Has Complex Logic: When each type has its own substantial set of validations, associations, and behavior.
Here's where delegated types shine:
class Notification < ApplicationRecord
delegated_type :notifiable, types: %w[EmailNotification PushNotification SMSNotification]
end
class EmailNotification < ApplicationRecord
include Notification::Notifiable
# Tons of email stuff: subject, html_body, text_body,
# sender info, bounce handling, etc.
end
class PushNotification < ApplicationRecord
include Notification::Notifiable
# Push-specific stuff: badges, sounds, deep links, etc.
end
class SMSNotification < ApplicationRecord
include Notification::Notifiable
# SMS stuff: phone numbers, character limits, etc.
end
Performance: The Real Talk
STI Performance
STI is usually faster for basic stuff:
# These are fast with STI
Vehicle.count
Car.where(year: 2020..).count
Vehicle.where("created_at > ?", 1.week.ago)
But it can get slow when your table gets really wide or you have a lot of sparse data.
Delegated Types Performance
Delegated types require more complex queries:
# This needs a JOIN
Entry.includes(:entryable).where(entryable_type: 'Message')
# But this can be optimized with better indexes
Message.joins(:entry).where(entries: { published_at: 1.week.ago.. })
The trade-off usually works out in favor of delegated types when you're doing a lot of type-specific queries or when you can set up really good indexes on the individual tables.
What If You Need to Switch?
Sometimes you start with one approach and realize you need the other. It's not fun, but it's doable.
STI to Delegated Types
This usually happens when your STI table gets too messy:
class MigrateToDelgatedTypes < ActiveRecord::Migration[7.0]
def up
# Create tables for each type
create_table :cars do |t|
t.integer :doors
t.timestamps
end
# Move the data over
Vehicle.where(type: 'Car').find_each do |vehicle|
car = Car.create!(doors: vehicle.doors)
vehicle.update!(
vehicleable_type: 'Car',
vehicleable_id: car.id
)
end
# Clean up the old structure
remove_column :vehicles, :type
remove_column :vehicles, :doors
add_column :vehicles, :vehicleable_type, :string, null: false
add_column :vehicles, :vehicleable_id, :bigint, null: false
end
end
Delegated Types to STI
This is less common but happens when you realize the complexity isn't worth it:
class MigrateToSTI < ActiveRecord::Migration[7.0]
def up
# Add all the columns you need
add_column :entries, :type, :string
add_column :entries, :body, :text # From messages
add_column :entries, :recipient, :string # From messages
add_column :entries, :content, :text # From comments
# etc...
# Move data into the main table
Entry.includes(:entryable).find_each do |entry|
case entry.entryable
when Message
entry.update!(
type: 'MessageEntry',
body: entry.entryable.body,
recipient: entry.entryable.recipient
)
# handle other types...
end
end
# Drop the old polymorphic stuff
remove_column :entries, :entryable_type
remove_column :entries, :entryable_id
end
end
Testing: Keep Your Sanity
Testing STI
Pretty straightforward - test your base class and each subclass:
# Test the shared behavior
RSpec.describe Vehicle do
it "validates required fields" do
vehicle = Vehicle.new
expect(vehicle).not_to be_valid
end
end
# Test type-specific stuff
RSpec.describe Car do
it "validates doors" do
car = Car.new(make: "Toyota", model: "Camry", year: 2020)
expect(car).not_to be_valid
expect(car.errors[:doors]).to include("can't be blank")
end
it "inherits parent scopes" do
expect(Car).to respond_to(:recent)
end
end
Testing Delegated Types
A bit more complex since you've got the interface and the implementations:
# Test the interface
RSpec.describe Entry do
it "works with messages" do
message = Message.create!(body: "Test", recipient: "test@example.com")
entry = Entry.create!(entryable: message, title: "Test Entry")
expect(entry.message?).to be true
expect(entry.entryable).to eq(message)
end
end
# Test the implementations
RSpec.describe Message do
it "can be used as an entryable" do
message = Message.create!(body: "Test", recipient: "test@example.com")
entry = Entry.create!(entryable: message, title: "Test Entry")
expect(message.entry).to eq(entry)
end
end
The Bottom Line
Look, both patterns are useful, and Rails wouldn't include them if they weren't solving real problems. The key is understanding what problem you're actually trying to solve.
If your types are pretty similar and you want simple, fast queries, STI is probably your friend. If your types are really different and you want clean, flexible schemas, delegated types are worth the extra complexity.
And honestly? Don't stress too much about making the "perfect" choice upfront. Start with what makes sense now, and if it stops working for you, you can always refactor later. That's what migrations are for, right?
The most important thing is to pick something and ship your app. You can always optimize later when you have real data and real usage patterns to guide your decisions.
Top comments (0)