Convention Is Leverage
A principle tutorial on the load-bearing idea behind Rails' "magic": when both sides of a boundary ask the framework the same question — dom_id for identity, to_key for a record's key, touch: for cache freshness — the two things can never drift, because there's only one place that computes them. We derive why hand-typed id strings and hand-maintained cache versions rot, watch the principle interlock with One Renderer (P5), the model owning truth (P1), and Derive Don't Store (P2), and then prove it against Campfire's single _message partial, its edit-in-place frame ids agreed across three files, and its zero-bookkeeping Russian-doll cache.
The principle: When both sides of a boundary ask the framework the same question (dom_id, to_key, touch:), they can never drift — convention turns 'two things I must keep in sync by hand' into 'one thing computed in one place.'
① First principles: the bug that lives in the gap between two strings
You're building chat, and a single message has to be addressable. It gets rendered on the first page load. It gets appended live over a WebSocket when someone else posts. It gets re-appended after your laptop wakes from sleep and catches up. It gets swapped out when you edit it, and yanked when you delete it. Five moments, five different code paths — and every one of them needs to point at the same node in the DOM.
So you do the obvious thing: you give the node an id, and you type that id wherever you need it. Here's the version you'd vibe-code first — and it isn't a strawman, it's the honest first draft before the convention clicks:
<%# the page: the container and each row, ids typed by hand %>
<div id="room_<%= @room.id %>_messages">
<% @messages.each do |message| %>
<div id="msg-<%= message.id %>">
<%= render "messages/message_body", message: message %>
</div>
<% end %>
</div>
# the live append, in a controller or a broadcast somewhere far away:
turbo_stream.append "room_#{@message.room.id}_messages" do
# ...and you re-type the message markup inline here, because it was
# easier than figuring out how to share it with the page above
render_message_html(@message) # a SECOND copy of the markup
end
<%# and the edit handler, in yet a third place: %>
<turbo-frame id="message-<%= @message.id %>_edit"> <%# ...wait, was it "msg-" or "message-"? %>
Look at what you've built. The container is room_4_messages in the view but room_#{id}_messages in the stream — fine today, until someone renames the prefix in one file and forgets the other. Each row is msg-#{id} in the page and message-#{id} in the edit frame, and those two strings were never the same — the edit frame just silently matches nothing. And the message markup itself now exists in two places: the page renders it one way, the live append renders it another, and the day you add a boost badge to one copy and forget the other, the badge appears on reload but not on the live message, and nothing errors. A drifted id throws no exception. It just quietly stops working.
"Isn't this just sloppiness? A careful developer types the strings consistently." That's the trap. The bug isn't that you were careless once — it's that you signed up to be careful forever, across files that don't know about each other, every time the schema or the markup changes. Human vigilance is not a synchronization mechanism. The strings will drift, because keeping two hand-typed copies in sync is a job, and you'll eventually have a bad day.
Now derive the way out from scratch. The strings drift because there are two of them — one on each side of the boundary, kept equal by hand. So delete one. If every place that needs the id calls one function that computes the id from the model — and every place that renders the message renders one partial — then there is no second copy to drift from. The id isn't copied, it's derived. The markup isn't duplicated, it's referenced. Mismatch stops being a bug you avoid and becomes a state you cannot express.
That function exists, and it's dom_id. Ask dom_id(message) on the page, ask dom_id(message) in the stream, ask dom_id(message) in the broadcast — same model, same function, necessarily the same string. dom_id is the address. This is what "convention" actually buys you, and it is almost never explained this way: convention is not "memorize the Rails magic so you don't have to think." Convention is "delegate the bookkeeping to a function in one place, so two humans on two sides of a seam cannot desync it." The magic is just the absence of a synchronization chore.
That last clause is the seam — a convention boundary two layers trust. The view trusts dom_id to name the node; the stream trusts the same dom_id to find it. Neither side hard-codes the answer. They both ask.
② The beauty in combination
A single function deleting one class of typo would be a nice tip. The reason convention is a principle and not a tip is what happens when you hold it next to the others — it turns out to be the load-bearing glue underneath three different features that look unrelated.
Hold it with One Renderer (P5). see P5: One Renderer says the first page load, the live WebSocket append, and the wake-from-sleep refresh should all be the same HTML, rendered once on the server and sent over every transport. But "the same HTML hitting the same node" is only possible if every path can name that node identically — and that naming is dom_id. Convention is the glue One Renderer needs. The partial is the single renderer; dom_id is the single address; together they're why "send a message" is a handful of declarative lines instead of a payload schema and a reconciliation pass. We don't re-derive the one-renderer worldview here — that's P5's job. Here, just see that without the shared address, one renderer would have nothing to aim at.
Hold it with the model owning truth (P1) — and watch caching fall out of it. Here's a feature that looks like it needs a subsystem: a reply or a reaction should reorder the room in the sidebar and bust the room's cached HTML, through every create and destroy path. The naive version is an after_create that does room.update(updated_at: Time.current) — which you then forget to replicate on the boost child, and forget again on destroy, and now reacting to a message doesn't reorder anything. That's the same drift bug as the id strings, wearing a cache costume: a dependency you maintain by hand in N places.
Convention collapses it to one declaration on the association that owns the relationship:
belongs_to :room, touch: true # message.rb:4
belongs_to :message, touch: true # boost.rb:2
touch: true says: whenever this child is written or destroyed, bump the parent's updated_at. Declared once, on the edge of the graph it describes.
Fizzy declares it the same way and everywhere — belongs_to :board, touch: true on a column (column.rb:5), belongs_to :card, touch: true on a closure (closure.rb:3) — the same one-word delegation of the cache-bust to the association edge, in a product that shares no code with Campfire. Now nest the fragments — <% cache boost %> inside <% cache message %> inside the room — and the parent fragment's key depends on the child, because touching the child bumps the parent's updated_at, which changes the parent's key, which expires the parent fragment. The dependency graph is the touch: arrows; the framework walks it for you.
Hold it with Derive, Don't Store (P2), and the same idea shows up at a third altitude. What is that cache key? It's updated_at — derived from the data, never a hand-maintained version integer you bump and forget. "If the content changes, the key changes" is see P2: Derive, Don't Store applied to freshness. And the very same sentence appears at the route layer: direct :fresh_user_avatar stamps v: user.updated_at.to_fs(:number) into the avatar URL (routes.rb:58-60), so a new avatar gets a new URL and the browser cache can be immutable. One idea — derive the identity from the content — at three altitudes: the DOM id (dom_id), the fragment key (updated_at), the asset URL (v:). The reader who sees it once at the view layer recognizes it instantly at the other two.
That recognition is the whole point of the principles track: convention isn't a pile of Rails features to memorize. It's one move — let both sides ask the framework the same derived question — applied wherever two things would otherwise need hand-syncing.
③ How 37signals did it
Open app/helpers/messages_helper.rb. The container and every row get their id from the same function, never a typed string (messages_helper.rb:16,28):
def messages_tag(room, &)
tag.div id: dom_id(room, :messages), ... # the container: dom_id(room, :messages)
end
def message_tag(message, &)
tag.div id: dom_id(message), ... # each row: dom_id(message)
end
Now follow that exact same dom_id(room, :messages) to every path that targets the container.
Fizzy shows the same move isn't a Campfire habit but the house style: its message container is dom_id(card, :messages) (messages_helper.rb:3) and every card row is dom_id(card, :article) (cards_helper.rb:2), never a typed string. Two products built for different problems, the same function naming the node — dom_id is the address in both. The HTTP response to the sender (create.turbo_stream.erb:1):
<%= turbo_stream.append dom_id(@message.room, :messages), @message %>
The live broadcast from the model (broadcasts.rb:2-3):
def broadcast_create
broadcast_append_to room, :messages, target: [ room, :messages ]
The wake-from-sleep catch-up (rooms/refreshes/show.turbo_stream.erb:1-7):
<%= turbo_stream.append dom_id(@room, :messages) do %>
<%= render partial: "messages/message", collection: @new_messages, cached: true %>
<% end if @new_messages.any? %>
<% @updated_messages.each do |message| %>
<%= turbo_stream.replace dom_id(message), partial: "messages/message", locals: { message: message } %>
<% end %>
Four files. Five operations — append on first load, append on live, append on refresh, replace on edit, remove on delete. Not one of them types an id string. Every single one asks dom_id the same question about the same model. The rename-one-forget-another bug from beat ① is not avoided here by discipline; it is unwriteable, because there is no second copy to forget.
And the markup behind that address is one partial. Both the index and the room show render the message list the same way (index.html.erb:1, rooms/show.html.erb:17):
<%= render partial: "messages/message", collection: @messages, cached: true %>
The HTTP stream renders messages/_message. The broadcast renders messages/_message. The refresh renders messages/_message. One renderer, addressed by one function. The partial even guards the one place a second copy of the markup unavoidably exists — the optimistic JS template the sender draws before the round-trip — with a comment, not a hope (_message.html.erb:1):
<%# Be sure to check/update messages/_template.html.erb when changing this file %>
"That comment IS hand-syncing — you just said convention deletes that." Right, and it's the exception that proves the rule. The optimistic template (
_template.html.erb) is client-side markup drawn before any server round-trip, so it genuinely can't call the server's partial — there are unavoidably two copies. So 37signals did the next best thing: made the seam loud with a warning comment right at the top of the file. Where a function could absorb the sync (every server path), they used one. Where one couldn't (the optimistic twin), they flagged it. The discipline is reserved for exactly the one place convention can't reach.
The empty edit action
Here is the convention principle at its most quietly absurd. Edit-in-place needs three files to agree on one Turbo Frame id. They do — by all three asking dom_id(message, :edit):
<%# _message.html.erb:11 — the frame the row lives in %>
<turbo-frame id="<%= dom_id(message, :edit) %>">
<%# _actions.html.erb:51-52 — the Edit link targets that frame %>
<%= link_to edit_room_message_path(message.room, message),
data: { turbo_frame: dom_id(message, :edit) }, ... %>
<%# edit.html.erb:1 — the edit form's response fills that frame %>
<turbo-frame id="<%= dom_id(@message, :edit) %>">
The Edit link declares which frame its response targets; the edit.html.erb response wraps itself in a frame with the matching id; Turbo sees the ids match and swaps the row's contents for the form, in place. And because the convention does the entire swap, the controller action that "handles" the edit is this (messages_controller.rb:33):
def edit
end
Empty. There is nothing to do, because the convention — matching frame ids, all three derived from dom_id — is the behavior.
Fizzy edits a card the same way: its Edit link targets dom_id(card, :edit) (_closure_buttons.html.erb:8), the edit response wraps itself in the matching frame, and the swap falls out of the matching ids. Same trick, different noun — the edit-in-place-by-frame-id convention is the house's, not the chat app's.
to_key — the bridge named here, unpacked in P5
One more, named here because it's the same move and unpacked fully elsewhere. The sender's browser draws an optimistic node with an id of its own choosing — message_$clientMessageId$ (_template.html.erb:3) — before the server has even seen the message. For the live append to replace that node instead of stacking a duplicate, dom_id on the server has to resolve to the client's UUID, not the database primary key. So the model overrides what "identity" means (message.rb:11,27-29):
before_create -> { self.client_message_id ||= Random.uuid } # Bots don't care
# ...
def to_key
[ client_message_id ]
end
to_key is the ActiveModel method dom_id consults to compute a record's identity. Override it, and dom_id(message) now speaks the client-chosen UUID — so the optimistic node and the authoritative broadcast share one address, and Turbo replaces in place. The de-dupe is deleted, not written. That's the same principle one layer down: both sides (the client's optimistic node, the server's broadcast) ask the same identity question and get the same answer. The full worldview — how this makes optimistic realtime fall out for free — belongs to see P5: the optimistic-id handshake, and the client-side handshake mechanics to see F4: the to_key handshake. Here, just register the shape: to_key is a convention bridge, the same trick as dom_id applied to identity itself.
A nod to the yardstick the whole series runs on: count the edge cases this line absorbs for free. dom_id absorbs every id-drift typo across five render paths. touch: true absorbs the forgot-to-bust-the-cache bug across every create and destroy path. to_key absorbs the entire duplicate-message reconciliation pass. None of these is clever code. Each is one conventional call standing in for a synchronization chore you'd otherwise do by hand, forever, until you didn't.
And here is the proof that convention-as-leverage is doctrine and not one team's taste: Fizzy ships a STYLE.md where 37signals write these conventions down as law — REST resources instead of custom actions (STYLE.md:138-153), thin controllers calling a rich model with no service objects (STYLE.md:155-183), shallow _later/_now jobs that delegate to the model (STYLE.md:185-213). Campfire only practiced these silently; Fizzy states them. That is what makes a convention leverage rather than folklore — it's a shared written rule both the humans and their AI obey, so two people on two sides of a seam can trust it without a conversation.
Key Takeaways — Patterns to Steal
- Never type a DOM id by hand and never re-type it on the other side of a WebSocket — the moment the page calls
room_#{id}_messagesand the broadcast retypes the same string, those two copies are one bad day away from drifting, and a drifted id throws no exception, it just silently stops appending. Make every path ask the same function instead, so the id is derived from the model rather than copied; inmessages_helper.rb:16,28the container isdom_id(room, :messages)and each row isdom_id(message), and the mismatch you were trying to avoid becomes a state you can't even express. - When the same HTML has to render on first load, on a live append, and on a wake-from-sleep refresh, don't write a fresh chunk of markup inline in each handler — the day you add a badge to one copy and forget the others, it shows on reload but not live, and nothing errors. Render one partial everywhere and let
dom_idaim every transport at the same node; Campfire's first load, HTTP stream, broadcast, and refresh all rendermessages/_message(index.html.erb:1,rooms/show.html.erb:17), one renderer addressed by one function. - The one place you genuinely can't share the markup — the optimistic node the sender's browser draws before any server round-trip — don't pretend convention covers it and don't quietly let it rot out of sync. That client-side template can't call the server's partial, so there are unavoidably two copies; the honest move is to make the seam loud, which
_message.html.erb:1does with<%# Be sure to check/update messages/_template.html.erb when changing this file %>. Reserve the hand-discipline for exactly the one spot convention can't reach, and use a function everywhere it can. - When you build edit-in-place, resist writing a controller action that computes a
@target_idstring and threads it through three templates by hand — that's the id-drift bug wearing a routing costume. Have all three files askdom_id(message, :edit): the row's frame (_message.html.erb:11), the Edit link'sturbo_frametarget (_actions.html.erb:51-52), and the edit response's frame (edit.html.erb:1). Because matching ids do the entire swap, the action that "handles" edit is the emptydef editatmessages_controller.rb:33— the convention is the behavior. - To reorder a room and bust its cached HTML whenever a reply or reaction lands, don't reach for an
after_createthat runsroom.update(updated_at: Time.current)— you'll forget to replicate it on the boost child, forget it again on destroy, and reacting to a message will quietly stop reordering anything. Declare the dependency once on the association edge that owns it:belongs_to :room, touch: true(message.rb:4) andbelongs_to :message, touch: true(boost.rb:2). Now nest<% cache boost %>inside<% cache message %>, and touching the child bumps the parent'supdated_at, which changes the parent's key and expires its fragment — the framework walks thetouch:arrows so the view author writes no cache key and noroom.updatecall. - For the cache key itself, never introduce a hand-maintained
versioninteger you bump and forget — that's just one more thing to keep in sync by hand. Derive freshness from the content: the fragment key isupdated_at, and the same sentence shows up at the route layer wheredirect :fresh_user_avatarstampsv: user.updated_at.to_fs(:number)into the URL (routes.rb:58-60) so a new avatar gets a new URL and the browser cache can be immutable. It's one idea — derive the identity from the content — at three altitudes: the DOM id (dom_id), the fragment key (updated_at), and the asset URL (v:). - When an optimistic node and the real broadcast need to be the same node so the live append replaces instead of stacking a duplicate, don't write a client-side de-dupe pass that reconciles the database primary key against the browser's UUID. Teach
dom_idto speak the client's id instead:before_createmints aclient_message_id(message.rb:11) andto_keyreturns[ client_message_id ](message.rb:27-29), so the optimistic node and the authoritative broadcast share one address and Turbo replaces in place.to_keyis the ActiveModel hookdom_idconsults for a record's identity — the same trick asdom_id, applied to identity itself, with the entire reconciliation pass deleted rather than written.