Introduction
Delegated types, introduced in Rails 6.1, offer a cleaner alternative to Single Table Inheritance when your types have very different attributes. Instead of one table with many nullable columns, each type gets its own table while sharing a common interface.
Key Concepts
- Delegated type: A pattern where a base model delegates type-specific behavior to separate models, each with their own table.
delegated_type: The Rails macro that sets up the delegation, combining polymorphism with a cleaner API.- Entryable: A conventional name for the polymorphic interface. Any name works.
Real World Context
Consider a messaging system with text messages, images, and videos. With STI, all types share one table — but images need width, height, and url, while text messages just need body. Delegated types give each type its own table while the parent provides the shared interface.
Deep Dive
The Problem with STI for Divergent Types
ruby# STI approach — one table with many NULLs class Message < ApplicationRecord; end class TextMessage < Message; end # only uses body class ImageMessage < Message; end # only uses image_url, width, height
Each row has many NULL columns for attributes it does not use.
Delegated Types Solution
rubyclass Message < ApplicationRecord delegated_type :entryable, types: %w[TextEntry ImageEntry VideoEntry] end class TextEntry < ApplicationRecord has_one :message, as: :entryable, touch: true end class ImageEntry < ApplicationRecord has_one :message, as: :entryable, touch: true end
Each type gets its own table with only the columns it needs. The messages table holds shared attributes plus entryable_type and entryable_id.
Using Delegated Types
rubymessage = Message.create!(entryable: TextEntry.new(body: "Hello!")) message.text_entry? # => true message.image_entry? # => false message.entryable # => #<TextEntry body: "Hello!"> Message.text_entries # scope for all text messages
The delegated_type macro generates type-checking predicates, type-safe accessors, and scopes for each type.
Migration
rubyclass CreateMessages < ActiveRecord::Migration[8.1] def change create_table :messages do |t| t.references :entryable, polymorphic: true, null: false t.references :sender, null: false, foreign_key: { to_table: :users } t.timestamps end create_table :text_entries do |t| t.text :body, null: false t.timestamps end end end
Common Pitfalls
- Using delegated types when STI suffices: If all types share the same columns, STI is simpler.
- Forgetting the polymorphic columns: The parent needs
entryable_typeandentryable_id. - Not indexing the polymorphic columns: Always add an index on
[entryable_type, entryable_id].
Best Practices
- Use delegated types when types have significantly different attributes.
- Keep shared attributes on the parent and type-specific attributes on the delegate.
- Use the generated scopes (
Message.text_entries) for type-filtered queries.
Summary
- Delegated types split type-specific attributes into separate tables while maintaining a shared parent.
- Use
delegated_type :entryable, types: [...]on the parent model. - Each delegate model has
has_one :parent, as: :entryable. - Prefer delegated types over STI when types have very different attributes.
Code Examples
class Message < ApplicationRecord
delegated_type :entryable, types: %w[TextEntry ImageEntry VideoEntry]
end
class TextEntry < ApplicationRecord
has_one :message, as: :entryable, touch: true
end
message = Message.create!(entryable: TextEntry.new(body: "Hello!"))
message.text_entry? # => true
message.entryable # => #<TextEntry body: "Hello!">
Message.text_entries # Scope for all text messages