Why I Stopped Using STI and Started Using Delegated Types
We hold a Ruby Campfire at the company I work for, bi-weekly. It's not a real campfire of course we work remotely! Recently, we talked about Ruby delegated types in one of those meetings. It was really interesting. I used to use STI (Single Table Inheritance) to connect multiple tables, but it turns out there is a different and more effective solution.
Background
Single-table inheritance looks simple at first. One table, one type column, done. Rails makes it easy to set up.
But then the problems start.
I've been writing Rails apps for over 14 years. I've seen the STI trap many times in my own code too. You start with two or three similar models, put them in one table, and move on. Six months later, the table has thirty columns. Most of them are NULL for any given row. The type column is the only thing keeping it together.
There's a better option. Rails has had it since 6.1. It's called delegated types.
What's Wrong With STI
STI works fine when your models are very similar. When they share most columns and only differ a little in behavior. The Rails docs say it clearly:
"STI works best when there's little divergence between the subclasses and their attributes."
A simple example: you have Message and Comment. You want to show both in a feed and paginate them together. STI lets you do that. But a Message has a subject column. A Comment doesn't. With STI, the comment row gets a subject column anyway. It's just always NULL.
Do this with enough models and enough columns, and your table becomes a mess. It's hard to query, hard to index, and hard to understand.
Polymorphic associations fix the sparse table problem, but they bring other issues: no foreign key constraints, more complex queries, and it gets confusing fast.
A Third Option
DHH added delegated types to Rails 6.1. The idea is simple: keep the shared columns in one "superclass" table. Give each subclass its own table for the columns that are specific to it.
DHH said this pattern had "the most profound impact on how we do domain modeling at Basecamp". They used it in Basecamp 3 and then in HEY.
Here's how it looks. You have an Entry model. It holds the shared stuff: creator_id, account_id, timestamps. Then Message and Comment each have their own tables with their own specific columns.
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[ Message Comment ]
delegate :title, to: :entryable
end
class Message < ApplicationRecord
def title
subject
end
end
class Comment < ApplicationRecord
def title
content.truncate(20)
end
endNow you can call entry.title on anything in the feed. Message returns its subject. Comment returns a short preview. The caller doesn't need to care which one it is.
The schema stays clean. The entries table has only shared columns. The messages and comments tables have only their own columns. No NULL columns, no if record.type == "Message" checks in your views.
How Is This Different From Polymorphic Associations?
Delegated types are built on top of polymorphic associations. DHH called it "syntactic sugar" in some early discussions. But the intent is different.
With normal polymorphic associations, you go from parent to child: fetch a Post, then get its images. With delegated types, you go the other way. You query from Entry and let it delegate to the specific type. Entry is the main entry point. Message and Comment are details.
This means you can write Entry.all and paginate across all types with no UNION queries. You can eager-load associations on Entry. You can build controllers around Entry that work for both messages and comments.
The 37signals team talked about this on their dev blog. Their version of Entry is called recordings. It stays small because it only holds foreign key references. The actual content lives in the specific tables. A small table is easy to index and easy to query. The content tables can grow on their own without making everything slow.
When to Use It
Delegated types aren't always the right choice. If your models are very similar and you don't need pagination across types, STI is fine.
But if you see yourself doing any of these things, delegated types are worth trying:
- Adding columns to an STI table that only one model uses
- Writing
if record.type == "Message"in views or controllers - Having trouble paginating across different model types
- Building a feed, timeline, or activity log with different kinds of content
The Entry/Message/Comment example in the Rails docs looks simple. But 37signals has used this pattern in production for over ten years, across two big products.
One Thing to Know
When you need to filter by a subtype-specific column, you need a join. If you want to filter by messages.subject, you join entries to messages. This is usually fine, but it's good to know before you start.
The 37signals engineers said the small size of the entries table makes this less of a problem. A small table is cheap to index.
How to Set It Up
Creating a record is simple:
Entry.create!(
entryable: Comment.new(content: "Hello!"),
creator: Current.user,
account: Current.account
)Rails gives you scopes and helpers automatically: Entry.messages, Entry.comments, entry.message?, entry.comment?. Rendering is clean too:
<%# entries/_entry.html.erb %>
<%= render "entries/entryables/#{entry.entryable_name}", entry: entry %>Each type gets its own partial. Entry doesn't need to know which one to pick.
Why It Matters
Rails is opinionated. It bets that good conventions make code easier to work with over time. Delegated types feel like one of those conventions not a new abstraction for its own sake, but a name and a structure for something developers were already doing by hand, usually in a messier way.
It came from DHH's own work at Basecamp. It ran in production. It survived scaling. It got refined over years before it became part of the framework.
If you've been using STI out of habit, it's worth reconsidering. Not every model hierarchy needs delegated types. But the ones that do will be much cleaner for it.
If you've used STI or delegated types in your own projects, I'd love to hear about it. Which one did you go with? Did delegated types make things easier, or did you run into problems I didn't mention here? And if you're still using STI is it working well, or are you starting to feel the pain? Let me know in the comments.
References
- ActiveRecord::DelegatedType — Rails API
- Add delegated type to Active Record — DHH's original PR #39341
- DHH on X — Delegated types and domain modeling at Basecamp
- The Rails Delegated Type Pattern — 37signals Dev Blog
- Delegated Types are an alternative to STI — DEV Community
- Delegated Types in Rails — Medium / NYC Ruby on Rails
- The delegated type pattern and multi-table inheritance — Mateus Guimarães
Comments ()