Beautiful Rails (Campfire)
25 tutorials · nityeshagarwal
What Campfire Is, and What 'Beautiful Rails' Even Means
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
The Rails Model & Active Record
Active Record maps one table row to one object instance; columns become attributes
Associations (belongs_to/has_many) declare a foreign-key relationship and give you a navigable object graph
The callback lifecycle: before/after _save vs _create vs _commit, and which fire inside vs after the DB transaction
_commit means after-durable — the single token that designs out the ghost-row bug class
scope is a named, chainable, composable class method that returns a Relation (a query), never an array
enum maps a word to a stored value and generates a whole family: predicates, bang-setters, and scopes
STI maps a type column to subclasses that share one table; subclasses override only their one difference
A model need not have a table (PORO models like Sound and FirstRun) and state can be the existence of a row
An association can carry its own grammar via an association-extension block
Foundations teach the mechanics; the worldview WHY lives in the Principles track (P1, P2, P7, P9)
Controllers & Routing: The Layer That Should Almost Disappear
routes.rb as the sitemap: resources/resource, scope module:, scope defaults:, direct
the action as a method; implicit rendering; before_action in declaration order
strong params as the allow-list (params.require.permit)
verb-as-noun controllers: every state change is CRUD on a hidden noun
authorization-by-association: the load IS the security boundary
find_by! (hard 404) vs find_by + redirect (friendly nav)
authentication as a vocabulary of class macros, secure-by-default
one predicate (can_administer?) guarding every write via one before_action
controller inheritance + super: the bot path IS the human path
partition: one query, two lists
Views, Partials & Helpers: One Renderer, One Address
A partial is a render-once fragment, and `render collection:` replaces the hand-written `.each` loop
`dom_id(record)` and `dom_id(record, :prefix)` compute a stable DOM id FROM the model, so view and operation derive the same id instead of hand-typing matching strings
One shared `_message` partial paints every path (first load, live append, refresh, edit) — there is no second renderer to drift
Edit-in-place works because three files agree on `dom_id(message, :edit)`, so the controller's `edit` action is literally empty
Layout regions via `content_for`/`yield`, and a CSS state driven by a `local_assigns` boolean
Helpers are plain methods that give the view a domain vocabulary so logic stays out of the ERB
`cached: true` on a collection render turns N fragment lookups into one batched read_multi
Turbo: Frames & Streams
Turbo Drive turns full-page navigation into fetch-and-swap
Turbo Frames update one region independently
Turbo Streams carry HTML + an action + a target dom_id over two transports (HTTP response and WebSocket broadcast)
the wire carries HTML, not data
one partial, every path: the stream reuses the same _message partial and the same dom_id
broadcasting is a deliberate explicit method, not a callback
the optimistic-id handshake: to_key makes dom_id resolve to the client-chosen UUID
the de-dupe is deleted, not written
edit-in-place via broadcast_replace to a matching dom_id target
wake-from-sleep catch-up is a turbo_stream diff (append new, replace edited), not a reload
the Rails-side Stimulus seam: the server stamps intent (maintain_scroll) as data on the wire
Turbo 8: Morphing & Live Refresh
broadcasts_refreshes (refresh-don't-replace)
turbo_refreshes_with method: :morph
morph as reconciliation vs destructive replace
touch: true as declarative fan-out
multiplayer as an emergent property of conventions agreeing on a stream name
self-healing morphing frames (morphReload)
survive-morph attribute veto (before-morph-attribute)
autosave: the timer is the dirty flag
localStorage drafts that survive morph (derive-don't-store at the client layer)
DOM-as-source-of-truth (autoresize, self-deleting form)
Keyboard-First UIs: Config-Driven Navigation, Hotkeys, Accessibility by Construction
the DOM attribute is the state (aria-selected as the cursor)
config over forks: one generic controller varied by data-* values
url-as-contract: the routing table decides what each hotkey means
verb-as-noun hotkeys (POST creates a NotNow/Closure/SelfAssignment resource)
accessibility-by-construction (selection is ARIA, capability subtracted in markup)
Stimulus outlet as a declared wire from board-level keydown to the focused list
nested navigable lists relayed through the DOM tree
cursor-rehoming: re-derive a sane cursor from the fresh DOM after a morph removes the focused row
bounded Promise.race (real morph signal vs 200ms fallback) for liveness
Hotwire Form Widgets That Stay Plain-Rails on the Wire
The client invents no wire format — materialize real form inputs, let params parse them
A server-rendered <template> authors the field name once, on the side that also writes the permit list
Single-select vs multi-select IS Rails' scalar-vs-array param distinction (name vs name[])
requestSubmit() preserves native validation and Turbo; form.submit() bypasses both
Declare intent as data on the wire: data-action as a publisher/subscriber pipeline in ERB
Self-submitting, self-erasing forms: the Turbo lifecycle as one object fired by connect()
Additive decorator helpers: merge Stimulus controllers onto form_with via compact.join, never clobber
Jobs & Background Work: The Thinnest Thread Boundary
Active Job as a uniform interface over a swappable queue backend
perform_later serializes records as GlobalIDs (pass the record, not the id)
the trigger altitude: enqueue from after_create_commit so a rolled-back row is never picked up
the job class as the thinnest possible thread boundary (a two-line thunk delegating to a model method)
the _later/plain-method pairing where the guard lives on the wrapper, never inside the job
the in-band/out-of-band split at the commit boundary (one bulk update_all vs push_later)
work that must escape the Rails executor lives in lib/ with all Active Record reads done before posting to threads
Caching: The Key You Never Maintain
Fragment caching with cache record do — the key embeds updated_at (cache_version)
Russian-doll caching: nested fragments where a child can bust the parent
belongs_to :parent, touch: true declares the cache dependency graph once on the association
render collection: cached: true batches N fragment lookups into one read_multi
HTTP caching: stale?(etag:) / fresh_when wrap an action so a 304 skips the body
expires_in ... public:, stale_while_revalidate: pushing freshness to browser/CDN
Per-path Cache-Control headers (immutable assets vs short-lived else)
The unifying mechanic: the key is DERIVED from the data, never a stored version integer — change the content, change the key/URL
The same change-the-content-change-the-URL idea at the route layer via signed token + v: updated_at
Concerns as a Mechanism
ActiveSupport::Concern as a mixin with three regions: included do, the module body, class_methods
included do is the wiring harness: macros that change the host class (callbacks, scopes, associations)
the module body is plain instance behavior
class_methods do keeps a named constructor next to the trait it builds, so mint/verify can't drift
the include line IS the table of contents / the spec you read before any method body
a concern can self-register a request gate just by being listed (block_banned_requests)
concerns solve the two pain points of plain Ruby mixins: include-order of dependent macros and the awkwardness of adding class-level macros from inside a module
give behavior a home: co-locate a trait's wiring, behavior, and constructor (points to P8)
The Model Is the Truth — and It Owns Its Consequences
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
Derive, Don't Store
derive, don't store
a stored copy is a second source of truth
read-state as one nullable timestamp
presence is a question about time
flags lie
signed_id capability links (the credential IS the URL)
purpose-scoped tokens with no token table or sweeper
mentions are derived attachables, not parsed strings
HTTP 304 as derive-freshness-from-an-etag
count the edge cases this line absorbs for free
Security Is the Shape of Your Data Access
authorization-by-association
the IDOR you cannot type
load every record through the current user
find_by! (hard 404) vs find_by + redirect (human nav)
reachable_messages as the single source of truth for visibility
secure-by-default authentication concern
opt-out-by-name with allow_unauthenticated_access / allow_bot_access
OR'd auth strategies in priority order
conditional CSRF on .bot_key?
one can_administer? predicate guarding every write
subclass overrides the guard (Direct rooms)
the ambient self-registering before_action (block_banned_requests)
capability by subtraction (deny_bots by default)
fail-closed SSRF guard (invalid means dangerous)
defense-in-depth with no per-call vigilance
Convention Is Leverage
Convention as the elimination of hand-kept synchronization: when both sides of a seam call the same framework function, drift becomes unwriteable
dom_id is the address: a stable DOM identity computed FROM the model, so the view, the turbo_stream, the broadcast, the refresh, and the edit all derive the same id instead of copying a string
The single _message partial as one renderer reused across every path, guarded against its optimistic twin by a one-line warning
Edit-in-place as a convention handshake: a frame id (dom_id(message, :edit)) agreed across three files lets `def edit` be empty
to_key named as the convention bridge that makes dom_id speak a client-chosen UUID (full worldview owned by P5)
Russian-doll caching as convention doing the dependency graph: touch: true declares the freshness edge once on the association; the cache key is updated_at, derived not stored
The yardstick applied to convention: count the bookkeeping a single conventional call deletes
One Renderer: HTML Over the Wire
One renderer: one partial paints every transport
HTML over the wire — the wire carries HTML, not data
The two-renderer drift bug (page render vs broadcast render)
to_key as the optimistic-id handshake bridging ActiveModel identity to dom_id
The de-dupe is deleted, not written
Edit-in-place: broadcast a replace to spectators, redirect the actor, no branch
Server declares intent as data on the wire (maintain_scroll), client honors it
Model Every State Change as CRUD on a Noun
verb-as-noun: every custom controller verb is CRUD on a hidden noun
find the noun: the move that collapses a fat controller into a resourceful seven actions
the two-line CRUD controller: HTTP-to-method translation, work lives on the model
scope module: makes the controller folder tree mirror routes.rb without changing URLs
scope defaults: { user_id: 'me' } makes a path helper argument-free
open/switch a room is just GET #show — a read, not a verb
one render path, two pagination scopes (last_page vs page_around)
skinny controller is not discipline you impose — it's what's left when every action is genuine CRUD
Polymorphism Over Conditionals
polymorphism you haven't named yet
the branch was a missing abstraction
STI: one table, a type column, subclasses that override only the seam
enum as a query vocabulary (prefix:)
capability by subtraction
controller inheritance + super (the bot path IS the human path)
type_previously_changed? as free dirty-tracking
becomes! to recast a record's subclass
muted as the absence of a .merge
Give Behavior a Home: Composition via Concerns
give behavior a home
included do is the wiring harness
the include line IS the spec / table of contents
ActiveSupport::Concern's three regions: included do, module body, class_methods
co-location: a trait's wiring, behavior, and constructor cannot desync
concerns as where owned consequences (P1), self-registering gates (P3), and _later guards (P9) live
full-text search as a self-contained ~25-line concern
count the edge cases this line absorbs for free
Put Work at Its Right Altitude
in-band vs out-of-band work
the sync/async line
the thinnest thread boundary
the _commit boundary as the trigger altitude
_commit means after-durable
the ghost row
jobs as thin thunks delegating to model methods
the guard lives on the _later wrapper, not in the job
GlobalID serialization: pass records, not ids-to-re-find
work that must escape the Rails executor lives in lib/
do all Active Record reads before posting to threads
altitude is decided at the seam, not by the model
The Line Where Saving Becomes a Product: One Message, Every Screen, Zero Glue
after_create_commit and the meaning of _commit (after-durable)
The model owning its own consequence vs the fat controller
Mixin composition with ActiveSupport::Concern (the table-of-contents model)
room.receive and the synchronous/asynchronous delivery split
Composable AR scopes: visible.disconnected.where.not as a PM sentence
Connectable time-window range scopes for presence
enum prefix generating English-reading query scopes
Turbo Streams over ActionCable with one server-rendered partial for all paths
Overriding ActiveModel#to_key so dom_id speaks the client's chosen UUID
Optimistic-UI de-duplication that falls out of shared DOM identity
Stimulus listening on Turbo's before-stream-render lifecycle event
Search: The Feature That Is Almost Entirely Convention
Full-text search as a self-syncing FTS5 index, not a query trick
Why Message.where("body LIKE ?") never even matches (the body lives in action_text_rich_texts)
Searchable as a self-contained concern with three symmetric _commit callbacks
plain_text_body deriving searchable text, with a filename fallback for image-only messages
The search scope as a chainable Relation so auth and pagination compose onto it
Current.user.reachable_messages.search(query) — the authorization boundary composed onto the search
_commit keeping rolled-back rows out of the index
The same _message partial rendering search results as every other surface
Recent-searches as CRUD on a Search noun, self-trimming on create
The Ban Arc: Thin Controller, Orderly Model, Ambient Guard
verb-as-noun: banning is creating a Ban
the fat-model transaction as an ordered checklist
transaction ordering as the load-bearing correctness detail
snapshot durable state before you delete its source
security as an ambient self-registering before_action
the block outlives the session and the account
altitude: defer the slow fan-out (content removal) to a job
authorization-by-association at the request gate
Drag-and-Drop the Rails Way: Derived Order, REST Drops, Morph Reconciliation
Derived order with no position column (last_active_at as the one sort axis)
State-change-as-REST-resource: the routing table owns the case-on-destination
Thin drop controllers (3-7 lines) calling one rich model verb
triage_into / close / postpone as transactional model verbs with two entry points
URL-as-contract: __id__ placeholder swap; the route decides what a drop means
Optimistic DOM move reconciled by morph (the to_key handshake, new mechanism)
Top-or-bottom data attribute constrains the client's guess to the server's sort axis
Config-as-data in data-* attributes; JS stays domain-agnostic
verb-as-noun / CRUD-on-a-noun (STYLE.md as written house law)
count the edge cases this line absorbs for free
Passwordless Login With No Tokens Table
Passwordless magic-link authentication modeled as CRUD on a noun
Consume-means-destroy: deriving 'spent' from a row's absence instead of a `used` flag
Expiry as a read-time range comparison (beginless/endless ranges), not a status to sweep
Pending-login state as a signed, httponly, self-expiring cookie instead of a server-side table
Binding credential to browser via a constant-time email compare at redeem
Anti-enumeration by structural identity: fabricating an unsaved MagicLink so unknown emails are byte-identical to known ones
Thin controllers / rich model: every hard part lives in MagicLink and Session