This style guide was generated by Claude Code through deep analysis of the Fizzy codebase - 37signals' open-source project management tool.
Why Fizzy matters: While 37signals has long advocated for "vanilla Rails" and opinionated software design, their production codebases (Basecamp, HEY, etc.) have historically been closed source. Fizzy changes that. For the first time, developers can study a real 37signals/DHH-style Rails application - not just blog posts and conference talks, but actual production code with all its patterns, trade-offs, and deliberate omissions.
How this was created: Claude Code analyzed the entire codebase - routes, controllers, models, concerns, views, JavaScript, CSS, tests, and configuration. The goal was to extract not just what patterns are used, but why - inferring philosophy from implementation choices.
What you'll find:
- Patterns extracted from actual code (with file references)
- "Do this, not this" guidance based on observed conventions
- What's deliberately absent (often more revealing than what's present)
- Reusable code you can copy to your own projects
- Philosophy Overview
- Dependencies & What's Notably Absent
- Routing: Everything is CRUD
- Controller Design
- Controller Concerns: The Complete Catalog
- Model Layer & Concerns
- Authentication Without Devise
- State as Records, Not Booleans
- Views & Turbo/Hotwire Patterns
- Background Jobs
- Testing Approach
- What They Deliberately Avoid
- Naming Conventions
- Product Design Inferences
- HTTP Caching Patterns
- Multi-Tenancy Deep Dive
- Database Patterns
- Stimulus Controller Patterns
- Event Tracking & Activity System
- PORO Patterns
- API Design Patterns
- Error Handling & Validation
- Configuration & Environment
- Mailer Patterns
- Code Evolution Patterns
- Reusable Stimulus Controllers Catalog
- CSS Architecture
- View Helpers
- Fragment Caching Patterns
- Scope Naming Conventions
- PWA & Push Notifications
- Notable Gems They DO Use
- Model Callbacks
- CSP Configuration
- Summary
The 37signals approach can be summarized as: "Vanilla Rails is plenty." They maximize what Rails gives you out of the box, minimize dependencies, and resist abstractions until absolutely necessary.
Core principles:
- Rich domain models over service objects
- CRUD controllers over custom actions
- Concerns for horizontal code sharing
- Records as state over boolean columns
- Database-backed everything (no Redis)
- Build it yourself before reaching for gems
# Gemfile
# Core Rails (running edge!)
gem "rails", github: "rails/rails", branch: "main"
# Their Hotwire stack
gem "turbo-rails"
gem "stimulus-rails"
gem "importmap-rails"
gem "propshaft"
# Database-backed infrastructure (NO Redis!)
gem "solid_queue" # Jobs
gem "solid_cache" # Caching
gem "solid_cable" # WebSockets
# Their own gems
gem "geared_pagination"
gem "lexxy" # Rich text
gem "mittens" # Email
# Minimal, focused gems
gem "bcrypt" # Password hashing
gem "rqrcode" # QR codes
gem "redcarpet" # MarkdownDO NOT USE:
| Gem/Pattern | Why They Avoid It |
|---|---|
devise |
Auth is ~150 lines of custom code. Devise is overkill. |
pundit/cancancan |
Authorization lives in models (can_administer_card?) |
dry-rb gems |
Over-engineered for most Rails apps |
interactor/command |
Service objects are rarely needed |
view_component |
ERB partials are fine |
sidekiq |
Solid Queue uses the database (no Redis) |
redis |
Database-backed everything |
elasticsearch |
Custom sharded MySQL full-text search |
graphql |
REST with Turbo is sufficient |
rspec |
Minitest is simpler and faster |
Every action maps to a CRUD verb. When something doesn't fit, create a new resource.
# BAD: Custom actions on existing resource
resources :cards do
post :close
post :reopen
post :archive
post :gild
end
# GOOD: New resources for each state change
resources :cards do
resource :closure # POST to close, DELETE to reopen
resource :goldness # POST to gild, DELETE to ungild
resource :not_now # POST to postpone
resource :pin # POST to pin, DELETE to unpin
resource :watch # POST to watch, DELETE to unwatch
end# config/routes.rb
resources :cards do
scope module: :cards do
resource :board # Moving card to different board
resource :closure # Closing/reopening
resource :column # Assigning to workflow column
resource :goldness # Highlighting as important
resource :image # Managing header image
resource :not_now # Postponing
resource :pin # Pinning to sidebar
resource :publish # Publishing draft
resource :reading # Marking as read
resource :triage # Triaging
resource :watch # Subscribing to updates
resources :assignments # Managing assignees
resources :steps # Checklist items
resources :taggings # Tags
resources :comments do
resources :reactions # Emoji reactions
end
end
end# Board-specific resources
resources :boards do
scope module: :boards do
resource :publication # Publishing publicly
resource :entropy # Auto-postpone settings
resource :involvement # User's involvement level
namespace :columns do
resource :not_now # "Not Now" pseudo-column
resource :stream # Main stream view
resource :closed # Closed cards view
end
end
end# Make polymorphic_url work correctly for nested resources
resolve "Comment" do |comment, options|
options[:anchor] = ActionView::RecordIdentifier.dom_id(comment)
route_for :card, comment.card, options
end
resolve "Notification" do |notification, options|
polymorphic_url(notification.notifiable_target, options)
endControllers should be thin orchestrators. Business logic lives in models.
# GOOD: Controller just orchestrates
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close # All logic in model
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
def destroy
@card.reopen # All logic in model
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end# BAD: Business logic in controller
class Cards::ClosuresController < ApplicationController
def create
@card.transaction do
@card.create_closure!(user: Current.user)
@card.events.create!(action: :closed, creator: Current.user)
@card.watchers.each { |w| NotificationMailer.card_closed(w, @card).deliver_later }
end
end
end# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card, :set_board
end
private
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
def set_board
@board = @card.board
end
def render_card_replacement
render turbo_stream: turbo_stream.replace(
[@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: @card.reload }
)
end
end# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include Authentication
include Authorization
include BlockSearchEngineIndexing
include CurrentRequest, CurrentTimezone, SetPlatform
include RequestForgeryProtection
include TurboFlash, ViewTransitions
include RoutingHeaders
etag { "v1" }
stale_when_importmap_changes
allow_browser versions: :modern
end# Controller checks permission
class CardsController < ApplicationController
before_action :ensure_permission_to_administer_card, only: [:destroy]
private
def ensure_permission_to_administer_card
head :forbidden unless Current.user.can_administer_card?(@card)
end
end
# Model defines what permission means
class User < ApplicationRecord
def can_administer_card?(card)
admin? || card.creator == self
end
def can_administer_board?(board)
admin? || board.creator == self
end
endController concerns are the secret sauce of 37signals controllers. They create a vocabulary of reusable behaviors that compose beautifully. Here's every concern and when to use it.
These concerns handle loading parent resources for nested controllers.
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card, :set_board
end
private
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
def set_board
@board = @card.board
end
def render_card_replacement
render turbo_stream: turbo_stream.replace(
[@card, :card_container],
partial: "cards/container",
method: :morph,
locals: { card: @card.reload }
)
end
endUsage Pattern:
# Any controller nested under cards uses this
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end
end
class Cards::WatchesController < ApplicationController
include CardScoped
def create
@card.watch_by Current.user
# ...
end
end
class Cards::PinsController < ApplicationController
include CardScoped
def create
@pin = @card.pin_by Current.user
# ...
end
endKey insight: The concern provides render_card_replacement - a shared way to update the card UI. This is critical for consistency across all card actions.
# app/controllers/concerns/board_scoped.rb
module BoardScoped
extend ActiveSupport::Concern
included do
before_action :set_board
end
private
def set_board
@board = Current.user.boards.find(params[:board_id])
end
def ensure_permission_to_admin_board
unless Current.user.can_administer_board?(@board)
head :forbidden
end
end
endUsage: Any controller under boards/ uses this.
class Boards::ColumnsController < ApplicationController
include BoardScoped
def create
@column = @board.columns.create!(column_params)
# ...
end
end
class Boards::PublicationsController < ApplicationController
include BoardScoped
before_action :ensure_permission_to_admin_board
def create
@board.publish
end
end# app/controllers/concerns/column_scoped.rb
module ColumnScoped
extend ActiveSupport::Concern
included do
before_action :set_column
end
private
def set_column
@column = Current.user.accessible_columns.find(params[:column_id])
end
endUsage:
class Columns::LeftPositionsController < ApplicationController
include ColumnScoped
def create
@left_column = @column.left_column
@column.move_left
end
endThese concerns capture and propagate request context.
# app/controllers/concerns/current_request.rb
module CurrentRequest
extend ActiveSupport::Concern
included do
before_action do
Current.http_method = request.method
Current.request_id = request.uuid
Current.user_agent = request.user_agent
Current.ip_address = request.ip
Current.referrer = request.referrer
end
end
endWhy this matters: Models and jobs can access request context via Current without parameter passing. For example, logging who created something:
class Signup
def create_identity
Identity.create!(
email_address: email_address,
# These come from Current, not parameters!
ip_address: Current.ip_address,
user_agent: Current.user_agent
)
end
end# app/controllers/concerns/current_timezone.rb
# FIXME: This should move upstream to Rails. It's a good pattern.
module CurrentTimezone
extend ActiveSupport::Concern
included do
around_action :set_current_timezone
helper_method :timezone_from_cookie
etag { timezone_from_cookie }
end
private
def set_current_timezone(&)
Time.use_zone(timezone_from_cookie, &)
end
def timezone_from_cookie
@timezone_from_cookie ||= begin
timezone = cookies[:timezone]
ActiveSupport::TimeZone[timezone] if timezone.present?
end
end
endKey patterns:
around_actionwraps the entire request in the user's timezoneetagincludes timezone - different timezones get different cached responseshelper_methodmakes it available in views- Cookie is set client-side by JavaScript detecting the user's timezone
# app/controllers/concerns/set_platform.rb
module SetPlatform
extend ActiveSupport::Concern
included do
helper_method :platform
end
private
def platform
@platform ||= ApplicationPlatform.new(request.user_agent)
end
endUsage in views:
<% if platform.mobile? %>
<%= render "mobile_nav" %>
<% else %>
<%= render "desktop_nav" %>
<% end %># app/controllers/concerns/filter_scoped.rb
module FilterScoped
extend ActiveSupport::Concern
included do
before_action :set_filter
before_action :set_user_filtering
end
private
def set_filter
if params[:filter_id].present?
@filter = Current.user.filters.find(params[:filter_id])
else
@filter = Current.user.filters.from_params(filter_params)
end
end
def filter_params
params.reverse_merge(**Filter.default_values)
.permit(*Filter::PERMITTED_PARAMS)
end
def set_user_filtering
@user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param)
end
endThe Filter model does the heavy lifting:
class Filter < ApplicationRecord
def cards
result = creator.accessible_cards.preloaded.published
result = result.indexed_by(indexed_by)
result = result.sorted_by(sorted_by)
result = result.where(board: boards.ids) if boards.present?
result = result.tagged_with(tags.ids) if tags.present?
result = result.assigned_to(assignees.ids) if assignees.present?
# ... more filtering
result.distinct
end
endPattern: Filters are persisted! Users can save and name their filters.
# app/controllers/concerns/block_search_engine_indexing.rb
module BlockSearchEngineIndexing
extend ActiveSupport::Concern
included do
after_action :block_search_engine_indexing
end
private
def block_search_engine_indexing
headers["X-Robots-Tag"] = "none"
end
endWhy: Private app content shouldn't appear in search results, even if someone links to it.
# app/controllers/concerns/request_forgery_protection.rb
module RequestForgeryProtection
extend ActiveSupport::Concern
included do
after_action :append_sec_fetch_site_to_vary_header
end
private
def append_sec_fetch_site_to_vary_header
vary_header = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:blank?)
response.headers["Vary"] = (vary_header + ["Sec-Fetch-Site"]).join(",")
end
def verified_request?
request.get? || request.head? || !protect_against_forgery? ||
(valid_request_origin? && safe_fetch_site?)
end
SAFE_FETCH_SITES = %w[same-origin same-site]
def safe_fetch_site?
SAFE_FETCH_SITES.include?(sec_fetch_site_value) ||
(sec_fetch_site_value.nil? && api_request?)
end
def api_request?
request.format.json?
end
endModern approach: Uses Sec-Fetch-Site header instead of tokens. Browsers set this automatically - can't be spoofed by JavaScript.
# app/controllers/concerns/turbo_flash.rb
module TurboFlash
extend ActiveSupport::Concern
included do
helper_method :turbo_stream_flash
end
private
def turbo_stream_flash(**flash_options)
turbo_stream.replace(:flash, partial: "layouts/shared/flash", locals: { flash: flash_options })
end
endUsage in controller:
def create
@comment = @card.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.append(:comments, @comment),
turbo_stream_flash(notice: "Comment added!")
]
end
end
end# app/controllers/concerns/view_transitions.rb
# FIXME: Upstream this fix to turbo-rails
module ViewTransitions
extend ActiveSupport::Concern
included do
before_action :disable_view_transitions, if: :page_refresh?
end
private
def disable_view_transitions
@disable_view_transition = true
end
def page_refresh?
request.referrer.present? && request.referrer == request.url
end
endWhy: View transitions on page refresh look weird. This disables them automatically.
Here's how these concerns compose in practice:
# A full-featured nested controller
class Cards::AssignmentsController < ApplicationController
include CardScoped # Gets @card, @board, render_card_replacement
def new
@assigned_to = @card.assignees.active.alphabetically.where.not(id: Current.user)
@users = @board.users.active.alphabetically.where.not(id: @card.assignees)
fresh_when etag: [@users, @card.assignees] # HTTP caching!
end
def create
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
respond_to do |format|
format.turbo_stream
format.json { head :no_content }
end
end
end# A timeline controller composing multiple concerns
class Events::Days::ColumnsController < ApplicationController
include DayTimelinesScoped # Which includes FilterScoped
def show
@column = @board.columns.find(params[:id])
end
end-
Concerns can include other concerns:
module DayTimelinesScoped include FilterScoped # Inherits all of FilterScoped # ... end
-
Use
before_actioninincludedblock:included do before_action :set_card end
-
Provide shared private methods:
def render_card_replacement # Reusable across all CardScoped controllers end
-
Use
helper_methodfor view access:included do helper_method :platform, :timezone_from_cookie end
-
Add to
etagfor HTTP caching:included do etag { timezone_from_cookie } end
Models include many concerns, each handling one aspect:
# app/models/card.rb
class Card < ApplicationRecord
include Assignable, Attachments, Broadcastable, Closeable, Colored,
Entropic, Eventable, Exportable, Golden, Mentions, Multistep,
Pinnable, Postponable, Promptable, Readable, Searchable, Stallable,
Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :comments, dependent: :destroy
has_one_attached :image, dependent: :purge_later
has_rich_text :description
# Minimal model code - behavior is in concerns
endEach concern is self-contained with associations, scopes, and methods:
# app/models/card/closeable.rb
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
end
def closed?
closure.present?
end
def open?
!closed?
end
def closed_by
closure&.user
end
def close(user: Current.user)
unless closed?
transaction do
create_closure! user: user
track_event :closed, creator: user
end
end
end
def reopen(user: Current.user)
if closed?
transaction do
closure&.destroy
track_event :reopened, creator: user
end
end
end
endclass Card < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :creator, class_name: "User", default: -> { Current.user }
end
class Comment < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :creator, class_name: "User", default: -> { Current.user }
end# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :identity, :account
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
def session=(value)
super(value)
self.identity = session.identity if value.present?
end
def identity=(identity)
super(identity)
self.user = identity.users.find_by(account: account) if identity.present?
end
end# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :require_account
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
before_action :resume_session, **options
end
end
private
def authenticated?
Current.identity.present?
end
def require_authentication
resume_session || authenticate_by_bearer_token || request_authentication
end
def resume_session
if session = find_session_by_cookie
set_current_session session
end
end
def find_session_by_cookie
Session.find_signed(cookies.signed[:session_token])
end
def start_new_session_for(identity)
identity.sessions.create!(
user_agent: request.user_agent,
ip_address: request.remote_ip
).tap { |session| set_current_session session }
end
def set_current_session(session)
Current.session = session
cookies.signed.permanent[:session_token] = {
value: session.signed_id,
httponly: true,
same_site: :lax
}
end
end# app/models/session.rb
class Session < ApplicationRecord
belongs_to :identity
end# app/models/magic_link.rb
class MagicLink < ApplicationRecord
CODE_LENGTH = 6
EXPIRATION_TIME = 15.minutes
belongs_to :identity
enum :purpose, %w[sign_in sign_up], prefix: :for, default: :sign_in
scope :active, -> { where(expires_at: Time.current...) }
scope :stale, -> { where(expires_at: ..Time.current) }
before_validation :generate_code, on: :create
before_validation :set_expiration, on: :create
def self.consume(code)
active.find_by(code: Code.sanitize(code))&.consume
end
def consume
destroy
self
end
endInstead of closed: boolean, create a separate record. This gives you:
- Timestamp of when it happened
- Who did it
- Easy scoping via
joinsandwhere.missing
# BAD: Boolean column
class Card < ApplicationRecord
# closed: boolean column in cards table
scope :closed, -> { where(closed: true) }
scope :open, -> { where(closed: false) }
end
# GOOD: Separate record
class Closure < ApplicationRecord
belongs_to :card, touch: true
belongs_to :user, optional: true
# created_at gives you when
# user gives you who
end
class Card < ApplicationRecord
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
def closed?
closure.present?
end
end# Closure - tracks when/who closed a card
class Closure < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :user, optional: true
end
# Goldness - marks a card as "golden" (important)
class Card::Goldness < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
end
# NotNow - marks a card as postponed
class Card::NotNow < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :user, optional: true
end
# Publication - marks a board as publicly published
class Board::Publication < ApplicationRecord
belongs_to :account, default: -> { board.account }
belongs_to :board
has_secure_token :key # The public URL key
end# Finding open vs closed
Card.open # where.missing(:closure)
Card.closed # joins(:closure)
# Finding golden cards first
Card.with_golden_first # left_outer_joins(:goldness).order(...)
# Finding active vs postponed
Card.active # open.published.where.missing(:not_now)
Card.postponed # open.published.joins(:not_now)<%# app/views/cards/comments/create.turbo_stream.erb %>
<%= turbo_stream.before [@card, :new_comment],
partial: "cards/comments/comment",
locals: { comment: @comment } %>
<%= turbo_stream.update [@card, :new_comment],
partial: "cards/comments/new",
locals: { card: @card } %><%# app/views/cards/update.turbo_stream.erb %>
<%= turbo_stream.replace dom_id(@card, :card_container),
partial: "cards/container",
method: :morph,
locals: { card: @card.reload } %><%# app/views/cards/show.html.erb %>
<%= turbo_stream_from @card %>
<%= turbo_stream_from @card, :activity %>
<div data-controller="beacon" data-beacon-url-value="<%= card_reading_path(@card) %>">
<%= render "cards/container", card: @card %>
<%= render "cards/messages", card: @card %>
</div><%# Use standard partials %>
<%= render "cards/container", card: @card %>
<%= render "cards/display/perma/meta", card: @card %>
<%# With caching %>
<% cache card do %>
<section id="<%= dom_id(card, :card_container) %>">
<%= render "cards/container/content", card: card %>
</section>
<% end %>// app/javascript/controllers/auto_submit_controller.js
// Single-purpose: auto-submit forms
// app/javascript/controllers/dialog_controller.js
// Single-purpose: manage dialogs
// app/javascript/controllers/beacon_controller.js
// Single-purpose: track views/readsJobs just call model methods:
# app/jobs/notify_recipients_job.rb
class NotifyRecipientsJob < ApplicationJob
def perform(notifiable)
notifiable.notify_recipients
end
end
# The model does the work
module Notifiable
def notify_recipients
Notifier.for(self)&.notify
end
endmodule Card::Readable
def mark_as_read_later(user:)
MarkCardAsReadJob.perform_later(self, user)
end
def mark_as_read_now(user:)
# Actual implementation
readings.find_or_create_by!(user: user).touch
end
endNo Redis. Jobs stored in the database:
# config/recurring.yml
production:
deliver_bundled_notifications:
command: "Notification::Bundle.deliver_all_later"
schedule: every 30 minutes
auto_postpone_all_due:
command: "Card.auto_postpone_all_due"
schedule: every hour at minute 50
cleanup_magic_links:
command: "MagicLink.cleanup"
schedule: every 4 hours# test/models/card_test.rb
class CardTest < ActiveSupport::TestCase
setup do
Current.session = sessions(:david)
end
test "create assigns a number to the card" do
card = Card.create!(title: "Test", board: boards(:writebook), creator: users(:david))
assert_equal accounts("37s").reload.cards_count, card.number
end
test "closed" do
assert_equal [cards(:shipping)], Card.closed
end
end# test/controllers/cards/closures_controller_test.rb
class Cards::ClosuresControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
card = cards(:logo)
assert_changes -> { card.reload.closed? }, from: false, to: true do
post card_closure_path(card), as: :turbo_stream
end
end
test "destroy as JSON" do
card = cards(:shipping)
assert card.closed?
delete card_closure_path(card), as: :json
assert_response :no_content
assert_not card.reload.closed?
end
end# test/fixtures/cards.yml
logo:
account: 37s
board: writebook
creator: david
title: "Logo Design"
number: 1
status: published
shipping:
account: 37s
board: writebook
creator: david
title: "Shipping"
number: 2
status: published# BAD: Service object pattern
class CloseCardService
def initialize(card, user)
@card = card
@user = user
end
def call
@card.transaction do
@card.create_closure!(user: @user)
@card.track_event(:closed)
end
end
end
# GOOD: Method on model
class Card < ApplicationRecord
def close(user: Current.user)
transaction do
create_closure!(user: user)
track_event :closed, creator: user
end
end
end# Exception: Signup is form-like but still an ActiveModel
class Signup
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
attr_accessor :full_name, :email_address, :identity
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
def create_identity
@identity = Identity.find_or_create_by!(email_address: email_address)
@identity.send_magic_link for: :sign_up
end
def complete
if valid?(:completion)
create_account
true
end
end
endView helpers and partials handle presentation:
# app/helpers/cards_helper.rb
module CardsHelper
def card_article_tag(card, **options, &block)
classes = [
options.delete(:class),
("golden-effect" if card.golden?),
("card--postponed" if card.postponed?)
].compact.join(" ")
tag.article(class: classes, **options, &block)
end
endREST with Turbo is sufficient. API is JSON only when needed:
def create
@card.close
respond_to do |format|
format.turbo_stream { render_card_replacement }
format.json { head :no_content }
end
end# GOOD: Action verbs
card.close
card.reopen
card.gild
card.ungild
card.postpone
card.resume
board.publish
board.unpublish
# BAD: Set-style methods
card.set_closed(true)
card.update_status(:closed)# GOOD: Question methods
card.closed?
card.open?
card.golden?
card.postponed?
card.active?
card.entropic?
# Derived from presence of related record
def closed?
closure.present?
end
def golden?
goldness.present?
endConcerns are adjectives describing capability:
Closeable- can be closedPublishable- can be publishedWatchable- can be watchedAssignable- can be assignedSearchable- can be searchedEventable- tracks events
Controllers are nouns matching the resource:
Cards::ClosuresController- manages card closuresCards::GoldnessesController- manages card goldnessBoards::PublicationsController- manages board publications
Based on the codebase, here are product decisions that emerge from technical constraints:
Cards automatically get "postponed" after inactivity. This is technically simple (a recurring job + a database record) but creates a product feature that keeps todo lists from becoming graveyards.
# Simple to implement
class Card::Entropic
class_methods do
def auto_postpone_all_due
due_to_be_postponed.find_each do |card|
card.auto_postpone(user: card.account.system_user)
end
end
end
endBecause states are records (not booleans), the UI can show:
- When something happened
- Who did it
- Filter by actor
<%= "Closed #{time_ago_in_words(card.closed_at)} by #{card.closed_by.name}" %>Account ID in URL (/12345678/boards/...) makes:
- Deep linking work naturally
- Sharing URLs include context
- No subdomain DNS complexity
Magic links mean:
- No password reset flows
- No password storage liability
- Works across devices (email is the transfer mechanism)
Binary "golden" state is simpler than priority numbers. Forces decisions: is this important or not? No priority 1 vs 2 vs 3 debates.
No Redis means:
- One fewer infrastructure component
- Transactions work across jobs/cache/websockets
- Simpler backup/restore
37signals uses HTTP caching extensively via ETags and conditional GET requests.
Controllers declare cache dependencies with fresh_when:
class Cards::AssignmentsController < ApplicationController
def new
@assigned_to = @card.assignees.active.alphabetically
@users = @board.users.active.alphabetically.where.not(id: @card.assignees)
fresh_when etag: [@users, @card.assignees] # Returns 304 if unchanged
end
end
class Cards::WatchesController < ApplicationController
def show
fresh_when etag: @card.watch_for(Current.user) || "none"
end
end
class Boards::ColumnsController < ApplicationController
def show
set_page_and_extract_portion_from @column.cards.active.latest
fresh_when etag: @page.records
end
endclass ApplicationController < ActionController::Base
etag { "v1" } # Bump to bust all caches on deploy
endmodule CurrentTimezone
included do
etag { timezone_from_cookie }
end
end
module Authentication
included do
etag { Current.identity.id if authenticated? }
end
endWhy timezone affects caching:
Pages display times like "Created 2 hours ago" or "Dec 14, 3:00 PM". These are rendered server-side in the user's timezone (via Time.use_zone). If you cache the HTML and serve it to everyone:
- User in NYC sees "3:00 PM" ✓
- User in London gets same cached response, sees "3:00 PM" ✗ (should be "8:00 PM")
By including timezone in the ETag:
- NYC user gets ETag
"abc123-America/New_York" - London user's
If-None-Matchdoesn't match → gets fresh response - Each timezone gets its own cached version
Same logic for Current.identity.id - personalized content ("You commented...") can't be shared across users.
fresh_when etag: [@board, @page.records, @user_filtering]
fresh_when etag: [@filters, @boards, @tags, @users]URL-based multi-tenancy: /{account_id}/boards/...
# config/initializers/tenanting/account_slug.rb
module AccountSlug
PATTERN = /(\d{7,})/ # 7+ digit account IDs
PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/
class Extractor
def call(env)
request = ActionDispatch::Request.new(env)
if request.path_info =~ PATH_INFO_MATCH
# Move /{account_id} from PATH_INFO to SCRIPT_NAME
# Rails thinks it's "mounted" at this path
request.engine_script_name = request.script_name = $1
request.path_info = $'.empty? ? "/" : $'
env["fizzy.external_account_id"] = $2.to_i
end
if env["fizzy.external_account_id"]
account = Account.find_by(external_account_id: env["fizzy.external_account_id"])
Current.with_account(account) { @app.call env }
else
Current.without_account { @app.call env }
end
end
end
endBenefits:
- No subdomain DNS complexity
- Deep links work naturally
Current.accountavailable everywhere- URL helpers auto-include account prefix
create_table "cards", id: :uuid do |t|
t.uuid "account_id", null: false
t.uuid "board_id", null: false
t.uuid "creator_id", null: false
endclass Comment < ApplicationRecord
belongs_to :account, default: -> { card.account }
endConstraints removed for flexibility:
class RemoveAllForeignKeyConstraints < ActiveRecord::Migration[8.2]
def change
# All foreign keys removed
end
endclass AddPurposeToMagicLinks < ActiveRecord::Migration[8.2]
def change
add_column :magic_links, :purpose, :string, default: "sign_in"
end
end52 controllers split: 62% reusable, 38% domain-specific.
// toggle_class_controller.js
export default class extends Controller {
static classes = ["toggle"]
toggle() { this.element.classList.toggle(this.toggleClass) }
}
// copy_to_clipboard_controller.js
export default class extends Controller {
static values = { content: String }
async copy(e) {
e.preventDefault()
await navigator.clipboard.writeText(this.contentValue)
}
}// drag_and_drop_controller.js
export default class extends Controller {
static targets = ["item", "container"]
static classes = ["draggedItem", "hoverContainer"]
async drop(event) {
const container = this.#containerContaining(event.target)
await this.#submitDropRequest(this.dragItem, container)
}
}- Single responsibility - One behavior per controller
- Configuration via values/classes -
static values,static classes - Events for communication -
this.dispatch("show") - Private methods with
#-this.#handleSubmitEnd() - Small file size - Most under 50 lines
Events are the single source of truth for activity:
class Event < ApplicationRecord
include Notifiable, Particulars, Promptable
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User"
belongs_to :eventable, polymorphic: true # Card, Comment, etc.
after_create -> { eventable.event_was_created(self) }
after_create_commit :dispatch_webhooks
endmodule Eventable
extend ActiveSupport::Concern
included do
has_many :events, as: :eventable, dependent: :destroy
end
def track_event(action, creator: Current.user, board: self.board, **particulars)
if should_track_event?
board.events.create!(
action: "#{eventable_prefix}_#{action}",
creator:, board:, eventable: self,
particulars:
)
end
end
endmodule Card::Eventable
include ::Eventable
included do
after_save :track_title_change, if: :saved_change_to_title?
end
def event_was_created(event)
transaction do
create_system_comment_for(event)
touch_last_active_at unless was_just_published?
end
end
private
def track_title_change
if title_before_last_save.present?
track_event "title_changed", particulars: {
old_title: title_before_last_save,
new_title: title
}
end
end
endPERMITTED_ACTIONS = %w[
card_assigned
card_closed
card_postponed
card_auto_postponed
card_board_changed
card_published
card_reopened
card_sent_back_to_triage
card_triaged
card_unassigned
comment_created
]module Event::Particulars
included do
store_accessor :particulars, :assignee_ids
end
def assignees
@assignees ||= User.where(id: assignee_ids)
end
end
# Usage
track_event "title_changed", particulars: {
old_title: "Old",
new_title: "New"
}class Webhook < ApplicationRecord
PERMITTED_ACTIONS = %w[card_assigned card_closed ...]
has_many :deliveries, dependent: :delete_all
serialize :subscribed_actions, type: Array, coder: JSON
def for_slack?
url.match? %r{//hooks\.slack\.com/services/}i
end
def for_campfire?
url.match? %r{/rooms/\d+/\d+-[^\/]+/messages\Z}i
end
endPOROs are namespaced under their parent model:
app/models/
├── event.rb
├── event/
│ ├── description.rb # PORO
│ ├── particulars.rb # Concern
│ └── promptable.rb # Concern
├── card.rb
├── card/
│ ├── eventable/
│ │ └── system_commenter.rb # PORO
│ ├── closeable.rb # Concern
│ ├── golden.rb # Concern
│ └── goldness.rb # ActiveRecord
class Event::Description
include ActionView::Helpers::TagHelper
include ERB::Util
attr_reader :event, :user
def initialize(event, user)
@event, @user = event, user
end
def to_html
to_sentence(creator_tag, card_title_tag).html_safe
end
def to_plain_text
to_sentence(creator_name, card.title)
end
private
def action_sentence(creator, card_title)
case event.action
when "card_closed"
%(#{creator} moved #{card_title} to "Done")
when "card_reopened"
"#{creator} reopened #{card_title}"
# ...
end
end
endclass Card::Eventable::SystemCommenter
include ERB::Util
attr_reader :card, :event
def initialize(card, event)
@card, @event = card, event
end
def comment
return unless comment_body.present?
card.comments.create!(
creator: card.account.system_user,
body: comment_body,
created_at: event.created_at
)
end
private
def comment_body
case event.action
when "card_closed"
"<strong>Moved</strong> to "Done" by #{creator_name}"
# ...
end
end
endclass User::Filtering
attr_reader :user, :filter, :expanded
delegate :as_params, :single_board, to: :filter
def initialize(user, filter, expanded: false)
@user, @filter, @expanded = user, filter, expanded
end
def boards
@boards ||= user.boards.ordered_by_recently_accessed
end
def cache_key
ActiveSupport::Cache.expand_cache_key(
[user, filter, expanded?, boards, tags, users, filters],
"user-filtering"
)
end
end- Presentation logic -
Event::Descriptionformats events for display - Complex operations -
SystemCommentercreates comments from events - View context bundling -
User::Filteringcollects filter UI state - NOT service objects - POROs are model-adjacent, not controller-adjacent
def create
@comment = @card.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream
format.json { head :created, location: card_comment_path(@card, @comment) }
end
end| Action | Success Code |
|---|---|
| Create | 201 Created + Location header |
| Update | 204 No Content |
| Delete | 204 No Content |
def authenticate_by_bearer_token
if token = request.authorization&.match(/^Bearer (.+)$/)&.[](1)
if access_token = AccessToken.find_by_token(token)
set_current_session_from_access_token(access_token)
end
end
endclass Account < ApplicationRecord
validates :name, presence: true # That's it
end
class Identity < ApplicationRecord
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
endclass Signup
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
validates :full_name, :identity, presence: true, on: :completion
enddef create
@comment = @card.comments.create!(comment_params) # Raises on failure
end# Use ENV.fetch with sensible defaults
ENV.fetch("PORT", 3000)
ENV.fetch("SMTP_PORT", "587").to_i
ENV.fetch("MULTI_TENANT", "true")
ENV.fetch("DATABASE_ADAPTER", saas? ? "mysql" : "sqlite")
# Only use ENV[] (no default) for optional values
ENV["MYSQL_PASSWORD"]
ENV["VAPID_PRIVATE_KEY"]# config/database.mysql.yml
default: &default
adapter: trilogy
host: <%= ENV.fetch("MYSQL_HOST", "127.0.0.1") %>
port: <%= ENV.fetch("MYSQL_PORT", "3306") %>
pool: 50
timeout: 5000
production:
primary:
<<: *default
database: fizzy_production
cable:
<<: *default
database: fizzy_production_cable
queue:
<<: *default
database: fizzy_production_queue
cache:
<<: *default
database: fizzy_production_cacheSeparate databases for:
- Primary - Main app data
- Cable - ActionCable/WebSocket messages
- Queue - Solid Queue jobs
- Cache - Solid Cache data
# config/database.sqlite.yml
production:
primary:
adapter: sqlite3
database: storage/production.sqlite3
pool: 5# lib/fizzy.rb
module Fizzy
def db_adapter
@db_adapter ||= DbAdapter.new ENV.fetch("DATABASE_ADAPTER", saas? ? "mysql" : "sqlite")
end
end
# config/database.yml
<%= ERB.new(File.read("config/database.#{Fizzy.db_adapter}.yml")).result %>Almost no explicit logging in app code:
# Only 2 logging calls in entire app/models:
Rails.logger.error error
Rails.logger.error error.backtrace.join("\n")Let Rails handle logging. Don't litter code with log statements.
# config/initializers/multi_tenant.rb
Rails.application.configure do
config.after_initialize do
Account.multi_tenant = ENV["MULTI_TENANT"] == "true" || config.x.multi_tenant.enabled == true
end
end| Variable | Default | Purpose |
|---|---|---|
DATABASE_ADAPTER |
sqlite/mysql |
Database type |
MULTI_TENANT |
true |
Enable multi-tenancy |
SMTP_ADDRESS |
- | SMTP server |
MAILER_FROM_ADDRESS |
[email protected] |
From address |
ACTIVE_STORAGE_SERVICE |
local |
Storage backend |
DISABLE_SSL |
false |
Disable SSL in prod |
SOLID_QUEUE_IN_PUMA |
- | Run jobs in web process |
class MagicLinkMailer < ApplicationMailer
def sign_in_instructions(magic_link)
@magic_link = magic_link
@identity = @magic_link.identity
mail to: @identity.email_address,
subject: "Your Fizzy code is #{@magic_link.code}"
end
end# config/recurring.yml
deliver_bundled_notifications:
command: "Notification::Bundle.deliver_all_later"
schedule: every 30 minutesAnalysis of the git history reveals how 37signals code evolves over time.
Tests are included in the same commit as features. Not strict TDD, but tests aren't added later either:
commit fa118a7 - "Add validation for the join code usage limit"
app/models/account/join_code.rb | 3 +++
app/controllers/account/join_codes_controller.rb | 7 +++++--
test/controllers/accounts/join_codes_controller_test.rb | 10 ++++++++++
The commit message focuses on the feature, but the test is there.
# commit 83360ec - "Escape the names used to generate system comments"
# The fix:
def creator_name
h event.creator.name # ERB::Util.h for escaping
end
# The test (same commit):
test "escapes html in comment body" do
users(:david).update! name: "<em>Injected</em>"
@card.toggle_assignment users(:kevin)
comment = @card.comments.last
html = comment.body.body.to_html
assert_includes html, "<em>Injected</em>" # Escaped!
refute_includes html, "<em>Injected</em>" # Not raw!
endThe storage tracking feature (commit 761d0b4) shipped with:
- 11 new model/concern files
- 2 jobs
- 2 migrations
- 7 test files with 1015+ lines of tests
All in one commit. Features ship complete.
Logic gets moved to better locations over time:
# Before: Controller concern checking environment
module SetTenant
def single_tenant?
ENV.fetch("SINGLE_TENANT", "false") == "true"
end
end
# After: Model concern with class method
module MultiTenant
class_methods do
def accepting_signups?
ENV.fetch("MULTI_TENANT", "true") == "true" || Account.none?
end
end
endThe pattern: Start simple, extract when patterns emerge.
When a pattern is established, older code gets updated to match:
# commit 5cfe693 - "Change reaction admin permission check to be in-line with other controllers"
# Before: Inline permission check
def destroy
@reaction = @comment.reactions.find(params[:id])
if Current.user != @reaction.reacter
head :forbidden
else
@reaction.destroy
end
end
# After: Matches pattern from other controllers
before_action :set_reaction, only: [:destroy]
before_action :ensure_permision_to_administer_reaction, only: [:destroy]
def destroy
@reaction.destroy
end
private
def set_reaction
@reaction = @comment.reactions.find(params[:id])
end
def ensure_permision_to_administer_reaction
head :forbidden if Current.user != @reaction.reacter
endCommit messages are concise and focus on the "what":
Add validation for the join code usage limitEscape the names used to generate system commentsWrap join code redemption in a lockRemove redundant includeRefactor: use idiomatic .last instead of .order(:desc).first
Not:
[FIZZY-1234] Add validation for join code usage limit to prevent integer overflow errors when users enter extremely large numbers
Race condition fix (commit c8a5d01) touched 1 file, 2 lines:
# Before
def redeem_if(&block)
yield if redeemable?
end
# After
def redeem_if(&block)
with_lock { yield if redeemable? }
end- Ship complete features - Code + tests + migrations in one commit
- Refactor toward consistency - When you establish a pattern, update old code
- Keep fixes small - Bug fixes should be surgical
- Tests prove the fix - Security/bug fixes include regression tests
- Start simple, extract later - Don't over-engineer upfront
These controllers are generic enough to copy into any Rails project. They demonstrate 37signals' approach: small, focused, dependency-free utilities.
Simple async clipboard API wrapper with visual feedback:
// app/javascript/controllers/copy_to_clipboard_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { content: String }
static classes = [ "success" ]
async copy(event) {
event.preventDefault()
this.reset()
try {
await navigator.clipboard.writeText(this.contentValue)
this.element.classList.add(this.successClass)
} catch {}
}
reset() {
this.element.classList.remove(this.successClass)
this.#forceReflow()
}
#forceReflow() {
this.element.offsetWidth
}
}Usage:
<button data-controller="copy-to-clipboard"
data-copy-to-clipboard-content-value="https://example.com/share"
data-copy-to-clipboard-success-class="copied"
data-action="click->copy-to-clipboard#copy">
Copy Link
</button>Clicks an element when it connects. Perfect for auto-submitting forms or auto-focusing:
// app/javascript/controllers/auto_click_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.click()
}
}Usage: <button data-controller="auto-click" data-action="..."> - triggers on page load.
Removes any element on action:
// app/javascript/controllers/element_removal_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
remove() {
this.element.remove()
}
}Usage: <div data-controller="element-removal"><button data-action="element-removal#remove">Dismiss</button></div>
Toggle, add, or remove CSS classes:
// app/javascript/controllers/toggle_class_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "toggle" ]
static targets = [ "checkbox" ]
toggle() {
this.element.classList.toggle(this.toggleClass)
}
add() {
this.element.classList.add(this.toggleClass)
}
remove() {
this.element.classList.remove(this.toggleClass)
}
checkAll() {
this.checkboxTargets.forEach(checkbox => checkbox.checked = true)
}
checkNone() {
this.checkboxTargets.forEach(checkbox => checkbox.checked = false)
}
}Auto-expands textareas as you type:
// app/javascript/controllers/autoresize_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { minHeight: { type: Number, default: 0 } }
connect() { this.resize() }
resize() {
this.element.style.height = "auto"
const newHeight = Math.max(this.minHeightValue, this.element.scrollHeight)
this.element.style.height = `${newHeight}px`
}
reset() {
this.element.style.height = "auto"
}
}Usage:
<textarea data-controller="autoresize"
data-autoresize-min-height-value="100"
data-action="input->autoresize#resize"></textarea>Bind keyboard shortcuts to elements:
// app/javascript/controllers/hotkey_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
click(event) {
if (this.#isClickable && !this.#shouldIgnore(event)) {
event.preventDefault()
this.element.click()
}
}
focus(event) {
if (this.#isClickable && !this.#shouldIgnore(event)) {
event.preventDefault()
this.element.focus()
}
}
#shouldIgnore(event) {
return event.defaultPrevented || event.target.closest("input, textarea, lexxy-editor")
}
get #isClickable() {
return getComputedStyle(this.element).pointerEvents !== "none"
}
}Usage: <button data-controller="hotkey" data-action="keydown.n@window->hotkey#click">New Card (N)</button>
Format UTC timestamps in the user's timezone:
// app/javascript/controllers/local_time_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
datetime: String,
format: { type: String, default: "time-or-date" }
}
connect() { this.render() }
render() {
const datetime = new Date(this.datetimeValue)
const formattedDate = this.#format(datetime)
this.element.textContent = formattedDate
}
#format(datetime) {
const now = new Date()
const isToday = datetime.toDateString() === now.toDateString()
switch (this.formatValue) {
case "time-or-date":
return isToday ? this.#formatTime(datetime) : this.#formatDate(datetime)
case "time":
return this.#formatTime(datetime)
case "date":
return this.#formatDate(datetime)
default:
return datetime.toLocaleString()
}
}
#formatTime(datetime) {
return datetime.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
}
#formatDate(datetime) {
return datetime.toLocaleDateString(undefined, { month: "short", day: "numeric" })
}
}Lazy load content when element scrolls into view:
// app/javascript/controllers/fetch_on_visible_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = { url: String, threshold: { type: Number, default: 0.1 } }
connect() {
this.observer = new IntersectionObserver(
entries => entries.forEach(entry => { if (entry.isIntersecting) this.fetch() }),
{ threshold: this.thresholdValue }
)
this.observer.observe(this.element)
}
disconnect() {
this.observer?.disconnect()
}
async fetch() {
this.observer.disconnect()
const response = await fetch(this.urlValue)
this.element.innerHTML = await response.text()
}
}Native HTML <dialog> management with modal/non-modal support:
// app/javascript/controllers/dialog_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "dialog" ]
static values = { modal: { type: Boolean, default: false } }
connect() {
this.dialogTarget.setAttribute("aria-hidden", "true")
}
open() {
if (this.modalValue) {
this.dialogTarget.showModal()
} else {
this.dialogTarget.show()
}
this.dialogTarget.setAttribute("aria-hidden", "false")
this.dispatch("show")
}
toggle() {
this.dialogTarget.open ? this.close() : this.open()
}
close() {
this.dialogTarget.close()
this.dialogTarget.setAttribute("aria-hidden", "true")
this.dispatch("close")
}
closeOnClickOutside({ target }) {
if (!this.element.contains(target)) this.close()
}
}Simple image lightbox using native <dialog>:
// app/javascript/controllers/lightbox_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "caption", "dialog", "zoomedImage" ]
open(event) {
this.dialogTarget.showModal()
this.#set(event.target.closest("a"))
}
handleTransitionEnd(event) {
if (event.target === this.dialogTarget && !this.dialogTarget.open) {
this.reset()
}
}
reset() {
this.zoomedImageTarget.src = ""
this.captionTarget.innerText = ""
}
#set(target) {
this.zoomedImageTarget.src = target.href
const caption = target.dataset.lightboxCaptionValue
if (caption) this.captionTarget.innerText = caption
}
}Full keyboard navigation for lists - arrow keys, enter to select, nested lists:
Key features:
- Arrow key navigation (configurable vertical/horizontal)
- Enter to activate items
- Auto-scroll to keep selection visible
- Nested list support
- ARIA-compliant (aria-selected, aria-activedescendant)
Too long to include inline, but essential for any accessible list UI.
Auto-save form content to localStorage, restore on page load:
// Key features:
// - Debounced saves (300ms)
// - Clears on successful submit
// - Restores content on page load
// - Unique key per form
static values = { key: String }
save() {
const content = this.inputTarget.value
if (content) {
localStorage.setItem(this.keyValue, content)
} else {
localStorage.removeItem(this.keyValue)
}
}
submit({ detail: { success } }) {
if (success) localStorage.removeItem(this.keyValue)
}Form utilities including:
- Debounced submission
- IME composition handling (for CJK input)
- Input validation before submit
- Duplicate submission prevention
Full drag-and-drop between containers with:
- Counter updates
- CSS variable transfer (for visual feedback)
- Turbo Stream integration
- Automatic position management
- Stimulus Values for Configuration - Not data attributes parsed manually
- Stimulus Classes for Styling - CSS classes are configurable
- Dispatch for Communication -
this.dispatch("show")lets parent controllers listen - Private Methods with # - Modern JS private fields
- Early Returns -
if (!condition) returnfor guard clauses - No Dependencies - Vanilla JS, no jQuery, no utilities
Fizzy uses vanilla CSS with cutting-edge features - no Sass, no PostCSS, no Tailwind. The entire codebase is ~8,100 lines of CSS across 56 files.
The foundation is CSS @layer for cascade control:
/* _global.css */
@layer reset, base, components, modules, utilities;This ensures predictable specificity:
reset- Lowest priority, normalize browser defaultsbase- Global element styles (body, a, kbd)components- Reusable UI patterns (.btn, .card, .input)modules- Page-specific stylesutilities- Highest priority, single-purpose classes
Colors use OKLCH (Oklab Lightness Chroma Hue) - a perceptually uniform color space:
:root {
/* Define colors as LCH components */
--lch-blue-dark: 57.02% 0.1895 260.46;
--lch-blue-medium: 66% 0.196 257.82;
--lch-blue-light: 84.04% 0.0719 255.29;
/* Use oklch() to create colors */
--color-link: oklch(var(--lch-blue-dark));
--color-selected: oklch(var(--lch-blue-lighter));
}Benefits:
- Perceptually uniform - Equal steps in lightness look equal
- P3 gamut support - Wider color range on modern displays
- Easy theming - Flip lightness values for dark mode
Dark mode is achieved by redefining OKLCH values:
/* Light mode (default) */
:root {
--lch-ink-darkest: 26% 0.05 264; /* Dark text */
--lch-canvas: 100% 0 0; /* White background */
}
/* Dark mode */
html[data-theme="dark"] {
--lch-ink-darkest: 96.02% 0.0034 260; /* Light text */
--lch-canvas: 20% 0.0195 232.58; /* Dark background */
}
/* Also respects system preference */
@media (prefers-color-scheme: dark) {
html:not([data-theme]) {
/* Same dark values */
}
}Uses native CSS nesting (no preprocessor):
.btn {
background-color: var(--btn-background);
@media (any-hover: hover) {
&:hover {
filter: brightness(var(--btn-hover-brightness));
}
}
html[data-theme="dark"] & {
--btn-hover-brightness: 1.25;
}
&[disabled] {
cursor: not-allowed;
opacity: 0.3;
}
}Components use a simple naming convention:
/* Base component */
.card { }
/* Sub-elements with __ */
.card__header { }
.card__body { }
.card__title { }
/* Variants with -- */
.card--notification { }
.card--closed { }But unlike BEM:
- No strict methodology - pragmatic naming
- Heavy use of CSS variables for theming within components
- :has() selectors for parent-aware styling
Components expose customization via variables:
.btn {
--btn-background: var(--color-canvas);
--btn-border-color: var(--color-ink-light);
--btn-color: var(--color-ink);
--btn-padding: 0.5em 1.1em;
--btn-border-radius: 99rem;
background-color: var(--btn-background);
border: 1px solid var(--btn-border-color);
/* ... */
}
/* Variants override variables */
.btn--link {
--btn-background: var(--color-link);
--btn-color: var(--color-ink-inverted);
}
.btn--negative {
--btn-background: var(--color-negative);
--btn-color: var(--color-ink-inverted);
}1. @starting-style for Entry Animations
.dialog {
opacity: 0;
transform: scale(0.2);
transition: 150ms allow-discrete;
transition-property: display, opacity, overlay, transform;
&[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
&[open] {
opacity: 0;
transform: scale(0.2);
}
}
}2. color-mix() for Dynamic Colors
.card {
--card-bg-color: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas));
--card-text-color: color-mix(in srgb, var(--card-color) 75%, var(--color-ink));
}3. :has() for Parent-Aware Styling
.btn:has(input:checked) {
--btn-background: var(--color-ink);
--btn-color: var(--color-ink-inverted);
}
.card:has(.card__closed) {
--card-color: var(--color-card-complete) !important;
}4. Logical Properties Throughout
.pad-block { padding-block: var(--block-space); }
.pad-inline { padding-inline: var(--inline-space); }
.margin-inline-start { margin-inline-start: var(--inline-space); }5. Container Queries
.card__content {
contain: inline-size; /* Enable container queries */
}6. Field Sizing
.input--textarea {
@supports (field-sizing: content) {
field-sizing: content;
max-block-size: calc(3lh + (2 * var(--input-padding)));
}
}Unlike Tailwind's hundreds of utilities, Fizzy has ~60 focused utilities:
@layer utilities {
/* Text */
.txt-small { font-size: var(--text-small); }
.txt-subtle { color: var(--color-ink-dark); }
/* Layout */
.flex { display: flex; }
.gap { column-gap: var(--column-gap, var(--inline-space)); }
/* Spacing (using design tokens) */
.pad { padding: var(--block-space) var(--inline-space); }
.margin-block { margin-block: var(--block-space); }
/* Visibility */
.visually-hidden {
clip-path: inset(50%);
position: absolute;
/* ... */
}
}One file per concern, ~100-300 lines each:
app/assets/stylesheets/
├── _global.css # CSS variables, layers, dark mode (472 lines)
├── reset.css # Modern CSS reset (109 lines)
├── base.css # Element defaults (122 lines)
├── layout.css # Grid layout (35 lines)
├── utilities.css # Utility classes (264 lines)
├── buttons.css # .btn component (273 lines)
├── cards.css # .card component (519 lines)
├── inputs.css # Form controls (295 lines)
├── dialog.css # Dialog animations (38 lines)
├── popup.css # Dropdown menus (209 lines)
└── ... (46 more files)
- No Sass/SCSS - Native CSS is powerful enough
- No PostCSS - Browser support is good
- No Tailwind - Utilities exist but are minimal
- No CSS-in-JS - Keep styles in stylesheets
- No CSS Modules - Global styles with naming conventions
- No !important abuse - Layers handle specificity
All values come from CSS custom properties:
:root {
/* Spacing */
--inline-space: 1ch;
--block-space: 1rem;
/* Typography */
--text-small: 0.85rem;
--text-normal: 1rem;
--text-large: 1.5rem;
/* Responsive typography */
@media (max-width: 639px) {
--text-small: 0.95rem;
--text-normal: 1.1rem;
}
/* Z-index scale */
--z-popup: 10;
--z-nav: 30;
--z-tooltip: 50;
/* Animation */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--dialog-duration: 150ms;
}Minimal breakpoints, mostly fluid:
/* Fluid main padding */
--main-padding: clamp(var(--inline-space), 3vw, calc(var(--inline-space) * 3));
/* Responsive via container */
--tray-size: clamp(12rem, 25dvw, 24rem);
/* Only 2-3 breakpoints used */
@media (max-width: 639px) { /* Mobile */ }
@media (min-width: 640px) { /* Desktop */ }
@media (max-width: 799px) { /* Tablet and below */ }- Use the platform - Native CSS is capable
- Design tokens everywhere - Variables for consistency
- Layers for specificity - No specificity wars
- Components own their styles - Self-contained
- Utilities are escape hatches - Not the primary approach
- Progressive enhancement -
@supportsfor new features - Minimal responsive - Fluid over breakpoint-heavy
Helpers wrap Stimulus controllers for reusable UI patterns:
# app/helpers/application_helper.rb
def icon_tag(name, **options)
tag.span class: class_names("icon icon--#{name}", options.delete(:class)),
"aria-hidden": true, **options
endUsage: <%= icon_tag("arrow-left") %>
# app/helpers/clipboard_helper.rb
def button_to_copy_to_clipboard(url, &)
tag.button class: "btn", data: {
controller: "copy-to-clipboard tooltip",
action: "copy-to-clipboard#copy",
copy_to_clipboard_success_class: "btn--success",
copy_to_clipboard_content_value: url
}, &
end# app/helpers/forms_helper.rb
def auto_submit_form_with(**attributes, &)
data = attributes.delete(:data) || {}
data[:controller] = "auto-submit #{data[:controller]}".strip
form_with **attributes, data: data, &
end# app/helpers/avatars_helper.rb
AVATAR_COLORS = %w[#AF2E1B #CC6324 #3B4B59 ...]
def avatar_background_color(user)
# Same user always gets same color via CRC32 hash
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
end
def avatar_tag(user, hidden_for_screen_reader: false, **options)
link_to user_path(user),
class: class_names("avatar btn btn--circle", options.delete(:class)),
data: { turbo_frame: "_top" },
aria: { hidden: hidden_for_screen_reader, label: user.name } do
avatar_image_tag(user)
end
enddef back_link_to(label, url, action, **options)
link_to url, class: "btn btn--back",
data: { controller: "hotkey", action: action }, **options do
icon_tag("arrow-left") +
tag.strong("Back to #{label}", class: "overflow-ellipsis") +
tag.kbd("ESC", class: "txt-x-small hide-on-touch")
end
end<%# app/views/cards/_container.html.erb %>
<% cache card do %>
<%= render "cards/card", card: card %>
<% end %><%# Automatic cache key per item, cached: true enables collection caching %>
<%= render partial: "cards/comments/comment",
collection: card.comments.preloaded.chronologically,
cached: true %><%# Pages that shouldn't be restored from Turbo's page cache %>
<% turbo_exempts_page_from_cache %>Models automatically generate cache keys via cache_key_with_version:
# Card includes updated_at, so cache invalidates on any change
cache card # => "cards/abc123-20241214120000"Instead of granular cache keys, they use touch: true extensively (16 associations):
# When a comment is created, card.updated_at changes → card cache invalidates
belongs_to :card, touch: true # Comment, Step, Closure, Assignment, Watch
belongs_to :board, touch: true # Column, Access
belongs_to :comment, touch: true # ReactionThis is intentionally simple:
- Accept the cache churn - cards change often anyway
- No Russian doll complexity - whole card cached, not nested fragments
- Predictable invalidation - any child change busts parent cache
Avatars use a redirect pattern for HTTP caching:
# app/controllers/users/avatars_controller.rb
def show
if @user.avatar.attached?
# Redirect to blob URL (which has its own caching)
redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline")
elsif stale? @user, cache_control: cache_control
# Render SVG initials, cached via ETags
render_initials
end
end
def cache_control
if @user == Current.user
{} # No caching for your own avatar (might change it)
else
{ max_age: 30.minutes, stale_while_revalidate: 1.week }
end
endAvatar variants are pre-processed on upload:
# app/models/user/avatar.rb
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [256, 256], process: :immediately
endInitials are rendered as SVG - a neat trick using Rails view rendering:
<%# app/views/users/avatars/show.svg.erb %>
<svg viewBox="0 0 512 512" class="avatar" aria-hidden="true">
<rect width="100%" height="100%" rx="50"
fill="<%= avatar_background_color(@user) %>" />
<text x="50%" y="50%" fill="#FFFFFF" text-anchor="middle" dy="0.35em"
font-size="230" font-weight="800">
<%= @user.initials %>
</text>
</svg>Benefits:
- Cacheable - HTTP caching works (ETags from user record)
- Dynamic - Color computed from user ID via CRC32
- No image processing - pure vector graphics
- Accessible -
aria-hiddensince avatar is decorative
scope :chronologically, -> { order created_at: :asc }
scope :reverse_chronologically, -> { order created_at: :desc }
scope :alphabetically, -> { order name: :asc }
scope :latest, -> { order last_active_at: :desc }Use preloaded as a standard name for eager loading:
# app/models/card.rb
scope :with_users, -> {
preload(creator: [:avatar_attachment, :account],
assignees: [:avatar_attachment, :account])
}
scope :preloaded, -> {
with_users
.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike,
:image_attachment, board: [:entropy, :columns], not_now: [:user])
.with_rich_text_description_and_embeds
}# app/models/comment.rb
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }# app/models/notification.rb
scope :preloaded, -> {
preload(:creator, :account,
source: [:board, :creator, { eventable: [:closure, :board, :assignments] }])
}scope :indexed_by, ->(index) do
case index.to_s
when "all" then all
when "closed" then closed
when "open" then open
when "not_now" then not_now
else all
end
end
scope :sorted_by, ->(sort) do
case sort.to_s
when "latest" then latest
when "oldest" then chronologically
else latest
end
end// app/views/pwa/service_worker.js
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return
if (event.request.destination === 'document') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.catch(() => caches.match(event.request)) // Offline fallback
)
}
})
// Push notifications
self.addEventListener("push", async (event) => {
const data = await event.data.json()
event.waitUntil(Promise.all([
showNotification(data),
updateBadgeCount(data.options)
]))
})
// App badge count
async function updateBadgeCount({ data: { badge } }) {
return self.navigator.setAppBadge?.(badge || 0)
}
// Notification click opens app
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.path, self.location.origin).href
event.waitUntil(openURL(url))
})Uses web-push gem for server-side push:
# Gemfile
gem "web-push"While avoiding heavyweight dependencies, these gems made the cut:
| Gem | Purpose |
|---|---|
geared_pagination |
DHH's cursor-based pagination |
propshaft |
Asset pipeline (simpler than Sprockets) |
solid_queue |
Database-backed job queue |
solid_cache |
Database-backed Rails cache |
solid_cable |
Database-backed Action Cable |
thruster |
HTTP/2 proxy for Puma |
kamal |
Docker deployment |
redcarpet + rouge |
Markdown + syntax highlighting |
rqrcode |
QR code generation |
lexxy |
Rich text editor (Basecamp's) |
platform_agent |
User agent parsing |
web-push |
Push notifications |
mission_control-jobs |
Job monitoring UI |
autotuner |
Automatic Ruby GC tuning |
- No
devise(custom auth) - No
pundit/cancancan(simple role checks) - No
sidekiq(Solid Queue) - No
redis(database for everything) - No
elasticsearch(custom sharded search) - No
view_component(partials + helpers) - No
dry-rbanything - No
interactor/trailblazer(no service objects)
Only 38 callback occurrences across 30 files - callbacks are used but not overused:
# After commit for async work
after_commit :relay_later, on: :create
# Before save for derived data
before_save :set_defaults
# After create for side effects
after_create_commit :broadcast_new_record- No complex callback chains
- No
before_validationfor business logic - No callbacks that call external services synchronously
- Prefer explicit method calls over implicit callbacks
# config/initializers/content_security_policy.rb
# Helper to get additional CSP sources from ENV or config.x
sources = ->(directive) do
env_key = "CSP_#{directive.to_s.upcase}"
value = if ENV.key?(env_key)
ENV[env_key]
else
config.x.content_security_policy.send(directive)
end
# Supports: nil, string, space-separated string, or array
case value
when nil then []
when Array then value
when String then value.split
else []
end
endThis allows:
- Base CSP defined in code
- Extensions via ENV vars (
CSP_SCRIPT_SRC="https://cdn.example.com") - Config overrides for multi-tenant SaaS
- Start with vanilla Rails - Don't add abstractions until you feel the pain
- Models are rich - Business logic lives in models, not services
- Controllers are thin - Just orchestration and response formatting
- Everything is CRUD - New resource over new action
- State is records - Not boolean columns
- Concerns are compositions - Horizontal behavior sharing
- Build before buying - Auth, search, jobs - all custom
- Database is king - No Redis, no Elasticsearch
- Test with fixtures - Deterministic, fast, simple
- Ship incrementally - Commit history shows many small changes
- Tests ship with features - Not TDD, not afterthought, but together
- Refactor toward consistency - Establish patterns, then update old code
- CSS uses the platform - Native CSS layers, nesting, OKLCH - no preprocessors
- Design tokens everywhere - CSS variables for colors, spacing, typography
The best code is the code you don't write. The second best is the code that's obviously correct. The 37signals codebase optimizes for both.
Care to share the prompt to generate such detailed analysis?