| name | jr-rails-classic |
| description | Write Rails code in 37signals/classic style: rich models, CRUD controllers, concerns, state-as-records, Minitest. Use when writing or modifying Ruby on Rails application code. |
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
strftime in 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
Card.joins(:closure)
Card.where.missing(:closure)
Concerns — Horizontal Behavior
Self-contained (associations + scopes + methods). 50–150 lines. Named for
capabilities, not organization.
class Card < ApplicationRecord
include Assignable, Closeable, Golden, Watchable, Searchable
end
POROs Under Model Namespace
Business logic that doesn't fit a concern:
class Event::Description
def initialize(event) = @event = event
def to_s =
end
Validations
Minimal on model (data integrity), contextual on form objects (UI flows):
class User < ApplicationRecord
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
normalizes :email, with: ->(e) { e.strip.downcase }
end
add_index :users, :email, unique: true
add_foreign_key :cards, :boards
Association 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
class Message < ApplicationRecord
delegated_type :messageable, types: %w[Comment Reply Announcement]
end
class User < ApplicationRecord
store_accessor :settings, :theme, :notifications_enabled
end
Database
create_table :cards, id: :uuid do |t|
t.references :board, type: :uuid, foreign_key: true
end
class Comment < ApplicationRecord
belongs_to :card, counter_cache: true
end
class Card < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
end
Controllers
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
end
Current Attributes
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account, :request_id
delegate :user, to: :session, allow_nil: true
end
Background 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
end
View Helpers
Date formatting via initializer (never strftime in views):
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:
For frontend patterns (Stimulus controllers, Turbo Frames/Streams), invoke
the relevant hwc-* skill alongside this one.