The Rails Model & Active Record
A first-principles tour of the Rails model layer — how a table becomes an object, what the callback lifecycle's _commit boundary actually guarantees, why a scope is a chainable Relation, what enum and STI generate for free — grounded line-by-line in Campfire's 44-line message.rb and the models around it. The Foundations building block that the model-layer principles (P1, P2, P7, P9) are built on.
You're building chat, and you need a Message. So you do the obvious thing: you open message.rb and you start adding. A message needs to mark people unread when it lands, so you write that. It needs to be searchable, so you bolt on a search-index update. It needs @mentions, attachments, pagination, a live broadcast. Each time a message has to do one more thing, you open the file and add a method. By message #47 the file is four hundred lines, and the honest version you'd vibe-code looks something like this:
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable # the table of contents
belongs_to :room, touch: true # association + cache wiring
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :boosts, dependent: :destroy
has_rich_text :body
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
after_create_commit -> { room.receive(self) } # the owned consequence, after-durable
scope :ordered, -> { order(:created_at) } # chainable Relations
scope :with_creator, -> { preload(creator: :avatar_attachment) }
scope :with_attachment_details, -> {
with_rich_text_body_and_embeds
with_attached_attachment
.includes(attachment_blob: :variant_records)
}
scope :with_boosts, -> { includes(boosts: :booster) }
def plain_text_body
body.to_plain_text.presence || attachment&.filename&.to_s || ""
end
def to_key
[ client_message_id ]
end
def content_type
case
when attachment? then "attachment"
when sound.present? then "sound"
else "text"
end.inquiry
end
def sound
plain_text_body.match(/\A\/play (?<name>\w+)\z/) do |match|
Sound.find_by_name match[:name]
end
end
end
It works. It also has three bugs baked in that you won't see until production, and they're all placement bugs — code that runs at the wrong moment, in the wrong layer, in the wrong shape.
This tutorial is about the mechanics that make those bugs impossible to type. A Rails model is not "a class where I put message stuff." It's a small set of precise tools — associations, the callback lifecycle, scopes, enums, STI — each of which exists to absorb one of those edge cases. Learn the tool and the placement becomes obvious.
We'll build the picture from first principles, then watch Campfire's real message.rb — 44 lines — use every one of these tools.
What a model actually is
Strip away the chat app for a second. An Active Record class is a thin claim: one row in a table is one instance of this object, and the table's columns are the object's attributes. You never write the attribute accessors — Rails reads the schema at boot and defines message.body, message.created_at, message.room_id for you. That's the first convention, and it's load-bearing for everything else: the database schema is the single source of truth for what a model has, so there is no second list of fields to keep in sync.
On top of that one-row-one-object mapping, the model layer gives you exactly five tools. Most of message.rb is just these five:
- Associations turn a foreign key into a navigable graph (
message.room,room.messages). - Validations gate the save — a record that fails them never reaches the database.
- Callbacks hook the lifecycle, letting a fact's consequences fire at a precisely chosen moment relative to the database transaction.
- Scopes are named, chainable queries that compose.
enumand STI turn a string column into a family of behavior, so a difference lives in one declaration instead of scatteredifs.
Let's take them in the order they bite you.
Associations: a foreign key, read as a sentence
belongs_to :room says "this table has a room_id column pointing at a room." In return you get message.room (follow the key) and, on the other side, room.messages (a has_many). Nothing exotic — but notice the two options Campfire hangs on these declarations, because they're where the leverage hides (message.rb:4-5):
belongs_to :room, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
touch: true means: whenever a message is saved or destroyed, bump the room's updated_at. That one token wires a cache-freshness dependency through every create and destroy path — we don't unpack why that matters here; that's see F6: Caching's job. default: -> { Current.user } means the controller never has to set the creator by hand — the association supplies it. The naive version threads creator_id: current_user.id through every call site and forgets it in the one place that matters (the webhook). Here the model owns the default, so it can't be forgotten.
This isn't a Campfire one-off: Fizzy's Card declares the byte-for-byte identical line (card.rb:8), and pushes the same trick one step further — belongs_to :account, default: -> { board.account } (card.rb:6) derives a non-user association from ambient context too. Two unrelated 37signals products reach for the same move, so it's the house style, not an accident of chat.
An association can even carry its own verbs. Look at how Room declares its memberships (room.rb:2-18):
has_many :memberships, dependent: :delete_all do
def grant_to(users)
room = proxy_association.owner
Membership.insert_all(Array(users).collect { |user| { room_id: room.id, user_id: user.id, involvement: room.default_involvement } })
end
def revise(granted: [], revoked: [])
transaction do
grant_to(granted) if granted.present?
revoke_from(revoked) if revoked.present?
end
end
end
room.memberships.grant_to(users) reads like a sentence and runs one insert_all instead of N saves. The naive version is a controller loop — users.each { room.memberships.create!(user_id: ...) } — N inserts, the involvement default hardcoded (so a direct-message room silently gets the wrong one), and revise re-implemented slightly differently in the webhook and a console script. The contrast: the collection itself owns the grammar of the relationship, so every caller speaks the same verbs.
The callback lifecycle: when, relative to the transaction
This is the tool that punishes you hardest if you reach for the wrong one, so it's worth deriving slowly.
When you save a record, Rails runs a fixed sequence of hooks, and the whole sequence is wrapped in a database transaction. The order is: validations → before_save → (before_create on a new record) → the actual INSERT/UPDATE → after_create → after_save → COMMIT → after_commit (and its specific flavors after_create_commit, after_update_commit, after_destroy_commit).
The line that splits everything is the COMMIT. Everything with a plain name — after_save, after_create — fires inside the open transaction, when the row exists provisionally but could still be rolled back. Everything with the _commit suffix fires after the transaction is durable, when the row is permanently real. Say it the way the series says it everywhere: _commit means after-durable.
Why does that distinction decide a bug? Because some consequences touch the outside world — a background job, a push notification, an email, a broadcast — and the outside world cannot be rolled back. If you fire a push from a plain after_create and then a later write in the same transaction throws, the database quietly undoes the message, but the phone already buzzed. You've notified fifty people about a message that no longer exists: the ghost row.
Here is the naive callback (the wrong tool) and Campfire's (the right one), one token apart:
# the version you'd vibe-code first:
after_create -> { room.receive(self) } # ⚠ fires INSIDE the transaction — can roll back
# Campfire, message.rb:12:
after_create_commit -> { room.receive(self) } # ✅ fires only once the row is durable
That's it — that's the whole fix, and it's one suffix.
Fizzy reaches for the exact same tool when a consequence must wait for durability: after_create_commit :watch_card_by_creator (comment.rb:18) auto-watches a card only once the comment is permanently real. Two products, same suffix on the same lifecycle line — _commit for the consequence that touches the world beyond this transaction. The deep reasoning about which consequences belong on the model at all, and at which altitude they should run, isn't ours to derive — that's see P1: The Model Owns Its Consequences and see P9: Put Work at Its Right Altitude. Here we only need the mechanic: the lifecycle is a timeline, the COMMIT is a line on it, and the suffix you choose decides which side of that line your side-effect fires on. Pick _commit for anything you can't take back.
"If
_commitis always safer, why does plainafter_createeven exist?"Because work that stays inside the database — bumping a counter, stamping a denormalized column, writing a child row in the same logical unit — should roll back with everything else if the save fails. Plain
after_savekeeps that work atomic with the row. The rule isn't "always use_commit"; it's "match the callback to whether the consequence belongs to the transaction or to the durable, outside world."
There's a second, quieter craft decision in message.rb that the lifecycle makes possible: not every consequence should be a callback at all. Broadcasting the message to live screens is handled explicitly rather than as an after_create_commit — because broadcasting is a property of how this message came to be (an interactive send vs. a seed or import), not of the message existing. We don't unpack that judgment here; the callback-vs-explicit-method line is see P1: the callback that refused to be a callback. F1's job is just to show you the lifecycle is the menu both choices are picked from.
The timing trap: compute before save, broadcast after commit
The _commit rule says fire a world-touching side-effect on the durable side of the line. But there's a subtler corollary the diagram doesn't show, and it bites the moment your side-effect needs to know what changed. Dirty flags — title_changed?, column_id_changed? — are only live during the save. By the time after_..._commit runs, the transaction is closed and Active Record has reset them; ask title_changed? there and you get false, every time. So a broadcast that wants to fire only when the visible bits moved is pinned between two moments: it must read the change before save, yet send it after commit. One callback can't be in both places.
The naive Rails fix is to do both in one durable hook — check the dirty flag inside after_update_commit and broadcast if it changed. That hook runs after the flags are already cleared, so the guard is always false and the broadcast never fires (or, if you reach for saved_change_to_title?, you've quietly coupled the broadcast to one specific attribute and will forget to add the next one). The honest fix is to split the work across the lifecycle. Fizzy's Card does exactly that with two cooperating callbacks.
The first stashes the verdict before the write, while the flags are still live (card/broadcastable.rb:1-18):
module Card::Broadcastable
extend ActiveSupport::Concern
included do
broadcasts_refreshes
before_update :remember_if_preview_changed
end
private
def remember_if_preview_changed
@preview_changed ||= title_changed? || column_id_changed? || board_id_changed?
end
def preview_changed?
@preview_changed
end
end
before_update (line 7) fires inside the transaction, before the UPDATE, so title_changed? and friends still answer truthfully. The verdict — "did anything a bystander would see on the card preview move?" — gets frozen into a plain instance variable, @preview_changed, which the closing transaction can't reset because it's just Ruby memory on the object.
The second callback opens its own paragraph because it lives on the other side of the COMMIT line and reads that stashed verdict instead of the (now-dead) flags (card/pinnable.rb:7):
module Card::Pinnable
extend ActiveSupport::Concern
included do
has_many :pins, dependent: :destroy
after_update_commit :broadcast_pin_updates, if: :preview_changed?
end
# ...pin_by / unpin_by / pinned_by? read helpers...
private
def broadcast_pin_updates
pins.find_each do |pin|
pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin"
end
end
end
after_update_commit ..., if: :preview_changed? is the durable hook from earlier in this section, now with a guard. By the time it runs the dirty flags are gone, but preview_changed? doesn't ask them — it reads the @preview_changed that before_update already decided. So the broadcast fires only when the visible bits actually moved, and only once the row is permanently real.
Notice how the two requirements pull in opposite directions, and the lifecycle resolves both. The computation must happen before save (that's the only window where the change is knowable), and the broadcast must happen after commit (so you never push a preview update for a save that gets rolled back — the same ghost-row trap, wearing a different coat). A single callback would force you to pick one and lose the other. Two callbacks, one stashing across the COMMIT line, let you have both. That's the lifecycle diagram doing real work: the before_update and after_update_commit aren't redundant hooks, they're the two ends of a measurement that has to straddle the transaction boundary.
scope: a query with a name, that composes
A scope is a class method that returns a Relation — an unexecuted query you can keep chaining — not an array. That distinction is the entire point. Look at Membership (membership.rb:14-15):
scope :visible, -> { where.not(involvement: :invisible) }
scope :unread, -> { where.not(unread_at: nil) }
Because each returns a Relation, they snap together.
Fizzy keeps the same vocabulary — Card declares plain ordering scopes like reverse_chronologically and chronologically (card.rb:22-23) — but shows the ceiling of the idea: its sorted_by scope is a scope of scopes (card.rb:41-48), a case that maps a URL token straight onto one of those named scopes, so the controller speaks intent (?sort=oldest) and the model owns the SQL. Same fundamental in two products; Fizzy just chains it one level higher. Campfire's entire "who needs an unread badge?" decision is one chain (room.rb:69):
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
Read it like a sentence: mark every visible, disconnected member who isn't the sender as unread — in one SQL UPDATE, no rows loaded into Ruby, no loop. Compare the naive instance method you'd write instead:
def unread_members
room.memberships.select { |m| m.visible? && !m.connected? && m.user != creator }
end
That loads every membership, filters in Ruby (the database's job, done slowly in app memory), and can't be reused as part of a bigger query. The contrast: a scope stays a chainable Relation, so the same visible.disconnected building blocks compose into the unread UPDATE and the push-notification query, and the database does all the work. Where the cheap-vs-slow split of that fan-out should live is see P9: Put Work at Its Right Altitude; here the lesson is purely the mechanic — scopes are query Lego, not arrays.
One scope is worth dwelling on because it shows what a Relation can express. Presence in Campfire is not an online boolean — it's a question about time, written as a scope over a TTL window (connectable.rb:4-8):
CONNECTION_TTL = 60.seconds
scope :connected, -> { where(connected_at: CONNECTION_TTL.ago..) }
scope :disconnected, -> { where(connected_at: [ nil, ...CONNECTION_TTL.ago ]) }
connected uses a beginless range (CONNECTION_TTL.ago.. — from 60 seconds ago to forever). disconnected is the elegant inverse: an array that folds two cases into one where — nil (never connected) and an endless range (...CONNECTION_TTL.ago, last seen too long ago). The naive where("connected_at < ?", 60.seconds.ago) silently drops every NULL row, so people who never connected would miss notifications. One scope, one constant, both edge cases absorbed. The worldview — presence is a question about time, flags lie — belongs to see P2: Derive, Don't Store; the scope-and-range mechanics are the F1 takeaway.
enum: one line, a whole vocabulary
A column that holds one of a fixed set of words wants to be an enum. The declaration looks modest (membership.rb:9):
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
But that one line generates a family.
For each value you get a predicate (
membership.involved_in_everything?), a bang-setter (membership.involved_in_everything!), and — the one that pays off most — a class scope (Membership.involved_in_everything). So the notification pipeline composes those generated scopes like English: relevant_subscriptions.merge(Membership.involved_in_everything). "Muted" needs no code at all — it's simply the absence of a merge: silence implemented by silence.
Two details are deliberate. .index_by(&:itself) stores the human-readable word ("everything") in the column rather than an opaque integer — a choice for a value humans read in the database.
Fizzy makes the same choice on its own state column: enum :status, %w[ drafted published ].index_by(&:itself) (card/statuses.rb:5). The string-backed form recurring across two unrelated 37signals products is the tell — it's a deliberate house default (rename-safe, reorder-safe), not one app's preference. And note that enum :role, %i[ member administrator bot ] (user/role.rb:5) uses the plain array form, which is integer-backed — Campfire picks string-backed for involvement and integer-backed for role on purpose. The naive version is three booleans (muted, mentions_only, hidden) that drift into impossible combinations, plus a hand-written everything? method and a hardcoded where(involvement: "everything") copy-pasted across three files — every member of the family written by hand, free to disagree the day someone renames a value. The contrast: the enum derives the predicate, the setter, and the scope from one declaration, so they cannot drift.
STI: three flavors, one table, zero ifs
Rooms come in three kinds — open, closed, direct — that behave almost identically. The instinct is a kind string column and if kind == "direct" branches sprinkled through the model, the controllers, and the views. Single-Table Inheritance is the tool that deletes those branches.
The mechanic: one rooms table with a type column. Rails reads type, instantiates the matching subclass, and the subclass holds only its one difference. The base Room carries everything common, including the default (room.rb:63-65):
def default_involvement
"mentions"
end
Rooms::Direct overrides that single method — its entire behavioral diff (rooms/direct.rb:19-21):
def default_involvement
"everything"
end
And Rooms::Closed is a literally empty class (rooms/closed.rb:2-3) — because "closed" is the base behavior, so there is nothing to override:
class Rooms::Closed < Room
end
The payoff shows up when a room converts type. Rooms::Open grants membership to everyone, but only at the moment it becomes open (rooms/open.rb:3-8):
after_save_commit :grant_access_to_all_users
private
def grant_access_to_all_users
memberships.grant_to(User.active) if type_previously_changed?(to: "Rooms::Open")
end
type_previously_changed?(to: "Rooms::Open") is free dirty-tracking — it fires the grant only on the save that actually flipped the type, with no manual flag and no re-grant on every later save. The contrast: the naive kind column means a ternary for default_involvement, an if kind == hunt every time you add a type, and converting closed→open means flipping the column and remembering to re-grant memberships by hand somewhere. STI puts the difference in the subclass; there is no branch to forget. The full worldview — every conditional is a polymorphism you haven't named yet — is see P7: Polymorphism Over Conditionals.
Truth without a table
A last mechanic that surprises people: a "model" need not have a table at all. It only has to quack like one. Sound is an in-memory catalog — a Struct value object and an index_by lookup — that gives controllers the familiar find_by_name interface with zero schema and zero round-trips (sound.rb:8-10, 88):
class Sound
class Image < Struct.new(:asset_path, :width, :height) # the value object
def initialize(name:, width:, height:)
super "sounds/#{name}", width, height
end
end
def self.find_by_name(name)
INDEX[name]
end
def self.names
INDEX.keys.sort
end
attr_reader :name, :asset_path, :image, :text
def initialize(name:, text: nil, image: nil)
@name = name
@asset_path = "#{name}.mp3"
if image
@image = Image.new(**image)
else
@text = text
end
end
BUILTIN = [ # the catalog (56 entries)
new(name: "56k", image: { name: "56k.webp", width: 79, height: 33 }),
new(name: "bell", text: "🔔"),
new(name: "rimshot", text: "plays a rimshot"),
# ...52 more sounds...
new(name: "yodel", text: "📣🗻🙉")
]
INDEX = BUILTIN.index_by(&:name) # the lookup find_by_name reads
end
And FirstRun borrows the resourceful create! name to orchestrate three real tables behind one call (first_run.rb:5-15):
def self.create!(user_params)
account = Account.create!(name: ACCOUNT_NAME)
room = Rooms::Open.new(name: FIRST_ROOM_NAME)
administrator = room.creator = User.new(user_params.merge(role: :administrator))
room.save!
room.memberships.grant_to administrator
administrator
end
The controller calls FirstRun.create!(user_params) and reads like any other create, while three tables get wired up behind the name. The corollary the principles track leans on: state itself can be the existence of a row — User.none? is the "fresh install" flag, and it cannot lie, because it's derived from the data, not a setup_complete boolean that drifts. That framing — the model is the truth — is see P1: The Model Owns Its Consequences; F1 just establishes the mechanic that a model is defined by behaving like one, not by having a table.
The 44-line file, read top to bottom
Now open the real message.rb and watch every tool from this tutorial show up in its place. Here is an annotated excerpt of the file — the declarations that matter for this tutorial (message.rb:1-15, 27-29):
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable # the table of contents
belongs_to :room, touch: true # association + cache wiring
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :boosts, dependent: :destroy
has_rich_text :body
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
after_create_commit -> { room.receive(self) } # the owned consequence, after-durable
scope :ordered, -> { order(:created_at) } # chainable Relations
scope :with_creator, -> { preload(creator: :avatar_attachment) }
# ...more scopes (with_attachment_details, with_boosts) and a few
# read helpers (plain_text_body, content_type, sound)...
def to_key
[ client_message_id ]
end
end
The first line is a spec — what a message can do — and each capability is a file at app/models/message/<name>.rb. That's see F7: Concerns as a Mechanism. The associations carry their options. One callback fires before the insert to stamp an id; one fires after the commit to declare the durable consequence. The scopes are query Lego. And to_key overrides ActiveModel's notion of identity itself — the keystone of optimistic chat — the mechanics are see F4: Turbo (Hotwire)'s, and the full arc is the published capstone see C1: The Line Where Saving Becomes a Product. There is no orchestration, no 400-line junk drawer, no if kind ==. Each tool sits in its place, and each place absorbs an edge case you'd otherwise hit in production.
That's the model layer. The mechanics here are the substrate for four principles: the model owning truth and its consequences (see P1: The Model Owns Its Consequences), deriving facts instead of storing them (see P2: Derive, Don't Store), pushing if type == branches into subclasses and enums (see P7: Polymorphism Over Conditionals), and choosing the altitude a consequence runs at across the _commit boundary (see P9: Put Work at Its Right Altitude). Foundations hand you the tools; the principles tell you when to reach for which.
Key Takeaways — Patterns to Steal
- Never keep a second list of your columns — no FIELDS constant, no attr_accessor for something the table already has. Active Record reads the schema at boot and hands you
message.body,message.room_id, and every other accessor for free, so the migration is the one and only list of what a model has. The moment you find yourself naming a column inside the model file, that's the smell — Campfire'smessage.rbdoesn't declare a single field. - When a value has an obvious default, put it on the association, not the call site. The naive move is to thread
creator_id: current_user.idthrough every controller and console script and webhook — and forget it in the one place that matters. Campfire writesbelongs_to :creator, class_name: "User", default: -> { Current.user }(message.rb:5), so the model owns the default and no caller can drop it. - When a relationship has its own grammar — grant, revoke, revise — teach the collection those verbs instead of looping in the controller. The naive version is
users.each { room.memberships.create!(...) }: N inserts, the involvement default hardcoded so a direct-message room silently gets the wrong one, andrevisere-implemented slightly differently in two places. Campfire opens a block onhas_many :memberships do ... end(room.rb:2-18) soroom.memberships.grant_to(users)reads like a sentence and runs oneinsert_all, and every caller speaks the same verbs. - When a callback reaches outside the database — a job, a push, an email, a broadcast — reach for
after_create_commit, not plainafter_create. The plain one fires inside the transaction, so if a later write rolls the row back you've already buzzed fifty phones about a message that no longer exists — the ghost row. That's the trap Campfire sidesteps inmessage.rb:12withafter_create_commit -> { room.receive(self) }; the fix is exactly one suffix. - A
scopeis a class method that returns an unexecuted, chainableRelation— not an array, and that distinction is the entire point. The naivedef unread_members; room.memberships.select { |m| m.visible? && !m.connected? }; endloads every row into Ruby and filters in app memory, and can't be reused inside a bigger query. Campfire'sscope :visible/scope :disconnected(membership.rb:14,connectable.rb:8) stay Relations, sovisible.disconnected.where.not(...)composes into one SQLUPDATE(room.rb:69) and the same building blocks snap into the push-notification query too. - When something is a yes/no over time — like presence — model it as a TTL-range scope, not an
onlineboolean. The naivewhere("connected_at < ?", 60.seconds.ago)silently drops everyNULLrow, so people who never connected miss their notifications. Campfire writesscope :connected, -> { where(connected_at: CONNECTION_TTL.ago..) }and the elegant inversescope :disconnected, -> { where(connected_at: [ nil, ...CONNECTION_TTL.ago ]) }(connectable.rb:7-8), folding never-connected and gone-stale into one scope and one constant. - When a column holds one of a fixed set of words, let
enumgenerate the whole family instead of hand-writing it. The naive version is three booleans that drift into impossible combinations plus a hand-rolledeverything?and awhere(involvement: "everything")copy-pasted across three files.enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in(membership.rb:9) derives the predicate, the bang-setter, and the class scope from one line, so they can't disagree — and.index_by(&:itself)stores the readable word in the column, where the integer-backed plain-array form (user/role.rb:5) would store an opaque number; choose per column. - When types behave almost identically, push the difference into an STI subclass instead of sprinkling
if kind == "direct"through model, controllers, and views. Oneroomstable with atypecolumn lets Rails instantiate the right subclass, and the subclass holds only its one diff:Rooms::Directoverrides justdefault_involvement(rooms/direct.rb:19-21), andRooms::Closedis a literally empty class (rooms/closed.rb) because "closed" is the base behavior. There's no branch left to forget when you add the next type.