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 via before_action
- Request context:
CurrentRequest - populate Current with 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:
Authentication concern with require_authentication, resume_session, start_new_session_for
Session model (belongs_to identity)
MagicLink model 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 classes for config, this.dispatch() for events, this.#privateMethod() for private methods
Background Jobs
- Shallow jobs, rich models - jobs just call model methods
_later and _now convention - mark_as_read_later queues job, mark_as_read_now executes 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:
Signup as 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:
preloaded as 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 do in views
cached: true for collection rendering
touch: true on associations for cache invalidation
Database Patterns
- UUIDs for primary keys
- Every model has
account_id for 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: :create for async work
before_save :set_defaults for 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.
1---2name: 37signals-rails-style3description: Apply 37signals/DHH Rails conventions when writing Ruby on Rails code. Use when building Rails applications, reviewing Rails code, or making architectural decisions. Covers various aspects of Rails application architecture, design and dependencies.4---56# 37signals/DHH Rails Style Guide78## Core Philosophy910- **"Vanilla Rails is plenty."** Maximize what Rails gives you, minimize dependencies, resist abstractions until necessary.11- **Rich domain models** over service objects12- **CRUD controllers** over custom actions13- **Concerns** for horizontal code sharing14- **Records as state** over boolean columns15- **Database-backed everything** (no Redis)16- **Build it yourself** before reaching for gems1718---1920## Dependencies2122### Use23- Rails (edge), turbo-rails, stimulus-rails, importmap-rails, propshaft, solid_queue, solid_cache, solid_cable (database-backed, NO Redis), geared_pagination, bcrypt, rqrcode, redcarpet2425### Avoid2627| Gem/Pattern | Why |28|------------------------|-----------------------------------|29| `devise` | Auth is ~150 lines of custom code |30| `pundit`/`cancancan` | Authorization lives in models |31| `dry-rb`, `interactor` | Over-engineered |32| `view_component` | ERB partials are fine |33| `sidekiq`, `redis` | Use Solid Queue (database-backed) |34| `graphql` | REST with Turbo is sufficient |3536---3738## Routing: Everything is CRUD3940Every action maps to a CRUD verb. Create new resources instead of custom actions:4142```ruby43# Avoid: Custom actions44resources :cards do45 post :close46end4748# Good: State changes as resources49resources :cards do50 resource :closure # POST to close, DELETE to reopen51 resource :pin # POST to pin, DELETE to unpin52 resource :watch # POST to watch, DELETE to unwatch53end54```5556Use `scope module:` for namespaced nested resources. Use `resolve` for custom polymorphic URL generation.5758---5960## Controllers6162### Thin Controllers, Rich Models6364Controllers orchestrate; business logic lives in models.6566```ruby67def create68 @card.close # All logic in model69 respond_to do |format|70 format.turbo_stream { render_card_replacement }71 format.json { head :no_content }72 end73end74```7576### Controller Concerns7778Use concerns for shared behavior:79- **Resource scoping**: `CardScoped`, `BoardScoped` - load parent resources via `before_action`80- **Request context**: `CurrentRequest` - populate `Current` with request data81- **Security**: `BlockSearchEngineIndexing`, `RequestForgeryProtection`82- **Turbo helpers**: `TurboFlash` - flash messages via Turbo Stream8384### Authorization8586Check permissions in controller, define permission logic in model:8788```ruby89# Controller90before_action :ensure_permission_to_administer_card, only: [:destroy]9192# Model93def can_administer_card?(card)94 admin? || card.creator == self95end96```9798---99100## Models & Concerns101102### Heavy Use of Concerns103104Each concern is self-contained with associations, scopes, and methods:105106```ruby107class Card < ApplicationRecord108 include Assignable, Closeable, Eventable, Pinnable, Watchable109end110```111112### Concern Structure113114```ruby115module Card::Closeable116 extend ActiveSupport::Concern117118 included do119 has_one :closure, dependent: :destroy120 scope :closed, -> { joins(:closure) }121 scope :open, -> { where.missing(:closure) }122 end123124 def closed? = closure.present?125126 def close(user: Current.user)127 create_closure!(user: user) unless closed?128 end129end130```131132### Default Values via Lambdas133134```ruby135belongs_to :account, default: -> { board.account }136belongs_to :creator, class_name: "User", default: -> { Current.user }137```138139### Current for Request Context140141Use `ActiveSupport::CurrentAttributes` for session, user, identity, account, and request metadata.142143### POROs (Plain Old Ruby Objects)144145Namespace under parent model: `Event::Description`, `Card::Eventable::SystemCommenter`146147Use for:148- **Presentation logic** - formatting for display149- **Complex operations** - multi-step processes150- **View context bundling** - collecting UI state151152POROs are model-adjacent, NOT controller-adjacent (that would be a service object).153154---155156## State as Records, Not Booleans157158Create separate records instead of boolean columns:159160```ruby161# Separate record gives you: timestamp, who did it, easy scoping162class Closure < ApplicationRecord163 belongs_to :card, touch: true164 belongs_to :user, optional: true165end166167card.closure.present? # Is it closed?168card.closure.user # Who closed it?169card.closure.created_at # When?170171# Scoping172Card.closed # joins(:closure)173Card.open # where.missing(:closure)174```175176Examples: `Closure`, `Pin`, `Watch`, `Publication`, `Goldness`177178---179180## Authentication181182Custom passwordless magic link auth (~150 lines). No Devise.183184Key components:185- `Authentication` concern with `require_authentication`, `resume_session`, `start_new_session_for`186- `Session` model (belongs_to identity)187- `MagicLink` model with expiration and consumption188- Bearer token authentication for API access189190---191192## Views & Turbo/Hotwire193194- **Turbo Streams** for partial updates (`turbo_stream.replace`, `turbo_stream.before`)195- **Morphing** for complex updates (`method: :morph`)196- **Partials over ViewComponents** - standard ERB partials with caching197- **Stimulus controllers** - single-purpose, small (~50 lines), `static values`/`static classes` for config, `this.dispatch()` for events, `this.#privateMethod()` for private methods198199---200201## Background Jobs202203- **Shallow jobs, rich models** - jobs just call model methods204- **`_later` and `_now` convention** - `mark_as_read_later` queues job, `mark_as_read_now` executes immediately205- **Solid Queue** - database-backed, no Redis206- **Recurring jobs** via `config/recurring.yml`207208---209210## Testing211212- **Request specs** for controllers (not controller specs)213- **Ship tests with features** in the same commit214- Use `change { }`, `as: :turbo_stream`, `as: :json`215216---217218## What They Avoid219220- **No service objects** - use model methods221- **No form objects** (usually) - exception: `Signup` as ActiveModel222- **No decorators/presenters** - use view helpers223- **No GraphQL** - REST with Turbo224225---226227## Naming Conventions228229### Methods230- **Verbs for actions**: `close`, `reopen`, `publish`231- **Predicates for state**: `closed?`, `published?`232233### Concerns234Adjectives describing capability: `Closeable`, `Publishable`, `Watchable`, `Searchable`235236### Controllers237Nouns matching the resource: `Cards::ClosuresController`, `Boards::PublicationsController`238239### Scopes240- **Ordering**: `chronologically`, `reverse_chronologically`, `alphabetically`, `latest`241- **Preloading**: `preloaded` as standard name for eager loading242- **Parameterized**: `indexed_by(index)`, `sorted_by(sort)`243244---245246## Caching247248### HTTP Caching249- `fresh_when etag: [...]` for conditional GET250- Global `etag { "v1" }` in ApplicationController (bump to bust caches)251- Concern-level ETags for timezone, authentication252253### Fragment Caching254- `cache card do` in views255- `cached: true` for collection rendering256- `touch: true` on associations for cache invalidation257258---259260## Database Patterns261262- **UUIDs** for primary keys263- **Every model has `account_id`** for multi-tenancy264- **URL-based multi-tenancy**: `/{account_id}/boards/...`265- **No foreign key constraints** - removed for flexibility266267---268269## CSS Architecture270271- **Vanilla CSS** no Sass, PostCSS, or Tailwind.272- **CSS Cascade Layers** `@layer reset, base, components, modules, utilities`273- **OKLCH color system** with CSS variables274- **Modern features** `@starting-style`, `color-mix()`, `:has()`, native nesting, container queries275276---277278## API Design279280- Same controllers, different format via `respond_to`281- Response codes: Create → `201 Created` + Location, Update/Delete → `204 No Content`282- Bearer token authentication283284---285286## Callbacks287288Use sparingly:289- `after_commit :relay_later, on: :create` for async work290- `before_save :set_defaults` for derived data291- Avoid complex chains, avoid synchronous external calls292293---294295## Summary2962971. **Start with vanilla Rails** - Don't add abstractions until you feel the pain2982. **Models are rich** - Business logic lives in models, not services2993. **Controllers are thin** - Just orchestration and response formatting3004. **Everything is CRUD** - New resource over new action3015. **State is records** - Not boolean columns3026. **Concerns are compositions** - Horizontal behavior sharing3037. **Build before buying** - Auth, search, jobs - all custom3048. **Database is king** - No Redis, no Elasticsearch3059. **Test with fixtures** - Deterministic, fast, simple30610. **Ship incrementally** - Many small commits30711. **Tests ship with features** - Not TDD, not afterthought, but together30812. **Refactor toward consistency** - Establish patterns, then update old code30913. **CSS uses the platform** - Native layers, nesting, OKLCH - no preprocessors31014. **Design tokens everywhere** - CSS variables for colors, spacing, typography311312The best code is the code you don't write. The second best is the code that's obviously correct.