37signals-rails-style
37signals/DHH Rails Style Guide
Core Philosophy
- "Vanilla Rails is plenty." Maximize what Rails gives you, minimize dependencies, resist abstractions until necessary.
- 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
Dependencies
Use
- Rails (edge), turbo-rails, stimulus-rails, importmap-rails, propshaft, solid_queue, solid_cache, solid_cable (database-backed, NO Redis), geared_pagination, bcrypt, rqrcode, redcarpet
Avoid
| Gem/Pattern | Why |
|---|---|
devise |
Auth is ~150 lines of custom code |
pundit/cancancan |
Authorization lives in models |
dry-rb, interactor |
Over-engineered |
view_component |
ERB partials are fine |
sidekiq, redis |
Use Solid Queue (database-backed) |
graphql |
REST with Turbo is sufficient |
Routing: Everything is CRUD
Every action maps to a CRUD verb. Create new resources instead of custom actions:
# Avoid: Custom actions
resources :cards do
post :close
end
# Good: State changes as resources
resources :cards do
resource :closure # POST to close, DELETE to reopen
resource :pin # POST to pin, DELETE to unpin
resource :watch # POST to watch, DELETE to unwatch
end
Use scope module: for namespaced nested resources. Use resolve for custom polymorphic URL generation.
Controllers
Thin Controllers, Rich Models
Controllers orchestrate; business logic lives in models.
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
Controller Concerns
Use concerns for shared behavior:
- Resource scoping:
CardScoped,BoardScoped- load parent resources viabefore_action - Request context:
CurrentRequest- populateCurrentwith request data - Security:
BlockSearchEngineIndexing,RequestForgeryProtection - Turbo helpers:
TurboFlash- flash messages via Turbo Stream
Authorization
Check permissions in controller, define permission logic in model:
# Controller
before_action :ensure_permission_to_administer_card, only: [:destroy]
# Model
def can_administer_card?(card)
admin? || card.creator == self
end
Models & Concerns
Heavy Use of Concerns
Each concern is self-contained with associations, scopes, and methods:
class Card < ApplicationRecord
include Assignable, Closeable, Eventable, Pinnable, Watchable
end
Concern Structure
module Card::Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :open, -> { where.missing(:closure) }
end
def closed? = closure.present?
def close(user: Current.user)
create_closure!(user: user) unless closed?
end
end
Default Values via Lambdas
belongs_to :account, default: -> { board.account }
belongs_to :creator, class_name: "User", default: -> { Current.user }
Current for Request Context
Use ActiveSupport::CurrentAttributes for session, user, identity, account, and request metadata.
POROs (Plain Old Ruby Objects)
Namespace under parent model: Event::Description, Card::Eventable::SystemCommenter
Use for:
- Presentation logic - formatting for display
- Complex operations - multi-step processes
- View context bundling - collecting UI state
POROs are model-adjacent, NOT controller-adjacent (that would be a service object).
State as Records, Not Booleans
Create separate records instead of boolean columns:
# Separate record gives you: timestamp, who did it, easy scoping
class Closure < ApplicationRecord
belongs_to :card, touch: true
belongs_to :user, optional: true
end
card.closure.present? # Is it closed?
card.closure.user # Who closed it?
card.closure.created_at # When?
# Scoping
Card.closed # joins(:closure)
Card.open # where.missing(:closure)
Examples: Closure, Pin, Watch, Publication, Goldness
Authentication
Custom passwordless magic link auth (~150 lines). No Devise.
Key components:
Authenticationconcern withrequire_authentication,resume_session,start_new_session_forSessionmodel (belongs_to identity)MagicLinkmodel with expiration and consumption- Bearer token authentication for API access
Views & Turbo/Hotwire
- Turbo Streams for partial updates (
turbo_stream.replace,turbo_stream.before) - Morphing for complex updates (
method: :morph) - Partials over ViewComponents - standard ERB partials with caching
- Stimulus controllers - single-purpose, small (~50 lines),
static values/static classesfor config,this.dispatch()for events,this.#privateMethod()for private methods
Background Jobs
- Shallow jobs, rich models - jobs just call model methods
_laterand_nowconvention -mark_as_read_laterqueues job,mark_as_read_nowexecutes immediately- Solid Queue - database-backed, no Redis
- Recurring jobs via
config/recurring.yml
Testing
- Request specs for controllers (not controller specs)
- Ship tests with features in the same commit
- Use
change { },as: :turbo_stream,as: :json
What They Avoid
- No service objects - use model methods
- No form objects (usually) - exception:
Signupas ActiveModel - No decorators/presenters - use view helpers
- No GraphQL - REST with Turbo
Naming Conventions
Methods
- Verbs for actions:
close,reopen,publish - Predicates for state:
closed?,published?
Concerns
Adjectives describing capability: Closeable, Publishable, Watchable, Searchable
Controllers
Nouns matching the resource: Cards::ClosuresController, Boards::PublicationsController
Scopes
- Ordering:
chronologically,reverse_chronologically,alphabetically,latest - Preloading:
preloadedas standard name for eager loading - Parameterized:
indexed_by(index),sorted_by(sort)
Caching
HTTP Caching
fresh_when etag: [...]for conditional GET- Global
etag { "v1" }in ApplicationController (bump to bust caches) - Concern-level ETags for timezone, authentication
Fragment Caching
cache card doin viewscached: truefor collection renderingtouch: trueon associations for cache invalidation
Database Patterns
- UUIDs for primary keys
- Every model has
account_idfor multi-tenancy - URL-based multi-tenancy:
/{account_id}/boards/... - No foreign key constraints - removed for flexibility
CSS Architecture
- Vanilla CSS no Sass, PostCSS, or Tailwind.
- CSS Cascade Layers
@layer reset, base, components, modules, utilities - OKLCH color system with CSS variables
- Modern features
@starting-style,color-mix(),:has(), native nesting, container queries
API Design
- Same controllers, different format via
respond_to - Response codes: Create →
201 Created+ Location, Update/Delete →204 No Content - Bearer token authentication
Callbacks
Use sparingly:
after_commit :relay_later, on: :createfor async workbefore_save :set_defaultsfor derived data- Avoid complex chains, avoid synchronous external calls
Summary
- 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 - Many small commits
- Tests ship with features - Not TDD, not afterthought, but together
- Refactor toward consistency - Establish patterns, then update old code
- CSS uses the platform - Native 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.