What Campfire Is, and What 'Beautiful Rails' Even Means

What Campfire Is, and What 'Beautiful Rails' Even Means

The legend for the whole series. Before any pattern, you need a way to read an unfamiliar Rails codebase without drowning — so this tutorial derives a reading strategy (the domain lives in app/models, the include line is the table of contents, routes.rb is the sitemap), draws the Campfire domain map from the real files, installs the one yardstick used in every later tutorial (count the edge cases this line absorbs for free), and previews the nine principles as a dependency graph so you understand why the sequence is the sequence.

Reading a codebase domain-first The include line as a model's table of contents routes.rb as the sitemap of every state change The yardstick: count the edge cases a line absorbs for free The Campfire domain map Convention at the boundary (the throughline) The nine principles as a dependency graph The four tracks: Orientation, Foundations, Principles, Capstones campfire by nityeshagarwal

You just cloned Campfire. It's the real group-chat app 37signals shipped — rooms, live messages, @mentions, search, presence dots, bots, the works. The kind of thing a company pays Slack per-seat for. You open the folder, and the first instinct is the one everyone has: find the biggest file and start scrolling.

That instinct is exactly wrong, and learning why it's wrong is the whole point of this series.

A dry, clean two-panel 'expanding brain' meme (not cringe, tasteful, white back…

Here's the trap. You'd guess that a feature-rich chat app has a feature-rich Message model — three hundred lines of attachment handling, mention parsing, search indexing, broadcast logic, pagination, all stacked in one file. That's the Message you'd vibe-code yourself, because every time you needed a message to do one more thing, you'd open message.rb and add a method. By message #47 the file is a junk drawer.

So open the real one. The whole file:

class Message < ApplicationRecord
  include Attachment, Broadcasts, Mentionee, Pagination, Searchable

  belongs_to :room, touch: true
  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) }

  scope :ordered, -> { order(:created_at) }
  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

Forty-four lines (message.rb:1-44). That's the entire Message.

