note
Rails reference apps: lessons from Campfire, Writebook, and Fizzy
Spent more time inside the official Rails reference apps — Campfire, Writebook, and Fizzy — reading actual code, not just surface docs. Some deeper patterns worth keeping:
Campfire — real-time chat
Auth & sessions
Authenticationconcern inapp/controllers/concerns/authentication.rbrestores a session from a signed permanent:session_tokencookie, falls back to bot auth viaparams[:bot_key], and stores areturn_toURL for post-login redirects.Sessionuseshas_secure_tokenand only refreshesuser_agent/ip_address/last_active_atonce per hour (ACTIVITY_REFRESH_RATE = 1.hour).User::Roleis a tiny enum (member/administrator/bot);can_administer?(record)returns true for admins, the record’s creator, or new records.
Rooms & access
Roomuses STI:Rooms::Open,Rooms::Closed,Rooms::Direct.Membershiphas aninvolvementenum (invisible/nothing/mentions/everything) andunread_atfor unread state.Room.membershipsexposesgrant_to,revoke_from, andrevise(granted:, revoked:)wrapped in a transaction.RoomScopedloadsCurrent.user.memberships.find_by!(room_id: ...)first, so room access is enforced by the association itself.
Real-time layer
- Multiple Action Cable channels:
RoomChannelstreams messages per room,PresenceChanneltracks who’s online,TypingNotificationsChannel,UnreadRoomsChannel,ReadRoomsChannel. Message::Broadcastshandles Turbo append/remove plus a rawActionCable.server.broadcast("unread_rooms", ...)for the sidebar.- Messages broadcast themselves:
@message.broadcast_createis called from the controller after create.
Attachments
Message::AttachmentgivesMessagehas_one_attached :attachmentwith a:thumbvariant.Message.create_with_attachment!(...)callsprocess_attachment, which analyzes the blob and builds a preview/representation (webp for video,:thumbfor images).
Bots & webhooks
User::Bottreats bots as users withrole: :botand abot_token;User.create_bot!creates the bot and an optionalWebhook.Webhook#deliverPOSTs a JSON payload (user, room, message) with a 7-second timeout, then posts the bot’s text or attachment reply back into the room.- Async via
Bot::WebhookJob; bot replies go throughMessages::ByBotsControllerwith CSRF skipped for bot-key auth.
Pagination & search
- Uses
geared_paginationwith custom scopes:page_before,page_after,last_page. Good for chat-style “load more above/below”. Message::Searchablemaintains a custom SQLite FTS index manually viaafter_create_commit/after_update_commit/after_destroy_commit. No Elasticsearch, no pgvector.
Other
- Web Push via VAPID keys and the
web-pushgem. - OpenGraph metadata fetching lives in
app/models/opengraph/withFetch,Document,Location,Metadata::Fetching.
Writebook — book publishing
Auth & access
- Same cookie/session pattern as Campfire;
User::Roleismember/administrator. Book::Accessableuses anAccessjoin model with levelsreader/editor.Bookhas aneveryone_accessflag;Book.accessable_or_publishedshows published books or books the current user can access.Book#update_access(editors:, readers:)usesupsert_alland deletes any access not in the new set.- Join codes for account access. Books can be public or private.
Publishing flow
Bookhas apublishedboolean andhas_one_attached :cover.BooksController#ensure_editablechecksBook#editable?(editor access or admin).- Books render HTML or Markdown (
format.md).
Delegated types for content shapes
- A
Bookhas manyLeafrecords. ALeafis a container with position, status, slug. Leafusesdelegated_type :leafable, types: %w[ Page Section Picture ]. Each leafable type has its own table and logic, but shares ordering/search/status throughLeaf.- The
Leafableconcern gives each type the inversehas_one :leafand delegatestitle.
Leaf lifecycle
Book#press(leafable, leaf_params)creates a new leaf.Leaf::Editablerecords a new version only when the leafable body changes and the last edit is older than 10 minutes; it duplicates the leafable and re-attaches blobs.- Leaves can be trashed (
status: trashed) rather than deleted.
Ordering
Positionableconcern implements gap-based ordering with parent-level locking and automatic rebalancing when gaps get too small.
Markdown rendering
- Custom
ActionText::Markdownmodel inlib/rails_ext/action_text_markdown.rbwraps Redcarpet with extensions for autolink, fenced code, tables, strikethrough, etc. has_markdown :bodymacro inlib/rails_ext/action_text_has_markdown.rbadds a polymorphicmarkdown_bodyassociation and reader/writer methods.MarkdownRendereradds Rouge syntax highlighting, header anchor links, and lightbox-wrapped images.- Markdown attachments use
ActionText::Markdown::Uploads(has_many_attached :uploads).
Search
- SQLite FTS
leaf_search_indexwithtitleandcontentcolumns, maintained manually and ordered bybm25(leaf_search_index, 2.0).
Fizzy — kanban/issue tracking
Multi-tenancy & auth
- URL-path based tenancy:
/{account_id}/boards/.... MiddlewareAccountSlug::Extractorpulls the account slug, moves it fromPATH_INFOtoSCRIPT_NAME, and setsCurrent.account. - Every model has
account_id. Background jobs capture and restoreCurrent.accountautomatically. Identityis global (email).Useris per-account membership. One identity can belong to many accounts.User::Roleenum:owner/admin/member/system; admin scope includes owners.User::Accessorauto-grants access toall_accessboards and exposesaccessible_cards/accessible_commentsthrough board access.
Board / column / card domain
Boardis heavy on concerns:Accessible,AutoPostponing,Cards,Entropic,Filterable,Publishable,Triageable.Columnbelongs to board, has color/position, andcardsarenullifyon column delete.Cardstatus enumdrafted/published; sequentialnumberscoped to the account, assigned insideaccount.with_lock.Cardscopes:indexed_by(all/closed/not_now/stalled/postponing_soon/golden) andsorted_by(newest/oldest/latest).Card::Eventablecreates anEventon publish/title change and writes a system comment.Eventis polymorphic (eventable) with JSONparticulars. Events drive activity timeline, notifications, and webhooks.
Notifications
Notifier.for(source)dispatches by source type (Event,Mention).Notifier::CardEventNotifierpicks recipients per action:card_assigned→ assignees except creatorcard_published→ board watchers + assignees, excluding creator/mentioneescomment_created→ card watchers excluding creator/mentionees
Notificationis linked to acardand a polymorphicsource; it callsuser.bundle(self)when the user has email bundling enabled.Notification::Bundleaggregates unread notifications into a time window and is delivered by a recurring Solid Queue job every 30 minutes.
Filter system
Filteris a persisted query object withFields,Params,Resources,Summarized.Filter::Paramsnormalizes params and stores an MD5params_digestfor deduplication.Filter::Resourcesuses HABTM for tags, boards, assignees, creators, and closers.Filter#cardsbuilds the query by chaining scopes: index/sort, assignees, creators, boards, tags, creation/closure windows, terms (mentions), and columns.User::Filteringis a presenter that prepares the UI state (boards, tags, users, saved filters).
Import / export
Exportbase model attaches a zip and runsDataExportJob.Account::Export#populate_zipiteratesAccount::DataTransfer::Manifest, which defines an ordered list ofRecordSets (account, users, boards, columns, cards, comments, events, notifications, Active Storage blobs/attachments/files, etc.).Account::DataTransfer::RecordSetwrites one JSON file per record underdata/{table_name}/{id}.json, imports in batches of 100, checks ID integrity, and rejects imports that would conflict with existing records.Account::Importchecks or processes the zip, then reconcilescards_count, all-access board membership for the importer, and storage totals.ZipFileabstracts disk vs S3 streaming withZipKit; exports can stream directly to S3 via multipart upload.
Entropy & jobs
- Cards auto-postpone to “not now” after inactivity. Configurable at account and board level. A recurring Solid Queue job cleans stale cards hourly.
- Uses Solid Queue (database-backed, no Redis). Shallow job classes delegate to model methods.
_latermethods enqueue,_nowmethods do the synchronous work.- Recurring tasks configured in
config/recurring.yml.
Search & IDs
- 16-shard MySQL full-text search, sharded by account ID hash. Models in
app/models/search/. - UUIDv7 primary keys, base36-encoded as 25-character strings. Fixtures use deterministic older UUIDs so
.first/.lastbehave predictably in tests.
Deployment
- Kamal in production. OSS Docker image plus a private
fizzy-saasgem for billing.
Cross-cutting style and conventions
- Vanilla Rails, rich models. Controllers stay thin. Models expose intention-revealing methods like
@card.close,@board.cards.create!,@card.gild. - REST resources over custom actions. Closing a card is
Cards::ClosuresController#create, notpost :close. - Concerns for behavior slices.
Card::Commentable,Card::Closeable,Card::Entropic,Message::Searchable,User::Mentionable. - Expanded conditionals over guard clauses. Guard clauses only at the very top or when the body is large.
- Method ordering: class methods, public methods with
initializefirst, private methods — all ordered by invocation order. !only when there’s a non-!counterpart. Not just to signal mutation.- Test conventions: use fixtures,
_pathhelpers,assert_in_body, omit explicit{ render }inrespond_to.
What stands out
These apps don’t avoid Rails features — they use delegated types, polymorphic associations, concerns, Action Cable, Turbo, Solid Queue. But they avoid adding architectural layers until the domain demands it. The result is code that reads like the product it builds.
