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.
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.
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.
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 few things to lock in, because the later tutorials assume them:
A
Roomcomes in three flavors via STI (single-table inheritance): oneroomstable, atypecolumn, and three subclasses (app/models/rooms/open.rb,closed.rb, anddirect.rb; the STI base is established byRoom < ApplicationRecordatroom.rb:1). The base class holds everything common.Rooms::Closedis literally an empty class — its body is empty, declared acrossclosed.rb:2-3asclass Rooms::Closed < Room/end— because "closed" is just the default behavior.Rooms::Directoverrides exactly one thing,default_involvement => "everything"(direct.rb:19-21). There is noif room.direct?branching scattered around; the difference lives in the subclass. (That's see P7: Polymorphism Over Conditionals.)Membershipis 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 (aconnected_attimestamp, not anonlineboolean), have you read the room (one nullableunread_at), and how involved are you — an ordered enuminvisible / 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
Userwithrole: :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_receiptstable. 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:
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'sreachable_messagesassociation 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 howscope 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: runls app/modelsand 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 wholeMessageis 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, Searchableatmessage.rb:1-2tells you a Message carries attachments, broadcasts itself, is a mention target, paginates, and is searchable. Each name is a concern file atapp/models/message/<thing>.rb, so when you want to know how search works you openmessage/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.rband read it as a sitemap. Rails forces every URL and every state change into that one readable file. Campfire'sresources :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 theafter_saveyour fingers want to type. Plainafter_savefires 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 writesafter_create_commit -> { room.receive(self) }atmessage.rb:12, and the_commitsuffix is the whole promise:_commitmeans 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_attimestamp compared against a 60-second window, read-state is one nullableunread_atcolumn, 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 (aread_receiptstable, anonlineboolean, a presence service). Campfire rides three live-chat features on that one tiny join model: presence as aconnected_attimestamp, read-state as a single nullableunread_at, and involvement as an ordered enuminvisible / 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.