The Model Is the Truth — and It Owns Its Consequences
The first principle of the Rails ethos: a record is the single source of truth about a fact, and the effects of that fact coming into being belong to the model — except when they belong to the call path instead. Derives "fat model, skinny controller" from scratch as a forced consequence of one question (whose fact is this?), shows truth without a table (PORO models, state-by-row-existence), and grounds it in Campfire's message.rb:11-12.
The principle: A record is the single source of truth about a fact, and the effects of that fact's coming-into-being belong to the model, not to whoever happened to create it — except when the effect belongs to the call path instead, which is the line you must learn to draw.
① First principles: where does a consequence live?
A message gets created. Three things must now happen: everyone else in the room gets marked unread, the offline ones get a push notification, and every live screen gets the new bubble appended. The question this whole tutorial answers is deceptively small: where does that code go?
Here's the version you'd vibe-code first, because the request lands in the controller and that feels like where the work goes:
class MessagesController < ApplicationController
def create
@message = @room.messages.create!(message_params)
@room.memberships.where.not(user: Current.user).each do |membership|
membership.update!(unread_at: Time.current) # mark unread, one by one
PushNotifier.deliver(membership.user, @message) # ...block on each push
end
ActionCable.server.broadcast "room_#{@room.id}", render_message_html(@message)
end
end
This isn't a strawman — it's the honest shape of the first thing you'd build.
The fix you reach for next is a callback — it feels cleaner to move the consequence onto the model:
class Message < ApplicationRecord
after_create :notify_room # now EVERY create marks unread, no matter the call site
private
def notify_room
room.send_push_notifications(self)
end
end
And that's the right instinct — mostly. But notice you've now bound two different kinds of consequence to the same hook, and they don't actually belong together. Pull them apart and the whole principle falls out.
Ask one question of each consequence: whose fact is this?
Marking people unread is true for every message that exists, regardless of how it was born. A bot's message, an imported message, a seeded message — if it's in the room, the people who weren't looking are unread. That fact is a property of the record existing. It belongs on the model, as a callback.
Broadcasting a live bubble to open browsers is not true for every message that exists. A seed shouldn't shove fifty messages into a developer's empty browser. An import shouldn't replay three years of history onto live screens. Broadcasting is true only because of how this particular record came into being — an interactive send, right now, with people watching. That fact is a property of the call path. It belongs at the call site, as an explicit method.
"Why not just put both on the callback and add an
attr_accessor :skip_broadcastflag for seeds?" Because the moment you writeskip_broadcast, you've admitted the broadcast isn't a property of the record — you're pushing a call-site decision down into model state and then reading it back out. The flag is the smell. If a consequence needs to know who called, it doesn't belong to the record; it belongs to the caller.
So the principle isn't "fat model, skinny controller" as a slogan you obey. It's the forced consequence of asking whose fact each line is. Consequences that belong to the record's existence become callbacks; the controller doesn't orchestrate them because it isn't where they're true. Consequences that belong to the call path stay explicit at the call site. Draw that line correctly and the controller has nothing left to orchestrate — it shrinks to "translate this HTTP request into a method call" all on its own.
The decision is the whole craft:
One more move, and it's the one that surprises people: "truth" need not mean a column, or even a table. The model owns the truth about a fact — but a fact can be the existence of a row rather than a value in a column. "Has this app been set up yet?" is not a setup_complete boolean; it's whether any User rows exist. And a model can own truth without a table at all, if it quacks like one. We'll see both in beat ③. The point of the principle is ownership, not storage: decide which object is responsible for a fact, and the storage question answers itself afterward.
② The beauty in combination
A principle in isolation is just an opinion. The reason this one is load-bearing is what it unlocks when you hold it against the others.
Hold it with derive, don't store (see P2: Derive, Don't Store). Once the model is the single source of truth about a fact, you can recompute its consequences instead of duplicating them. Read-state is the cleanest example: marking people unread is an owned consequence (it fires for every message), and because the truth lives in one unread_at timestamp on the membership, the unread badge is derived at every surface rather than stored as a counter that drifts. The model owning the fact is precisely what makes the badge safe to derive. P1 says "this object is responsible"; P2 says "so don't keep a second copy of what it's responsible for." They're two halves of the same discipline. We don't unpack derivation here — that's see P2: Derive, Don't Store's job; just notice that it requires P1 to be true first.
Hold it with give behavior a home (see P8: Give Behavior a Home). Owned consequences need somewhere to live, and a 300-line model is not it. Concerns are the answer: a consequence gets filed under the trait it belongs to. Bannable owns remove_banned_content_later; Searchable owns its index-sync callbacks. The model's include line stays a table of contents you can read in two seconds, and each owned consequence sits next to the trait that explains why it exists. P1 decides that a consequence belongs to the model; P8 decides where in the model it sits. The mechanism — included do is the wiring harness — is see F7: Concerns as a Mechanism; the worldview is P8.
Hold it with put work at its right altitude (see P9: Put Work at Its Right Altitude). This is the subtle one. P1 tells you a consequence is owned by the model. P9 tells you where it runs. Those are different questions, and the magic is that they compose on a single line. after_create_commit -> { room.receive(self) } is the owned consequence (P1) — but the _commit suffix is an altitude decision (P9). _commit means after-durable: the callback fires only after the database transaction commits. Use plain after_create and the callback fires inside the transaction, so a rollback after the push has already fired leaves you having notified fifty phones about a message that no longer exists — the ghost row. P1 says what the consequence is and whose; P9 says when it's safe to fire and how to split the cheap synchronous part from the slow background part. One declaration, two principles, zero glue between them.
That's the compounding the whole series is built on. "Fat model, skinny controller" stops being advice and becomes inevitable: the model owns the truth (P1), so you derive its consequences instead of duplicating them (P2), file them under their trait (P8), and run each at its right altitude (P9). Four principles, one message.rb.
③ How 37signals did it
Now the evidence — because a principle you can't point at in real code is just a sermon. Open message.rb and look at lines 11 and 12, sitting one above the other (message.rb:11-12):
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
after_create_commit -> { room.receive(self) }
Line 12 is the owned consequence in its purest form. Read it aloud: after the create commits, the room receives this message. It fires for every message that ever exists — bot, import, seed, interactive send — because being-received-by-the-room is a property of a message existing. It's a callback because the fact belongs to the record. receive then does the in-band unread-marking and hands the slow push fan-out to a job (room.rb:46-49) — the altitude split owned by see P9: Put Work at Its Right Altitude.
Now find the consequence that refused to be a callback. Broadcasting lives in a plain module with no included do, no callback registration at all (broadcasts.rb:1-5):
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
end
broadcast_create is a plain method, called explicitly at each call site that should broadcast — the interactive controller (messages_controller.rb:24) and the bot webhook reply (webhook.rb:60):
# messages_controller.rb
@message.broadcast_create
# webhook.rb
room.messages.create!(body: text, creator: user).broadcast_create
This is the crux of the entire principle, and it's two lines apart from its opposite. room.receive is a callback because it belongs to the record; broadcast_create is an explicit method because it belongs to the call path. A seed run never calls broadcast_create, so it never shoves history onto a live screen — and nobody needed a skip_broadcast flag to make that true. The two consequences sit one line apart in the same file and they are wired in opposite ways on purpose. That's not inconsistency; that's the line being drawn correctly.
The contrast: the naive version binds both to after_create_commit, then discovers seeds and imports now spray live broadcasts, then bolts on attr_accessor :skip_broadcast flags and threads them through every call site — pushing a call-path decision down into model state and reading it back out. Campfire never writes the flag because it never miscategorized the consequence.
Truth without a table
The principle says the model owns truth — but truth isn't always a column. Look at FirstRun (first_run.rb:1-16):
class FirstRun
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
end
FirstRun has no table. It's a plain Ruby object that borrows the create! verb to orchestrate three tables — Account, Room, User, and the membership tying them together — as one setup script. The controller that calls it (first_runs_controller.rb:11) reads exactly like any resourceful create: user = FirstRun.create!(user_params). The truth "what it means to set up a Campfire" is owned by a model that the database has never heard of. Sound is the same trick for read-only data: an in-memory catalog with Sound.find_by_name (sound.rb:8-10) that quacks like Active Record with zero schema and zero round-trips.
class Sound
class Image < Struct.new(:asset_path, :width, :height)
def initialize(name:, width:, height:)
super "sounds/#{name}", width, height
end
end
def self.find_by_name(name)
INDEX[name] # no table, no query — just a hash lookup
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 = [
new(name: "56k", image: { name: "56k.webp", width: 79, height: 33 }),
new(name: "bell", text: "🔔"),
new(name: "rimshot", text: "plays a rimshot"),
new(name: "tada", text: "plays a fanfare 🎏"),
# ...the rest of the ~57 built-in sounds...
new(name: "yodel", text: "📣🗻🙉")
]
INDEX = BUILTIN.index_by(&:name) # the "catalog" find_by_name reads from
end
And state itself can be a row's existence rather than a flag. There is no setup_complete column anywhere in Campfire. The fact "has this app been set up?" is derived from whether any users exist (sessions_controller.rb:26-27):
def ensure_user_exists
redirect_to first_run_url if User.none?
end
Its mirror guards the setup page from running twice (first_runs_controller.rb:20-21): redirect_to root_url if Account.any?. The data is the state machine.
The association owns its own grammar
One last shape. When the model owns a fact, it can also own the verbs of its relationships. Look at the has_many :memberships block on Room (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 revoke_from(users)
destroy_by user: users
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 the grammar lives on the association itself: grant_to bulk-inserts in one query stamped with the room's own default_involvement (so a Direct room and an Open room get the right default for free), and revise wraps grant-and-revoke in one transaction.
This isn't a Campfire quirk — 37signals reach for the same shape in Fizzy. Card#triage_into (app/models/card/triageable.rb:19-27) is one intention-revealing verb that wraps resume; update!; track_event in a transaction do … end, and the lesson sharpens here: two call paths — a drag-and-drop onto a column and an explicit triage button — both call that one verb, so the move-a-card logic (and its event) lives exactly once instead of being half-implemented in two controllers. The transactional model verb is the 37signals way, not a Campfire one-off. The model owns not just the membership fact but the verbs for changing it — and FirstRun above gets to write room.memberships.grant_to administrator precisely because that verb is owned where the relationship lives, not re-implemented in every script that needs it.
The contrast: the naive version loops room.memberships.create!(user_id: id, involvement: "mentions") in a fat controller — N inserts, a hardcoded involvement that's wrong for Direct rooms, a revoke as a separate untransactioned loop, and the same verbs re-typed slightly differently in the webhook and a console script. Campfire writes the grammar once, on the object that owns the relationship, and every call site speaks it.
This is the throughline, in P1's slice of it: count the edge cases this line absorbs for free. after_create_commit absorbs the ghost row. The explicit broadcast_create absorbs the seed-spray. User.none? absorbs the lying flag. grant_to's default_involvement absorbs the wrong-default-for-Direct-rooms bug. None of them is clever. Each is a consequence filed where it's actually true.
Key Takeaways — Patterns to Steal
- Before you write a single line of consequence code, ask one question of it: is this true for every record that exists, or only because of how this one was made? That question — not a "fat model, skinny controller" slogan — is what tells you where the code goes, and getting it right is the whole craft. The naive move is to dump the whole fan-out into the controller because that's where the request landed; the moment a second creation path appears (a bot webhook, a rake import, a seed) you're copy-pasting the same rule into three places that will drift.
- When a consequence is true for every record that exists — no matter who created it — make it a callback on the model, because the fact belongs to the record itself. Marking the room's other members unread is true for a bot message, an imported message, a seeded one; so Campfire writes
after_create_commit -> { room.receive(self) }in message.rb:12 and every creation path gets it for free. Don't leave it in the controller where only interactive sends would run it. - When a consequence is true only because of how this particular record came to be — an interactive send with people watching right now — keep it as a plain explicit method called at the call site, not a callback. Broadcasting a live bubble is like this: a seed shouldn't shove fifty messages onto a developer's screen, an import shouldn't replay three years of history. Campfire makes
broadcast_createa plain module method (broadcasts.rb:2) called explicitly at messages_controller.rb:24 and webhook.rb:60, so the seed run simply never calls it. - For any owned callback that reaches outside the database — a push, a job, a broadcast — use
after_create_commit, never plainafter_create. _commit means after-durable: the callback fires only once the transaction commits, so a row that rolls back can never have notified anyone. Plainafter_createfires inside the transaction, and a rollback after the push leaves you having pinged fifty phones about a message that no longer exists — the ghost row that message.rb:12 sidesteps with the_commitsuffix. (The full altitude reasoning is see P9: the _commit boundary.) - Let the existence of the right rows be the state, instead of inventing a boolean to track it. There is no
setup_completecolumn in Campfire; "has this app been set up?" is answered byredirect_to first_run_url if User.none?(sessions_controller.rb:26-27), mirrored byredirect_to root_url if Account.any?(first_runs_controller.rb:20-21). A flag can lie — delete every user and it still says "done" — butUser.none?asks the truth directly, so there's no second copy to drift. - A model can own a fact with no table behind it at all, as long as it owns the responsibility.
FirstRun(first_run.rb:5-14) is a plain Ruby object that borrows thecreate!verb to orchestrate three tables into one setup script, and its controller calls it exactly like any resourceful create. Don't smear that setup logic across a controller — give the truth "what it means to set up Campfire" an owner, even one the database has never heard of. - When the model owns a relationship, give it the verbs of that relationship too, defined right on the association block.
room.memberships.grant_to(users)/revoke_from/revise(granted:, revoked:)(room.rb:2-18) bulk-insert in one query stamped with the room's owndefault_involvementand wrap multi-step changes in a transaction — soFirstRunjust writesroom.memberships.grant_to administratorand gets it all. The naive version loopsmemberships.create!(involvement: "mentions")in a fat controller: N inserts, a hardcoded default that's wrong for Direct rooms, and the same verbs re-typed slightly differently everywhere.