DEV Community

vincent
vincent

Posted on

Single Table Inheritance vs Delegated Types in Rails: What's the Deal?

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.. })
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)