Beautiful Rails, Like DHH — Writing Rails the 37signals Way
Write Rails the way the best Rails developers alive write it — mined from two shipped 37signals products (Campfire, real-time chat; Fizzy, live multiplayer Kanban) plus their written style law. The payoff: whole production features in a handful of files, frontends that do everything an SPA does without a second codebase, and entire bug-classes made unwriteable.
The two sentences that govern everything
- The throughline: Rails stays small because each layer trusts a convention at its boundary.
- The yardstick: Count the edge cases this line absorbs for free — that judges every line, yours and others'.
Both are stated, derived, and worked in references/01-doctrine.md §1. Read it before any nontrivial work.
The ten rules (index — 01-doctrine.md carries the why; each maps to a P-number)
- The model owns the consequence (P1) — facts → model
_commitcallbacks; call-path effects → explicit methods. Reaching forskip_x? You put a call-path fact on the record. _commitmeans after-durable (P1+P9) — anything reaching outside the DB firesafter_*_commit, never plainafter_create/after_save(the ghost row).- Derive, don't store (P2) — any recomputable fact must not be stored; a stored copy is a second source of truth.
- Security is the shape of your data access (P3) — load every record through
Current.user; auth defaults closed, opened opt-out-by-name. - Find the noun (P6) — every custom controller verb is CRUD on a hidden noun. A model may have verbs; a controller may not.
- One renderer; the wire carries HTML, not data (P5) — one partial paints every path, addressed by
dom_id. No JSON contract, no client templates. - Polymorphism over conditionals (P7) — every
if kind == "x"is a class you haven't named. - Give behavior a home (P8) — traits live in concerns; the include line IS the spec.
- Put work at its right altitude (P9) — cheap-and-durable runs in-band; slow/flaky/fan-out crosses into a thin job thunk.
- JS is thin, generic, server-configured (P4+P5) — domain-agnostic Stimulus,
data-*as config, the URL carries the contract, the DOM attribute IS the state.
Routing: which reference to read, when
Read only what the task needs. Each file opens with its own "read this when" line and TOC.
| Situation | Read |
|---|---|
| Tempted by React/Next/SPA; task says "interactive/real-time/modern UI" | references/00-frontend-first-principles.md |
| Starting any nontrivial work — worldview + principle interlocks + glossary | references/01-doctrine.md |
| Models, migrations, callbacks, scopes, enums, STI, concerns | references/02-models.md |
| Routes/controllers/actions; custom-verb actions; params | references/03-controllers-routing.md |
| Views, partials, helpers; anything rendered twice; edit-in-place | references/04-views-helpers.md |
| Page regions, lazy loading, live appends, WebSocket pushes, uploads | references/05-turbo-frames-streams.md |
| Live multiplayer, morphing, drag-and-drop | references/06-morphing-live-updates.md |
| Any JS: widgets, comboboxes, keyboard nav, hotkeys, autosave, drafts | references/07-stimulus-widgets.md |
| Jobs, emails, push, scheduled work, retries, multi-tenancy in jobs | references/08-jobs-background-work.md |
| Anything slow; fragment/HTTP caching; ETags; invalidation | references/09-caching-performance.md |
| Auth, authorization, tokens, bans, security review | references/10-auth-security.md |
| A whole feature end-to-end — five worked examples + feature anatomy | references/11-worked-features.md |
| New app; codebase structure; stack choices; what NOT to build | references/12-app-blueprint.md |
| Reviewing code or a diff; auditing a codebase | references/13-review-checklist.md |
| Mobile apps; tempted by React Native/Flutter; bridge components | references/14-hotwire-native.md |
Exact API lookup mid-task — data-turbo-*, Turbo events, stream/frame attrs, Stimulus API |
references/15-hotwire-api-cheatsheet.md |
The feature build loop
For any new feature, work in this order, asking the question at each step:
- Model — What is the noun, and whose facts are its consequences? (
02) - Routes — What noun is each verb hiding?
resources/resourceonly. (03) - Controller — Can this be two lines? Load through
Current.user, call one model verb. If longer, the model is missing a verb. (03,10) - View — One partial, addressed by
dom_id, serving every future path. (04) - Live layer — Pull or push? Regions → frames; surgical → streams; multi-user →
broadcasts_refreshes+ morph. (05,06) - Polish — What's slow, what must the browser keep? Jobs (
08), caching (09), thin Stimulus (07).
Run the yardstick on each layer as you write it: every guard, loop, and hand-typed string is a candidate for a convention that absorbs it.
Review mode
Reviewing a diff/PR/codebase: start with references/13-review-checklist.md (red-flag tables by layer, severity-ranked). Highest-signal first scan: unscoped Model.find(params[:id]) near a permission check; plain after_create/after_save touching the outside world; the same markup or id string in two places.
Anti-pattern quick table (teaser — left column = the agent default; 13 owns the detail)
| You'll reach for | Instead | Rule |
|---|---|---|
| React/Next for "interactivity" | Hotwire — read 00 first |
6, 10 |
| A service object | A model verb or concern | 1, 8 |
after_create :notify |
after_create_commit |
1, 2 |
A position integer renumbered on reorder |
Derive order from a timestamp/satellite row | 3 |
A used/online/setup_complete boolean |
Destroyed row / TTL scope / User.none? |
3 |
Model.find(id) + permission if |
Load through Current.user |
4 |
member do post :ban end |
resource :ban |
5 |
| JSON endpoint + client template for a widget | Server-rendered <template> into real inputs |
10 |
case record.type / if kind == "x" |
STI / enum scope / partial-by-name | 7 |
| Tokens table + sweeper | signed_id(purpose:, expires_in:) |
3, 4 |
Logic inside a job's perform |
Two-line thunk → model verb | 9 |
| Hand-typed DOM ids / duplicated markup | dom_id + one partial |
6 |
A dirty flag beside the work |
The DOM attribute IS the state | 3, 10 |
form.submit() / location.reload() |
requestSubmit() / morph refresh |
10 |
Calibration
- Comprehensiveness ≠ ceremony. The doctrine produces less code. If it ever feels like ceremony, you've misread a pattern — recheck the owning reference.
- Respect existing conventions. Default for greenfield, lens for review — not a conversion mandate. Work with Tailwind/RSpec/Devise/Sidekiq/service objects; recommend the 37signals alternative when asked or when it clearly pays, never as a drive-by rewrite.
- The exceptions are part of the doctrine. Store a position for genuine user-intent order; plain
after_savefor work that must roll back; SPA for canvas-class apps (00§6). Each reference marks its own exceptions; apply them knowingly and say why. - Provenance tags "(Campfire)"/"(Fizzy)" = lifted from shipped production; both cited = house doctrine. "(Hotwire docs)" = distilled from the official Turbo/Stimulus handbooks.