The Model Is the Truth — and It Owns Its Consequences

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 model owns the consequence callback vs explicit method (whose fact is this?) _commit means after-durable truth without a table (PORO models) state-machine-by-row-existence the association owns its own grammar fat model, skinny controller as a forced consequence campfire by nityeshagarwal

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.

A two-panel hand-drawn sketch in the warm, loose Wait-But-Why / Tim Urban style…
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_broadcast flag for seeds?" Because the moment you write skip_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:

A clean, designed technical poster on a square 1:1 canvas, off-white ground (#F…
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.

A designed technical poster, square 1:1 canvas, off-white ground (#FAF7F2). ONE…
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.

A designed technical poster, square 1:1 canvas, off-white ground (#FAF7F2). ONE…

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_create a 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 plain after_create. _commit means after-durable: the callback fires only once the transaction commits, so a row that rolls back can never have notified anyone. Plain after_create fires 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 _commit suffix. (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_complete column in Campfire; "has this app been set up?" is answered by redirect_to first_run_url if User.none? (sessions_controller.rb:26-27), mirrored by redirect_to root_url if Account.any? (first_runs_controller.rb:20-21). A flag can lie — delete every user and it still says "done" — but User.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 the create! 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 own default_involvement and wrap multi-step changes in a transaction — so FirstRun just writes room.memberships.grant_to administrator and gets it all. The naive version loops memberships.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.