A clean, bright code-editor screenshot of a short Ruby file message.rb (about 2…

The attachment handling, the mention parsing, the search index, the broadcasts — they all exist, but they're not here. They're each filed somewhere, and the first line tells you exactly where.

This is what we mean by beautiful Rails, and it is the opposite of what most people assume. Beauty here is not clever code. There is not a single clever line in Campfire. Beauty is this: each layer trusts a convention at its boundary, so the whole app ends up far smaller than the pile of edge cases it quietly handles. The framework already knows things — what to name a DOM id, when a record is durable, how to find a row through an association — and Campfire lets that knowledge live at the seam instead of re-deriving it by hand. The result is an app you can hold in your head.

This tutorial teaches no single pattern. It's the legend for the map: a way to read the codebase, the domain laid out so you can navigate later, the one yardstick we'll use everywhere, and why the upcoming tutorials are ordered the way they are. Everything after this hangs on the frame we build now.

A reading strategy for a codebase you've never seen

When you don't know a codebase, you need a strategy that isn't "scroll." Rails gives you one for free, because Rails apps are not arbitrary — they're laid out by convention. Three moves get you oriented in about ten minutes.

Move 1 — The domain lives in app/models. Start there, not in controllers.

Your reflex is probably to open app/controllers and trace a request. Resist it. Controllers are plumbing — they translate HTTP into method calls and back. The nouns of the business — what a Room is, what a Message is, what it means for a User to be banned — live in app/models. Read the nouns first and the verbs make sense; read the verbs first and you're reverse-engineering the nouns from how they're poked.

So ls app/models is your first command. In Campfire it's a short list of real-world things: account.rb, room.rb, message.rb, user.rb, membership.rb, boost.rb, ban.rb, webhook.rb, sound.rb. You already understand most of the app just from the filenames. That's the domain.

"Why does the domain belong in the model and not the controller?" Because a controller exists once per request shape, but a fact about a Message is true no matter who created it or how. When a message is created, people get marked unread — that's true for every message, so it belongs to the model (message.rb:12). Put it in the controller and you'll re-type it in every place that creates a message and watch the copies drift. We unpack this fully in see P1: The Model Owns Its Consequences; for now just know: read models first because that's where the truth is.

Move 2 — The include line is the table of contents.

Open a model and read exactly one line: the include. In Campfire, the second line of every big model is a list of capabilities.

class Message < ApplicationRecord
  include Attachment, Broadcasts, Mentionee, Pagination, Searchable

That's message.rb:1-2. Before reading a single method body, you know a Message can carry an attachment, broadcast itself, be a mention target, paginate, and be searched. Each of those is a concern — a small file at app/models/message/<thing>.rb that owns that one trait. Want to know how search works? You don't scroll message.rb; you open message/searchable.rb and read its ~25 self-contained lines.

User reads the same way:

class User < ApplicationRecord
  include Avatar, Bannable, Bot, Mentionable, Role, Transferable

That's user.rb:1-2 — a User has an avatar, can be banned, can be a bot, can be mentioned, has a role, can be transferred. The include line IS the spec. You read it in two seconds and you know the shape of the thing. The mechanism that makes this work — ActiveSupport::Concern and its included do wiring — is see F7: Concerns as a Mechanism; the worldview of why you'd file behavior this way is see P8: Give Behavior a Home. Here, just internalize the reading habit: the include line is the table of contents.

Move 3 — routes.rb is the sitemap of every state change.

Now you know the nouns. The last move tells you every thing the app can do: open config/routes.rb. Every verb the app exposes — every URL, every state change a user can trigger — is in this one file, and Rails forces it to be readable.

resources :rooms do
  resources :messages
  # ...
end

resources :users, only: :show do
  scope module: "users" do
    resource :ban, only: %i[ create destroy ]
    # ...
  end
end

That's routes.rb:62-63 and routes.rb:37-41. Read it like a sitemap: there are rooms, rooms have messages, users have a ban you can create or destroy. Notice that last one — banning a user is the creation of a Ban. Not a POST /users/:id/ban custom verb bolted onto a fat controller, but plain CRUD on a noun called Ban. That instinct — that every verb is really CRUD on some hidden noun — is one of the load-bearing ideas of the whole series (see P6: Model Every State Change as CRUD on a Noun). For now: when you want to know what an app does, read routes.rb.

THE TEN-MINUTE ORIENTATION

  1. ls app/models          →  the NOUNS    (what the domain is)
        message.rb, room.rb, user.rb, membership.rb, ban.rb ...

  2. read the include line  →  the SPEC     (what each noun can do)
        include Attachment, Broadcasts, Mentionee, Pagination, Searchable
        └────────────── each is a file in app/models/<noun>/ ──────────────┘

  3. read routes.rb         →  the VERBS    (every state change, as CRUD)
        resources :rooms do resources :messages end
        resource  :ban,  only: %i[ create destroy ]

The yardstick: count the edge cases this line absorbs for free

Now the single most important habit in this series. When you read a line of Campfire and it looks suspiciously short, do not think "that's elegant" and move on. Ask the real question: how many production edge cases does this one line absorb for free?

Take belongs_to :room, touch: true (message.rb:4). It looks like a plain association. But that touch: true means: whenever a message is written or destroyed, the room's updated_at bumps — which busts the room's cached HTML — which means a reply or a reaction reorders the room list and refreshes its cache through every create and destroy path, and you never wrote a single room.update or cache-key line. One token, a whole class of stale-cache bugs that simply cannot occur. (That mechanism is see F6: Caching.)

Or after_create_commit -> { room.receive(self) } (message.rb:12). The naive version you'd write is after_save, which fires inside the database transaction. The difference is one word — _commit — and _commit means after-durable: the callback only runs once the row is permanently saved. Use plain after_save and a transaction that rolls back after you've already fired a push notification leaves you having pinged fifty phones about a message that no longer exists — the ghost row. One word, that entire bug class gone. (Fully unpacked in see P9: Put Work at Its Right Altitude.)

This is the lens for all twenty tutorials. Beauty is never "look how clever." Beauty is count the edge cases this line absorbs for free. When a Campfire line is one-tenth the size of the version you'd write, it's not because it does less — it's because a convention at the boundary is silently doing the other nine-tenths.

The Campfire domain map

Here's the whole domain, drawn from the real models. Hold this picture; later tutorials are all close-ups of one corner of it.

A bright, legible entity/domain map on a white background, teaching-poster styl…

A few things to lock in, because the later tutorials assume them:

  • A Room comes in three flavors via STI (single-table inheritance): one rooms table, a type column, and three subclasses (app/models/rooms/open.rb, closed.rb, and direct.rb; the STI base is established by Room < ApplicationRecord at room.rb:1). The base class holds everything common. Rooms::Closed is literally an empty class — its body is empty, declared across closed.rb:2-3 as class Rooms::Closed < Room / end — because "closed" is just the default behavior. Rooms::Direct overrides exactly one thing, default_involvement => "everything" (direct.rb:19-21). There is no if room.direct? branching scattered around; the difference lives in the subclass. (That's see P7: Polymorphism Over Conditionals.)

  • Membership is the most important model in the app, and it's a join row. It sits between User and Room, and it carries the three facts that make chat feel alive: are you present (a connected_at timestamp, not an online boolean), have you read the room (one nullable unread_at), and how involved are you — an ordered enum invisible / nothing / mentions / everything (membership.rb:9). Three production features, all riding on one tiny join model. We'll keep coming back here.

  • A bot is just a User with role: :bot. No separate API user table, no parallel auth system. Because a bot is a User, Current.user.memberships, message authorship, and broadcasting all just work for it. Capability gets added by subtraction, not by a whole new code path.

"Wait — where's the chat-specific machinery? Where's the WebSocket code, the dedup logic, the presence sweeper?" That's the whole revelation of the series: there mostly isn't any. Presence is a timestamp compared against a 60-second window, not a sweeper job. Read-state is one nullable column, not a read_receipts table. The "live update" and "the HTTP response" are the same rendered HTML over two transports, not two systems. Each thing you expect to find as a subsystem turns out to be a convention absorbing it. Count the edge cases each line absorbs and the missing subsystems are the answer.

The nine principles, as a dependency graph

The series has two tracks. Foundations (F1–F7) teach the building blocks — what a model, a controller, a view, Turbo, a job, the cache, a concern each are, and the specific way 37signals uses them. They're reference-flavored: mechanics plus a pointer to the principle they serve. Principles (P1–P9) are the why behind the why — nine cross-cutting ideas of the Rails ethos that let you re-derive the patterns yourself. Then three Capstones show the principles collaborating on real features (sending a message, search, the ban arc).

The nine principles are not a flat list of tips. They're a dependency graph — each one is what makes the next one possible. This is why the sequence is the sequence:

A clean dependency-graph infographic on white titled 'The 9 Principles — why th…

Read the arrows as "makes possible," not "comes before." P1 is the root: once the model is the single source of truth, P2 (derive, don't store) becomes available — you can recompute a fact instead of keeping a second, drifting copy of it. Deriving who can see what by loading every record through the current user is just P1+P2 applied to access — and that's P3, security as the shape of your queries rather than a guard you remember to add.

The right column is about painting that truth cheaply. P4 (convention is leverage) is the glue: when both sides of a boundary ask the framework the same question — dom_id for an element's identity, to_key for a record's id, touch: for cache freshness — they can never drift. That's exactly what P5 (one renderer, HTML over the wire) needs to make the first page load, the live WebSocket append, and the wake-from-sleep refresh all hit the identical DOM node with the identical partial.

The bottom keeps the code small: P6 (find the noun) collapses fat controllers into tiny resourceful ones; P7 (polymorphism over conditionals) makes every if type == ... branch disappear into a subclass or an enum; P8 (give behavior a home) files all of it into concerns so the include line stays a readable spec; and P9 (right altitude) decides what runs in-band now versus out-of-band in a background job. You meet them in this order because each leans on the ones before it.

The map (so you can navigate from here)

You now have the legend. Three files are the corners of it — physically open them in your editor right now, because every later tutorial cites into them:

  • app/models/message.rb — 44 lines. Read line 1-2 as the table of contents (include Attachment, Broadcasts, Mentionee, Pagination, Searchable), line 12 as the owned consequence (after_create_commit), line 27-29 as the identity trick (to_key) that makes optimistic chat work. This file is the spine of the published capstone, see C1: The Line Where Saving Becomes a Product.
  • app/models/user.rb — 64 lines. Line 1-2 again as a spec (include Avatar, Bannable, Bot, Mentionable, Role, Transferable); line 7's reachable_messages association is the single source of truth for "what you can see" that see P3: Security Is the Shape of Data Access is built on.
  • config/routes.rb — the sitemap. Read it top to bottom once. Notice how the verbs are nouns (resource :ban, resource :join_code), and how scope module: makes the controller folders mirror the URL tree.

That's the whole orientation. From here, Foundations give you the vocabulary, Principles tie it together, and the Capstones show it compose. The only thing you need to carry into all of them is the yardstick — and it's worth pinning to the wall, because the entire series is just this question asked over and over of one short line of code after another.

Key Takeaways — Patterns to Steal

  • When you open an app you've never seen, the pull is to find the fattest file and scroll it top to bottom, on the theory that a feature-rich chat app must hide its complexity in a feature-rich Message. Do the opposite: run ls app/models and read the nouns first, because controllers are just plumbing that pokes the models and the truth of the domain lives in the nouns. Campfire's whole Message is 44 lines (message.rb:1-44) — the attachment, mention, search, and broadcast machinery all exist, just filed elsewhere, so the scroll would have taught you nothing.
  • Before you read a single method body, read exactly one line of a model: the include. The reflex is to start scrolling method definitions to learn what a class can do, but the capability list is already sitting at the top — include Attachment, Broadcasts, Mentionee, Pagination, Searchable at message.rb:1-2 tells you a Message carries attachments, broadcasts itself, is a mention target, paginates, and is searchable. Each name is a concern file at app/models/message/<thing>.rb, so when you want to know how search works you open message/searchable.rb, not a 300-line god model.
  • When you want to know what an app can actually do, don't reverse-engineer it from scattered controller actions — open config/routes.rb and read it as a sitemap. Rails forces every URL and every state change into that one readable file. Campfire's resources :rooms do resources :messages end (routes.rb:62-63) is the entire verb list for rooms and messages in two lines, where reconstructing the same picture from the controllers would take an afternoon.
  • This is the single habit to carry through the whole series: when a line looks suspiciously short, don't nod and think "elegant," ask how many production edge cases it absorbs for free. The beauty is never cleverness — there isn't a clever line in Campfire — it's that a convention at the boundary is silently doing the nine-tenths you'd otherwise hand-write. Every short line in the codebase is an invitation to ask that one question.
  • When a callback pings the outside world — a push, a broadcast, an email — reach for after_create_commit, not the after_save your fingers want to type. Plain after_save fires inside the transaction, so a rollback after you've already notified fifty phones leaves them buzzing about a ghost row that no longer exists. Campfire writes after_create_commit -> { room.receive(self) } at message.rb:12, and the _commit suffix is the whole promise: _commit means after-durable — this runs only once the row is permanently saved.
  • When the chat-specific machinery you expect — a presence sweeper job, a dedup table, a separate "live update" pipeline — seems to be missing, don't go hunting for where it's hidden; the absence is the lesson. Presence is a connected_at timestamp compared against a 60-second window, read-state is one nullable unread_at column, and the live update and the HTTP response are the same rendered HTML over two transports. Each subsystem you'd build from scratch turns out to be a convention quietly absorbing it.
  • The most important model in a chat app isn't Message or User — it's Membership, the join row between them, and the temptation is to spread its facts across new tables (a read_receipts table, an online boolean, a presence service). Campfire rides three live-chat features on that one tiny join model: presence as a connected_at timestamp, read-state as a single nullable unread_at, and involvement as an ordered enum invisible / nothing / mentions / everything (membership.rb:9). When a feature feels like it needs its own table, first ask whether the join row can carry it.
  • Learn the nine principles as a dependency graph, not a flat checklist of tips, because the sequence is the sequence for a reason — each idea is what makes the next one possible. The model owning truth (P1) is what lets you derive instead of store (P2), which applied to access is security as the shape of your queries (P3); convention (P4) is the glue the one-renderer idea (P5) needs; thin controllers (P6), polymorphism over conditionals (P7), behavior given a home (P8), and work at the right altitude (P9) keep all of it small. Reading them as "makes possible" arrows rather than "comes before" tells you why you meet them in this order.