docs/development/concepts/has-details-table/README.md
The HasDetailsTable concern (app/models/concerns/has_details_table.rb) gives any ActiveRecord model a companion
"detail" table — a 1:1 side table whose columns are transparently delegated back to the owner. From the outside the
model behaves as if the extra columns lived on its own table.
HasDetailsTable …
ApplicationRecord subclass and registers it as <Model>Detail (e.g. GroupDetail).has_one / belongs_to pair with autosave, dependent: :destroy, and a uniqueness constraint.id, timestamps, and the FK) as both readers and writers on the owner.belongs_to associations declared in the block so you can access them directly on the owner (e.g. group.parent).after_initialize for new records, so detail is never nil.with_detail and where_detail scopes for eager-loading and filtering.dup'd.Use HasDetailsTable when you want to extend a model with additional columns without adding them to the model's
main table. Typical reasons:
NULL for every other subclass.Rails provides delegated_type for a similar-sounding
problem: moving type-specific columns out of a shared table. However, it solves a different shape of problem and doesn't
fit the HasDetailsTable use case:
Entry can be a Message or a Comment). HasDetailsTable is a fixed 1:1 extension — every Group always has exactly one GroupDetail.entry.entryable.body). HasDetailsTable transparently delegates every column so the detail table is invisible to callers (group.organizational_unit).DelegatedType is intentionally minimal. HasDetailsTable handles the boilerplate that would otherwise be needed: auto-building on initialize, promoting validation errors, duplicating the detail on dup, and providing query scopes.DelegatedType stores a type/id pair on the base table. For STI models like Group (which already has a type column on users), introducing a second polymorphic type column would be confusing and semantically wrong — the detail isn't a different type of principal, it's additional data for a specific subclass.In short: use DelegatedType when a base model can delegate to one of many interchangeable types. Use HasDetailsTable when a single model needs extra columns in a side table with full transparent access.
Include the concern and call has_details_table in your model:
class Widget < ApplicationRecord
include HasDetailsTable
has_details_table do
# Anything here is evaluated inside the generated WidgetDetail class.
# You can add validations, callbacks, or belongs_to associations.
end
end
This generates a WidgetDetail class backed by the widget_details table. Every column in that table (except id,
widget_id, created_at, updated_at) is delegated to Widget, so you can read and write them directly:
widget = Widget.new(some_detail_column: "value")
widget.some_detail_column # => "value"
widget.detail # => #<WidgetDetail ...>
| Feature | Details |
|---|---|
| Detail class | <Model>Detail constant, subclass of ApplicationRecord |
| Association | has_one :<model>_detail on owner, belongs_to :<model> on detail |
| Aliases | detail / detail= / build_detail point to the association |
| Column delegation | Readers delegated via delegate, writers via custom methods that auto-build the detail |
| Association delegation | belongs_to associations declared in the block are delegated (both object and _id) |
| Auto-build | after_initialize builds the detail for new records |
| Error promotion | Detail validation errors are copied onto the owner |
| Dup support | dup on the owner also duplicates the detail |
with_detail scope | joins + includes for eager loading |
where_detail scope | joins + where for filtering by detail columns |
| Nested attributes | accepts_nested_attributes_for is called automatically |
When the model uses STI, the FK column won't match the model name. For example, Group inherits from Principal
(stored in the users table), so the FK is principal_id, not group_id:
class Group < Principal
include HasDetailsTable
has_details_table(foreign_key: :principal_id) do
belongs_to :parent, class_name: "Group", optional: true
validates :parent, presence: true, if: -> { parent_id.present? }
end
end
The corresponding group_details table uses principal_id as its FK column:
create_table :group_details do |t|
t.references :principal, null: false,
foreign_key: { to_table: :users },
index: { unique: true }
t.boolean :organizational_unit, default: false, null: false
t.references :parent, foreign_key: { to_table: :users }
t.timestamps
end
The detail table must follow these conventions:
| Convention | Example (Widget) |
|---|---|
| Table name | widget_details |
| FK column | widget_id (or custom, e.g. principal_id) |
| Required columns | FK (non-null), created_at, updated_at |
| Unique index | On the FK column (enforces 1:1) |
The concern reads the detail table's columns at load time to set up delegation, so the migration must run before the model is loaded. In practice this means the migration should exist before or alongside the code change — standard Rails migration ordering.
Declare belongs_to associations inside the block. They are evaluated on the detail class, but delegated to the owner:
has_details_table do
belongs_to :parent, class_name: "Group", optional: true
end
This lets you write:
group.parent # delegated to group.detail.parent
group.parent = other # delegated, auto-builds detail if needed
group.parent_id # delegated via column delegation
group.parent_id = 42 # delegated via column writer
The back-reference from the details table to the owner (belongs_to :group / belongs_to :principal) is set up automatically — don't declare it yourself.
has_details_table runs and the table doesn't exist yet, column delegation is deferred to after_initialize. This works at runtime but means delegation won't be available at class-load time during db:migrate.build_detail if detail is nil. This means assign_attributes works correctly even before after_initialize fires (e.g. in Model.new(attrs)).:parent, the owner will have an error on :parent.