Back to Course

OCP Series · 5 of 5

delegated_type

Where the pattern comes from, why STI hits a wall in real apps, and how Rails 6.1's delegated_type lets Basecamp's Hey handle Messages, Documents, and Comments through a single Entry without modifying the parent.

Where this rule comes from

Many Rails apps eventually run into the same shape: a collection of "items" where each item is one of several different kinds, but they all share some common metadata. A timeline has posts, photos, links, and reposts. An inbox has messages, threads, and notifications. A document has paragraphs, headings, and embedded images. The container needs to list them all in order; the kinds need their own behavior and their own columns.

Rails has had three different answers to this problem across its history. The earliest is Single Table Inheritance (STI): put every kind in one table, with a type column saying which subclass it is. STI works at small scale; it breaks down when the subclasses have substantially different columns, because the shared table grows wide and sparse, with many columns null for most rows.

The second answer is polymorphic associations: a parent record holds a subject_type and subject_id pointing into whichever table holds the subject. This works for relationships but leaves the "common metadata" problem unsolved. The polymorphic side often needs to know about every possible subject type, and adding a new type means editing the parent code.

delegated_type was introduced in Rails 6.1 in 2020, designed by DHH for Basecamp's email product Hey. It is the third answer, and it is built specifically around the Open/Closed Principle. The shared metadata lives in a single "Entry" table. Each kind of entry lives in its own table. Adding a new entry type means adding two new files (a model and a migration) and never touching the Entry class.

The anti-pattern (STI at scale)

Picture a Hey-like app starting with STI. Messages, Documents, and Comments all live in an entries table:

# Migration
create_table :entries do |t|
  t.string  :type, null: false             # STI discriminator
  t.belongs_to :user, null: false
  t.belongs_to :account, null: false
  t.datetime :created_at, null: false

  # Message-only columns:
  t.string :subject
  t.text   :body
  t.integer :priority

  # Document-only columns:
  t.string :title
  t.text   :content
  t.integer :version

  # Comment-only columns:
  t.text :remark
  t.references :commentable, polymorphic: true
end

class Entry < ApplicationRecord; end
class Message < Entry; end
class Document < Entry; end
class Comment < Entry; end

The entries table now has ten columns, but each row only uses three or four of them. A message has subject and body, but its title, content, version, remark, and commentable_type columns are all null. The database is paying disk and indexing cost for fields that are not relevant to most rows.

Worse, adding a new entry type means editing the migration that defines the entries table, plus the Entry class itself if there is any shared logic. The "Entry" abstraction is not actually closed; every new kind opens the parent for modification. When the product team wants to add Tasks as a fourth entry type, they have to add three or four more columns to the entries table, all of which will be null for messages, documents, and comments.

STI's other failure is type-specific behavior. Validations, callbacks, and methods that apply to only one subclass have to be conditionally guarded against the type column. The Message subclass has validations that should not fire for Documents, but they live in code paths that run for all Entries unless guarded.

How delegated_type solves it

delegated_type splits the entries table into two halves. The Entry table holds only what is genuinely shared (the user, the account, the timestamps, the position in the timeline). Each kind has its own table with its own columns:

# Migrations
create_table :entries do |t|
  t.belongs_to :entryable, polymorphic: true, null: false
  t.belongs_to :user, null: false
  t.belongs_to :account, null: false
  t.timestamps
end

create_table :messages do |t|
  t.string :subject
  t.text   :body
  t.integer :priority
  t.timestamps
end

create_table :documents do |t|
  t.string :title
  t.text   :content
  t.integer :version
  t.timestamps
end

create_table :comments do |t|
  t.text :remark
  t.references :commentable, polymorphic: true
  t.timestamps
end

Each kind owns its columns. No null fields for unused attributes. The Entry table is small and dense. Each kind's table is small and dense.

The Ruby side uses the delegated_type macro from Rails 6.1:

class Entry < ApplicationRecord
  belongs_to :user
  belongs_to :account

  delegated_type :entryable, types: %w[Message Document Comment]

  # Common methods on every entry, regardless of kind:
  def title
    entryable_title || "(untitled)"
  end
end

module Entryable
  extend ActiveSupport::Concern

  included do
    has_one :entry, as: :entryable, touch: true
  end
end

class Message < ApplicationRecord
  include Entryable
  validates :subject, presence: true
  # ... message-specific code
end

class Document < ApplicationRecord
  include Entryable
  # ... document-specific code
end

class Comment < ApplicationRecord
  include Entryable
  # ... comment-specific code
end

Read what is happening:

Entry knows it has a delegate but does not know what kind. The delegated_type :entryable line tells Rails: "every Entry has a polymorphic association to an entryable object, and that object is one of Message, Document, or Comment." The macro builds query scopes (Entry.messages, Entry.documents) and predicate methods (entry.message?, entry.document?) automatically.

Each kind has its own table and its own model. Message validates its own subject. Document has its own version tracking. Comment has its own polymorphic association to the thing being commented on. Each kind's logic lives in its own file, with no conditional guards needed.

