Rails Classic Coding Style
Write Rails application code following 37signals conventions. Rich domain models, CRUD controllers, database-backed everything, Minitest with fixtures.
Core Workflow
- Use generators first —
rails g model,rails g controller,rails g migration. Generators produce correct file structure, test stubs, and route entries in one shot. - Implementation order — models → controllers → views → tests.
- Ship, Validate, Refine — prototype to production, learn from real usage.
- Let it crash — use bang methods, let Rails handle RecordInvalid with 422s.
Guardrails
- No service objects — use domain models namespaced under
app/models/ - No custom controller actions — create new resources instead (REST mapping)
- No RSpec, no factory_bot — Minitest with fixtures only
- No Redis — Solid Queue, Solid Cache, Solid Cable
- No
strftimein views — custom DATE_FORMATS in initializers - Callbacks only for derived data and async dispatch — never business logic
- Database constraints over model validations for hard guarantees
- Pass IDs to jobs, not objects
Conventions Quick Reference
Naming
- Verbs for state changes:
card.close,board.publish - Predicates from record presence:
card.closed?,card.golden? - Concerns as adjectives:
Closeable,Publishable,Watchable - Controllers as nouns:
Cards::ClosuresController - Scopes as business terms:
scope:active,scope:chronologically
REST Mapping
No custom actions. Create sub-resources:
| Action | Route |
|---|---|
| close a card | POST /cards/:id/closure |
| archive a card | POST /cards/:id/archival |
| watch a board | POST /boards/:id/watching |
Models — State as Records
Instead of booleans, create state records (timestamps + authorship for free):
class Card::Closure < ApplicationRecord
belongs_to :card
belongs_to :creator, class_name: "User"
end
module Closeable
extend ActiveSupport::Concern
included do
has_one :closure, dependent: :destroy
end
def closed? = closure.present?
def close(creator: Current.user) = create_closure!(creator: creator)
def reopen = closure&.destroy
end
# Querying
Card.joins(:closure) # closed
Card.where.missing(:closure) # openConcerns — Horizontal Behavior
Self-contained (associations + scopes + methods). 50–150 lines. Named for capabilities, not organization.
class Card < ApplicationRecord
include Assignable, Closeable, Golden, Watchable, Searchable
endPOROs Under Model Namespace
Business logic that doesn't fit a concern:
# app/models/event/description.rb
class Event::Description
def initialize(event) = @event = event
def to_s = # ...
endValidations
Minimal on model (data integrity), contextual on form objects (UI flows):
# Model — minimal
class User < ApplicationRecord
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email, with: ->(e) { e.strip.downcase }
end
# Migration — hard constraints
add_index :users, :email, unique: true
add_foreign_key :cards, :boardsAssociation Design
| Instead of | Prefer | When |
|---|---|---|
has_and_belongs_to_many | has_many:through | Join model needs attributes or callbacks |
| 1:N | M:N (has_many:through) | Relationship may grow |
polymorphic: true | delegated_type | Type-safe variants with different schemas |
Always set :dependent on has_many/has_one. Use :inverse_of when Rails can't infer it.
Rails 7.1+ Patterns
# Delegated types
class Message < ApplicationRecord
delegated_type :messageable, types: %w[Comment Reply Announcement]
end
# store_accessor for JSON/JSONB
class User < ApplicationRecord
store_accessor :settings, :theme, :notifications_enabled
endDatabase
# UUIDs (UUIDv7 — time-sortable)
create_table :cards, id: :uuid do |t|
t.references :board, type: :uuid, foreign_key: true
end
# Counter caches
class Comment < ApplicationRecord
belongs_to :card, counter_cache: true
end
# Default values with Current
class Card < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
endControllers
Thin. Use concerns for shared behavior:
module CardScoped
extend ActiveSupport::Concern
included { before_action :set_card }
private
def set_card
@card = Card.find(params[:card_id])
@board = @card.board
end
def render_card_replacement
render turbo_stream: turbo_stream.replace(@card)
end
end
class Cards::ClosuresController < ApplicationController
include CardScoped
def create = @card.close && render_card_replacement
def destroy = @card.reopen && render_card_replacement
endCurrent Attributes
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account, :request_id
delegate :user, to: :session, allow_nil: true
endBackground Jobs
Thin wrappers — logic lives on the model:
module Watchable
def notify_watchers_later = NotifyWatchersJob.perform_later(self)
def notify_watchers
watchers.each { |w| WatcherMailer.notification(w, self).deliver_later }
end
end
class NotifyWatchersJob < ApplicationJob
def perform(card) = card.notify_watchers
endView Helpers
Date formatting via initializer (never strftime in views):
# config/initializers/date_formats.rb
Date::DATE_FORMATS[:default] = "%d.%m.%Y"
Time::DATE_FORMATS[:default] = "%d.%m.%Y %H:%M"
Time::DATE_FORMATS[:time_only] = "%H:%M"Use active_link_to gem for navigation active states.
Testing
- Minitest with fixtures, integration tests for controllers
- Test observable behavior, not implementation
- Don't mock what you can test for real
- VCR for external APIs
Preferred Stack
| Concern | Gem |
|---|---|
| Frontend | turbo-rails, stimulus-rails, importmap-rails |
| Assets | propshaft |
| Jobs | Solid Queue |
| Cache/Cable | Solid Cache, Solid Cable |
| Authorization | Pundit |
| Deployment | Kamal + Thruster |
Gem Selection
- Can vanilla Rails do this?
- Is it the app's core concern? If yes, own the code
- Does it add infrastructure? Database-backed alternatives exist
- Is the complexity worth it?
- Is it from someone you trust?
Deep Reference Files
Read these on demand when the task requires deeper guidance:
- Design patterns (form objects, query objects, strategies, etc.): reference/patterns.md
- Anti-patterns and code smells: reference/anti-patterns.md, reference/smells.md
- Refactoring recipes: reference/refactorings/ (002–010)
- Testing guide: reference/shared/testing.md
- Hotwire (Turbo + Stimulus): reference/shared/hotwire.md
- Background jobs + Continuations: reference/shared/jobs.md
- State machines (AASM): reference/shared/state_machines.md
- Callbacks scoring + extraction: reference/shared/callbacks.md
- Authorization (Pundit): reference/shared/authorization.md
- Notifications (Noticed): reference/shared/notifications.md
- Instrumentation / EventReporter: reference/shared/instrumentation.md
- Components (Phlex): reference/shared/components.md
- Serializers (API JSON): reference/shared/serializers.md
- Architecture layers: reference/shared/architecture.md
- Gem recommendations: reference/toolbelt.md
For frontend patterns (Stimulus controllers, Turbo Frames/Streams), invoke the relevant hwc-* skill alongside this one.