Adding a new kind is purely additive. When the product team wants Tasks as a fourth entry type:

# 1. New migration for the tasks table
create_table :tasks do |t|
  t.string :description, null: false
  t.boolean :completed, default: false
  t.datetime :due_at
  t.timestamps
end

# 2. New model that includes Entryable
class Task < ApplicationRecord
  include Entryable
  validates :description, presence: true
end

# 3. One-line addition to Entry
delegated_type :entryable, types: %w[Message Document Comment Task]
#                                                              ^^^^

The Entry table does not change. The Message, Document, and Comment models do not change. The existing tests do not change. The blast radius of the new feature is the new file and the one-word addition to the types list. The system is open to extension and closed to modification, by construction.

Why this design holds up

Four benefits, each one solving a specific STI failure mode.

Each kind owns its columns. Messages have message columns. Documents have document columns. No null padding, no sparse rows, no index entries that point nowhere. The database is doing useful work on every column it stores.

Type-specific behavior is unguarded. The Message model can have validates :subject, presence: true without a if: -> { type == "Message" } guard. The validation lives on the class that has the column, where it belongs.

The timeline queries one table. Entry.where(account: current_account).order(created_at: :desc) returns every kind of entry in chronological order. Rendering each entry's specific content is the delegated_type macro's job — render entry.entryable picks the right partial based on the kind. The list is unified; the rendering is polymorphic.

New kinds do not destabilize existing kinds. Adding Tasks does not require editing the Message model, the Document model, or the Comment model. Their tests do not run again. The change is local to the new files.

When delegated_type fits, and when it does not

delegated_type is the right tool when three conditions all hold:

  • The kinds share genuine common metadata. An Entry has a user, an account, a position in time. Without shared metadata, there is no Entry; you would only have separate models with no parent.
  • The kinds have substantially different columns. If Messages and Documents both have title and body and differ only in their type, STI is fine. delegated_type earns its weight when the column lists diverge.
  • New kinds are expected over time. If you have exactly two kinds today and there will never be more, the polymorphic infrastructure is overhead. delegated_type pays off when "we will add more kinds" is a realistic plan.

STI is not always wrong. If your subclasses have nearly identical columns and only differ in a handful of methods, STI is simpler and works fine. The rule of thumb: when you find yourself adding columns that only apply to some subclasses, switch to delegated_type. Until then, STI is the lower-overhead choice.

The principle at play

delegated_type is the Open/Closed Principle applied at the schema level. STI's failure is that the shared table grows every time a new subclass is added, which means the parent class is not actually closed. delegated_type isolates each kind in its own table, so the parent's schema is frozen once the relationship is established.

The deeper move is recognizing that "shared metadata" and "shared columns" are different things. Every entry has a user and an account; that is genuine sharing. Not every entry has a subject; that is one kind's column masquerading as shared. STI conflates the two by putting everything in one table. delegated_type separates them by design.

The pattern's pragmatic value is the same as every OCP pattern in this series: new variation arrives as a new file, not as an edit to existing files. The Entry table, the Message model, and the existing test suite all stay frozen when Tasks land. The change is purely additive, by construction.

Practice exercise

  1. Find the largest STI table in your app. Run grep -rn "self.inheritance_column\\|type, :string" db/migrate/ to find tables with a type column.
  2. For the table you find, count the columns. For each column, check how many subclasses actually use it. A column used by one subclass out of five is a signal that delegated_type would fit better.
  3. Sketch the delegated_type version. Which columns are genuinely shared (move to the parent table)? Which are kind-specific (move to a child table)?
  4. Bonus: read Basecamp's Hey codebase if you can find it referenced anywhere in the Rails 6.1 release notes. The delegated_type macro was designed specifically for their Entry/Message/Document/Comment model. Seeing the canonical use case clarifies when the pattern is the right choice.

Closing the OCP series

You have now seen five shapes of the Open/Closed Principle in Rails: registry for value-driven dispatch, adapter for config-driven backends, middleware for cross-cutting pipelines, pub-sub for fan-out reactions, and delegated_type for additive subtypes. All five share the same instinct: add new behavior by adding a file, not by editing an existing one.

Each pattern has a different boundary at which the substitution happens. The registry chooses at runtime per record. The adapter chooses at boot per environment. The middleware composes at request time per concern. The pub-sub fans out per event. delegated_type discriminates at row time per kind. The boundary matters because it determines what kinds of variation the pattern can absorb cheaply.

None of these patterns are mandatory. A small Rails app does not need any of them. The cost of an extension point is real: indirection, more files, more concepts for a new engineer to learn. The right time to introduce one is when the variation it captures is actually showing up in your codebase, not when you think it might one day. Build the registry when the second processor arrives, not in anticipation of one.

Like the SRP series, the best calibration is to read the source of apps that have lived with these patterns at scale. Mastodon, Forem, Discourse, and Gumroad all use registries. Rails core uses adapters everywhere, middleware for the request stack, and ActiveSupport::Notifications across the whole framework. The more of this code you read, the more the patterns will feel like obvious moves instead of theoretical ones.