
37signals Rails
- 257 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
37signals-rails: A skill for development. This provides functionality for development workflows.
Key points
- 37signals-rails
37signals Rails by the numbers
- 257 all-time installs (skills.sh)
- +11 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,483 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill 37signals-railsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 257 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use 37signals-rails for development tasks?
Use 37signals-rails for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with 37signals-rails.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use 37signals-rails for development tasks, or when 37signals-rails: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to 37signals-rails: 37signals-rails.
Files
37signals Rails Best Practices
Comprehensive coding principles and conventions for Ruby on Rails applications, as practiced at 37signals (Basecamp, HEY, Fizzy). Contains 56 rules across 8 categories, prioritized by architectural impact. Derived from official 37signals sources: the Fizzy codebase, STYLE.md, AGENTS.md, the Rails Doctrine, DHH's "On Writing Software Well" series, and the unofficial 37signals style guide (265 Fizzy PRs).
When to Apply
Reference these guidelines when:
- Writing new Rails controllers, models, or views
- Deciding between gems and vanilla Rails
- Modeling state and database schema
- Setting up background jobs, caching, or real-time features
- Reviewing code for 37signals-style conventions
- Refactoring toward rich domain models
- Choosing authentication or authorization approach
- Adding Stimulus controllers or Turbo patterns
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Architecture Fundamentals | CRITICAL | arch- |
| 2 | Controllers & REST | CRITICAL | ctrl- |
| 3 | Domain Modeling | HIGH | model- |
| 4 | State Management | HIGH | state- |
| 5 | Database & Infrastructure | HIGH | db- |
| 6 | Views & Frontend | MEDIUM | view- |
| 7 | Code Style | MEDIUM | style- |
| 8 | Testing | MEDIUM | test- |
Quick Reference
1. Architecture Fundamentals (CRITICAL)
- `arch-rich-models` - Rich Domain Models Over Service Objects
- `arch-vanilla-rails` - Vanilla Rails is Plenty
- `arch-avoid-patterns` - Deliberately Avoided Patterns and Gems
- `arch-earn-abstractions` - Earn Abstractions Through Rule of Three
- `arch-build-before-gems` - Build It Yourself Before Reaching for Gems
- `arch-ship-to-learn` - Start Simple — Add Complexity Only After Validation
- `arch-domain-facades` - Domain Models as Facades Over Internal Complexity
- `arch-single-business-layer` - Single Layer for Business Logic
- `arch-custom-auth` - Custom Passwordless Auth Over Devise
2. Controllers & REST (CRITICAL)
- `ctrl-crud-only` - CRUD Controllers Over Custom Actions
- `ctrl-model-as-resources` - Model Non-CRUD Operations as Separate Resources
- `ctrl-thin-controllers` - Thin Controllers with Rich Domain Models
- `ctrl-params-expect` - Use params.expect() for Parameter Validation
- `ctrl-controller-concerns` - Controller Concerns for Cross-Cutting Behavior
- `ctrl-nested-resources` - Nested Resources with scope module
3. Domain Modeling (HIGH)
- `model-concerns` - Concerns for Horizontal Code Sharing
- `model-normalizes` - Use normalizes Macro for Data Cleaning
- `model-store-accessor` - Use store_accessor for JSON Column Access
- `model-delegated-type` - Use delegated_type for Polymorphism
- `model-counter-caches` - Counter Caches to Prevent N+1 Count Queries
- `model-touch-chains` - Touch Chains for Cache Invalidation
- `model-callbacks-auxiliary` - Callbacks for Auxiliary Complexity
- `model-event-tracking` - Polymorphic Event Model for Activity Tracking
- `model-poro-namespacing` - Namespace POROs Under Parent Models
4. State Management (HIGH)
- `state-records-over-booleans` - Records as State Over Boolean Columns
- `state-timestamps` - Timestamps for State Transitions
- `state-enums` - Enums for Categorical States
- `state-db-constraints` - Database Constraints Over ActiveRecord Validations
- `state-write-time` - Compute at Write Time Not Read Time
5. Database & Infrastructure (HIGH)
- `db-backed-everything` - Database-Backed Everything
- `db-solid-queue` - Solid Queue for Background Jobs
- `db-solid-cable` - Solid Cable for Real-Time Pub/Sub
- `db-solid-cache` - Solid Cache for Application Caching
- `db-multi-tenancy` - Path-Based Multi-Tenancy with Current.account
- `db-uuid-primary-keys` - UUIDs as Primary Keys
- `db-no-foreign-keys` - No Foreign Key Constraints
6. Views & Frontend (MEDIUM)
- `view-turbo-frames` - Turbo Frames for Scoped Page Fragments
- `view-turbo-streams` - Turbo Streams for Real-Time Updates
- `view-stimulus-targets` - Stimulus Targets Over CSS Selectors
- `view-stimulus-design` - Stimulus Controller Design Principles
- `view-helpers-not-partials` - Extract Logic to Helpers Not Partials
- `view-progressive-enhancement` - Progressive Enhancement as Primary Pattern
- `view-fragment-caching` - Fragment Caching for View Performance
- `view-http-caching` - HTTP Caching with fresh_when and ETags
7. Code Style (MEDIUM)
- `style-conditionals` - Expanded Conditionals Over Guard Clauses
- `style-method-ordering` - Methods Ordered by Call Sequence
- `style-positive-names` - Use Positive Names for Methods and Scopes
- `style-naming-return-values` - Method Names Reflect Return Values
- `style-visibility-modifiers` - Visibility Modifier Formatting
- `style-bang-methods` - Bang Methods Only When Non-Bang Exists
- `style-async-naming` - Use _later and _now Suffixes for Async Operations
8. Testing (MEDIUM)
- `test-minitest` - Minitest Over RSpec
- `test-fixtures` - Database Fixtures Over FactoryBot
- `test-no-damage` - No Test-Induced Design Damage
- `test-no-system-tests` - Integration Tests Over System Tests
- `test-behavior` - Test Behavior Not Implementation
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on architectural or maintainability implications.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{# Comments explaining the cost}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{# Comments explaining the benefit}{Optional sections as needed:}
Alternative ({context}): {Alternative approach when applicable}
When NOT to use this pattern:
- {Exception 1}
- {Exception 2}
Benefits:
- {Benefit 1}
- {Benefit 2}
Reference: [{Reference Title}]({Reference URL})
{
"version": "1.0.8",
"organization": "37signals",
"technology": "Ruby on Rails",
"date": "February 2026",
"abstract": "Comprehensive coding principles and conventions for Ruby on Rails applications following the 37signals philosophy. Contains 56 rules across 8 categories, prioritized by architectural impact from critical (rich domain models, CRUD controllers, avoided patterns) to foundational (testing, code style). Each rule includes detailed explanations, production-realistic code examples comparing incorrect vs. correct implementations, and references to official 37signals sources. Designed for AI agents and LLMs to guide code generation and refactoring toward the 37signals way.",
"references": [
"https://github.com/basecamp/fizzy",
"https://github.com/basecamp/fizzy/blob/main/STYLE.md",
"https://github.com/basecamp/fizzy/blob/main/AGENTS.md",
"https://rubyonrails.org/doctrine",
"https://dev.37signals.com/",
"https://dev.37signals.com/vanilla-rails-is-plenty/",
"https://signalvnoise.com/svn3/on-writing-software-well/",
"https://gist.github.com/marckohlbrugge/d363fb90c89f71bd0c816d24d7642aca",
"https://world.hey.com/dhh/system-tests-have-failed-d90af718"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Architecture Fundamentals (arch)
Impact: CRITICAL Description: Rich domain models over service objects, vanilla Rails over gems, earned abstractions over premature design — these foundational decisions cascade through every layer of the application.
2. Controllers & REST (ctrl)
Impact: CRITICAL Description: CRUD-only controllers with resourceful routing eliminate custom actions and keep the HTTP layer thin, pushing all business logic into domain models.
3. Domain Modeling (model)
Impact: HIGH Description: Concerns for horizontal sharing, normalizes for data cleaning, delegated_type for polymorphism — the rich model toolkit that replaces service layers.
4. State Management (state)
Impact: HIGH Description: Records over booleans, timestamps over flags, database constraints over validations — state modeling that provides audit trails and enforces integrity at the database level.
5. Database & Infrastructure (db)
Impact: HIGH Description: Database-backed everything: Solid Queue for jobs, Solid Cable for pub/sub, Solid Cache for caching — eliminating Redis and external dependencies.
6. Views & Frontend (view)
Impact: MEDIUM Description: Hotwire-driven UI with Turbo Frames, Turbo Streams, and Stimulus — server-rendered HTML with progressive enhancement, minimal JavaScript.
7. Code Style (style)
Impact: MEDIUM Description: Method ordering by call sequence, expanded conditionals, positive naming, _later/_now async conventions — the 37signals STYLE.md readability rules.
8. Testing (test)
Impact: MEDIUM Description: Minitest over RSpec, fixtures over factories, behavior verification over implementation testing — fast, simple tests without design damage.
Deliberately Avoided Patterns and Gems
37signals explicitly avoids these patterns and gems across Basecamp, HEY, and Fizzy. This is not accidental omission — each was evaluated and rejected in favor of vanilla Rails. When an agent or developer reaches for any of these, stop and use the built-in alternative instead.
Incorrect (reaching for common gems and patterns):
# Gemfile — the "standard" Rails stack that 37signals rejects
gem "devise" # authentication
gem "pundit" # authorization
gem "sidekiq" # background jobs
gem "redis" # caching, pub/sub, sessions
gem "elasticsearch-rails" # search
gem "dry-validation" # input validation
gem "interactor" # service orchestration
gem "view_component" # view encapsulation
gem "graphql" # API layer
# app/services/create_card.rb — service object pattern
class CreateCard
include Interactor
def call
card = Card.new(context.params)
card.creator = context.user
if card.save
context.card = card
CardNotifier.call(card)
else
context.fail!(errors: card.errors)
end
end
end
# app/graphql/types/card_type.rb — GraphQL type
class Types::CardType < Types::BaseObject
field :id, ID, null: false
field :title, String, null: false
end
# app/policies/card_policy.rb — Pundit policy
class CardPolicy < ApplicationPolicy
def update?
user.admin? || record.creator == user
end
endCorrect (vanilla Rails alternatives):
# Gemfile — the 37signals stack
gem "solid_queue" # database-backed jobs (replaces Sidekiq + Redis)
gem "solid_cache" # database-backed cache (replaces Redis)
gem "solid_cable" # database-backed pub/sub (replaces Redis)
gem "mission_control-jobs" # job monitoring dashboard
# Authentication: ~150 lines of custom passwordless auth (replaces Devise)
# Authorization: permission methods on models (replaces Pundit)
# Search: database full-text search or custom sharding (replaces Elasticsearch)
# API: REST with respond_to blocks (replaces GraphQL)
# Views: partials + helpers (replaces ViewComponent)
# app/models/card.rb — rich model replaces service objects
class Card < ApplicationRecord
belongs_to :creator, class_name: "User", default: -> { Current.user }
after_create_commit :notify_watchers
def editable_by?(user)
user.admin? || creator == user
end
private
def notify_watchers
CardMailer.created(self).deliver_later
end
end
# app/controllers/cards_controller.rb — direct model calls
class CardsController < ApplicationController
def create
@card = Current.account.cards.create!(card_params)
redirect_to @card
end
def update
@card = Current.account.cards.find(params[:id])
if @card.editable_by?(Current.user)
@card.update!(card_params)
redirect_to @card
else
redirect_to @card, alert: "Not authorized"
end
end
endThe full avoidance list:
| Pattern/Gem | 37signals Alternative |
|---|---|
| Service objects / Interactors | Rich model methods |
| Form objects | ActiveModel::Model when truly needed (rare) |
| Decorators / Presenters | View helpers and model methods |
| GraphQL | REST with respond_to blocks |
| ViewComponent | Partials + helpers |
| Devise | Custom passwordless link auth (~150 lines) |
| Pundit / CanCanCan | Permission methods on models |
| Dry-rb gems | Plain Ruby validation |
| Trailblazer | Vanilla Rails |
| Sidekiq | Solid Queue |
| Redis | Solid Queue + Solid Cache + Solid Cable |
| Elasticsearch | Database full-text search |
| Sass / Tailwind / PostCSS | Vanilla CSS with native features |
Reference: Vanilla Rails is Plenty
Build It Yourself Before Reaching for Gems
Implement features with vanilla Rails first. Only extract to gems after patterns prove themselves across multiple projects. 37signals avoids Devise (custom passwordless auth), RSpec (Minitest), FactoryBot (fixtures), and Redis (Solid Queue/Cable/Cache). A custom implementation you understand completely beats a black-box gem you have to debug blindly.
Incorrect (Devise for simple authentication):
# Gemfile — pulls in Warden, OmniAuth, bcrypt wrappers, 14 modules
gem "devise"
# app/models/user.rb — opaque module soup
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :timeoutable, :trackable
end
# 20+ routes you didn't ask for
# Migration with 30+ columns you may never use
# Debugging requires reading Devise + Warden internalsCorrect (custom session-based auth you fully control):
# app/models/user.rb
class User < ApplicationRecord
has_secure_password
has_many :sessions, dependent: :destroy
normalizes :email, with: -> { _1.strip.downcase }
# Rails 7.1 built-in token generation
generates_token_for :password_reset, expires_in: 20.minutes do
password_salt.last(10)
end
end
# app/models/session.rb
class Session < ApplicationRecord
belongs_to :user
before_create { self.token = SecureRandom.urlsafe_base64(32) }
end
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
user = User.authenticate_by(
email: params[:email],
password: params[:password]
)
if user
session = user.sessions.create!
cookies.signed.permanent[:session_token] = session.token
redirect_to root_path
else
redirect_to new_session_path, alert: "Invalid email or password"
end
end
def destroy
Current.session&.destroy
cookies.delete(:session_token)
redirect_to new_session_path
end
endBenefits:
- Zero hidden behavior — every auth decision is explicit in your code
- No gem upgrade surprises or CVE patches for code paths you don't use
- Adapts instantly to your domain (passwordless, one-time links, API tokens)
- Team learns Rails fundamentals instead of gem-specific DSLs
Reference: Basecamp/Fizzy
Custom Passwordless Auth Over Devise
37signals uses custom passwordless email-link authentication across all their apps. No Devise, no Doorkeeper, no OAuth gems. The implementation is ~150 lines: a SignInLink model that generates a 6-digit code with 15-minute expiration, a Session model that tracks user_agent and IP, and bearer token support for API access. This gives full control over the auth flow and eliminates one of the heaviest dependencies in the Rails ecosystem.
Incorrect (Devise with extensive configuration):
# Gemfile
gem "devise"
gem "devise-two-factor"
gem "omniauth"
# app/models/user.rb — Devise modules add hidden complexity
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :trackable
end
# config/initializers/devise.rb — 300+ lines of configuration
Devise.setup do |config|
config.mailer_sender = "noreply@example.com"
config.password_length = 8..128
config.reset_password_within = 6.hours
endCorrect (custom passwordless auth — models):
# app/models/identity.rb — global user, email-based
class Identity < ApplicationRecord
has_many :users # one per account (multi-tenant)
has_many :sessions, dependent: :destroy
normalizes :email, with: -> { _1.strip.downcase }
end
# app/models/sign_in_link.rb — passwordless auth token
class SignInLink < ApplicationRecord
belongs_to :identity
generates_token_for :authentication, expires_in: 15.minutes
before_create { self.code = SecureRandom.random_number(10**6).to_s.rjust(6, "0") }
def consume!
raise "Expired" if created_at < 15.minutes.ago
raise "Already used" if consumed_at.present?
update!(consumed_at: Time.current)
identity
end
end
# app/models/session.rb — tracks active sessions
class Session < ApplicationRecord
belongs_to :identity
belongs_to :user
endCorrect (custom passwordless auth — controllers):
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def create
identity = Identity.find_by!(email: params[:email])
link = identity.sign_in_links.create!
AuthMailer.sign_in_link(identity, link).deliver_later
redirect_to verify_path
end
def verify
link = SignInLink.find_by!(code: params[:code])
identity = link.consume!
session = identity.sessions.create!(user: identity.users.find_by(account: Current.account))
cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
redirect_to root_path
end
def destroy
Current.session&.destroy
cookies.delete(:session_id)
redirect_to root_path
end
end
# app/controllers/concerns/authentication.rb
module Authentication
extend ActiveSupport::Concern
included do
before_action :resume_session
helper_method :signed_in?
end
private
def resume_session
Current.session = Session.find_by(id: cookies.signed[:session_id])
Current.identity = Current.session&.identity
Current.user = Current.session&.user
end
def signed_in? = Current.session.present?
endWhen NOT to use:
- If you need OAuth provider integration (Sign in with Google/GitHub), consider
omniauthas a standalone gem without Devise. The session management can still be custom.
Reference: Basecamp Fizzy AGENTS.md
Domain Models as Facades Over Internal Complexity
Models should expose high-level methods that read like English while hiding internal systems behind them. recording.incinerate is better than Recording::IncinerationService.execute(recording). The caller doesn't need to know about storage cleanup, webhook notifications, or audit logging — the model is the facade.
Incorrect (service-based approach exposing internals):
# Caller must orchestrate multiple services
class RecordingsController < ApplicationController
def destroy
@recording = Recording.find(params[:id])
Recording::StorageCleanupService.new(@recording).call
Recording::WebhookNotifier.new(@recording, event: :deleted).call
Recording::AuditLogger.new(@recording, actor: Current.user).log_deletion
Recording::SearchIndexRemover.new(@recording).call
@recording.destroy!
redirect_to recordings_path, notice: "Recording deleted"
end
endCorrect (model as facade hiding complexity):
# app/models/recording.rb
class Recording < ApplicationRecord
include Incineratable
include Searchable
def incinerate
transaction do
cleanup_storage
notify_webhooks(:incinerated)
audit_log(:incinerated, actor: Current.user)
remove_from_search_index
destroy!
end
end
private
def cleanup_storage
attachments.each(&:purge_later)
end
def notify_webhooks(event)
WebhookDeliveryJob.perform_later(self, event)
end
def audit_log(action, actor:)
audits.create!(action: action, actor: actor)
end
end
# Controller is clean — one method call
class RecordingsController < ApplicationController
def destroy
@recording = Recording.find(params[:id])
@recording.incinerate
redirect_to recordings_path, notice: "Recording deleted"
end
endBenefits:
- Controllers stay thin — one line per action
- Public API reads like natural language (
recording.incinerate,message.publish) - Implementation can change without touching callers
- Concerns can break up internal complexity without leaking it outward
Reference: Vanilla Rails is Plenty
Earn Abstractions Through Rule of Three
Don't extract abstractions until you have three or more concrete cases that genuinely share the same pattern. Premature abstraction creates indirection, increases cognitive load, and often doesn't even fit when the second or third case arrives. As DHH puts it: "If you can't point to three or more variations that need it, inline it."
Incorrect (premature extraction after first use):
# Extracted after just ONE notification type exists
# app/services/notification_dispatcher.rb
class NotificationDispatcher
def initialize(strategy:, recipient:, payload:)
@strategy = strategy
@recipient = recipient
@payload = payload
end
def dispatch
adapter = NotificationAdapterFactory.for(@strategy)
message = MessageBuilder.new(@payload).build
adapter.deliver(message, to: @recipient)
end
end
# Only one adapter actually exists
# app/services/notification_adapters/email_adapter.rb
class NotificationAdapters::EmailAdapter
def deliver(message, to:)
UserMailer.notification(to, message).deliver_later
end
endCorrect (inline until three cases prove the pattern):
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :recording
belongs_to :creator, class_name: "User"
after_create_commit :notify_participants
private
def notify_participants
recipients = recording.participants.where.not(id: creator_id)
recipients.each do |recipient|
CommentMailer.new_comment(self, recipient).deliver_later
end
# TODO: When we add Slack/push notifications (3rd channel),
# extract a Notifier concern. Until then, inline is clearer.
end
endWhen NOT to use:
- Well-known patterns with established Rails conventions (concerns, callbacks, validators) are fine to use immediately — they're not speculative abstractions, they're framework idioms.
Reference: On Writing Software Well
Rich Domain Models Over Service Objects
Business logic belongs in ActiveRecord models augmented by concerns. Service objects create anemic models where domain knowledge scatters across the codebase, making behavior hard to find and reason about. When a model owns its logic, you can ask "what can a Recording do?" and the model itself answers. As DHH puts it: "These explicit classes for the notifier are anemic. Inline them."
Incorrect (service objects wrapping domain logic):
# app/services/recording_archiver.rb
class RecordingArchiver
def initialize(recording)
@recording = recording
end
def call
@recording.update!(archived_at: Time.current)
@recording.attachments.each { |a| a.update!(storage_tier: "glacier") }
RecordingMailer.archived(@recording).deliver_later
end
end
# Controller must know which service to use
RecordingArchiver.new(@recording).callCorrect (rich model with domain methods):
# app/models/recording.rb
class Recording < ApplicationRecord
has_many :attachments
def archive
update!(archived_at: Time.current)
attachments.each(&:move_to_cold_storage)
RecordingMailer.archived(self).deliver_later
end
def archived?
archived_at.present?
end
end
# Controller calls the model directly
@recording.archiveWhen NOT to use:
- Form objects coordinating multiple unrelated models (e.g.,
RegistrationFormcreating a User, Account, and Subscription) are acceptable — they represent input coordination, not domain logic. - Job orchestrators that sequence multiple async steps are not domain logic either.
Reference: Vanilla Rails is Plenty
Start Simple — Add Complexity Only After Validation
Build the minimal working version first: one model, one controller, basic CRUD. Skip abstractions, optimizations, and edge cases until real usage proves they're needed. A feature that ships with 3 models and no concerns is better than one with 8 models, 4 concerns, and a service layer that never sees production. Add complexity in response to observed problems, not anticipated ones.
Incorrect (over-engineered first implementation):
# First commit for a new "labels" feature — too much, too soon
# app/models/label.rb
class Label < ApplicationRecord
include Eventable
include Searchable
include Exportable
has_many :card_labels, dependent: :destroy
has_many :cards, through: :card_labels
has_many :label_groups, dependent: :destroy
validates :name, uniqueness: { scope: :account_id }
validates :color, format: { with: /\A#[0-9a-f]{6}\z/i }
scope :popular, -> { left_joins(:card_labels).group(:id).order("COUNT(card_labels.id) DESC") }
scope :unused, -> { left_joins(:card_labels).where(card_labels: { id: nil }) }
normalizes :name, with: -> { _1.strip.downcase }
after_create_commit :reindex_searchable
after_update_commit :broadcast_changes
def merge_into(other)
transaction do
card_labels.update_all(label_id: other.id)
destroy!
end
end
end
# app/models/label_group.rb — grouping before anyone has 10 labels
class LabelGroup < ApplicationRecord
has_many :labels
acts_as_list
end
# app/controllers/labels/merges_controller.rb — merge before anyone asked
class Labels::MergesController < ApplicationController
def create
@label = Label.find(params[:label_id])
@target = Label.find(params[:target_id])
@label.merge_into(@target)
redirect_to labels_path
end
endCorrect (minimal first implementation — add complexity when needed):
# First commit — minimal working feature
# app/models/label.rb
class Label < ApplicationRecord
has_many :card_labels, dependent: :destroy
has_many :cards, through: :card_labels
validates :name, presence: true
normalizes :name, with: -> { _1.strip }
end
# app/models/card_label.rb
class CardLabel < ApplicationRecord
belongs_to :card
belongs_to :label
end
# app/controllers/labels_controller.rb
class LabelsController < ApplicationController
def index
@labels = Current.account.labels
end
def create
@label = Current.account.labels.create!(label_params)
redirect_to labels_path
end
def destroy
Current.account.labels.find(params[:id]).destroy!
redirect_to labels_path
end
private
def label_params
params.expect(label: [:name, :color])
end
end
# Later — add concerns, scopes, and features as real usage demands them:
# - Uniqueness validation after users report duplicates
# - Eventable concern after activity tracking is needed
# - Merge functionality after users accumulate enough labels to need it
# - Label groups after users have 20+ labels and need organizationWhen NOT to use:
- Security features should be complete from the start — never ship "minimal" authentication or authorization.
- Database constraints (
null: false, unique indexes) should be set correctly upfront — they're hard to add later with existing data.
Reference: Shape Up — 37signals
Single Layer for Business Logic
Don't separate application and domain layers. Controllers call model methods directly — no interactors, use cases, or command patterns between them. Rails already provides the layering you need: controllers handle HTTP, models handle business logic. Adding an "application layer" between them creates ceremony without value.
Incorrect (unnecessary layering between controller and model):
# app/use_cases/create_project.rb
class CreateProject
def initialize(params:, user:)
@params = params
@user = user
end
def call
project = Project.new(@params)
project.creator = @user
if project.save
ProjectCreatedNotifier.new(project).notify
Result.success(project)
else
Result.failure(project.errors)
end
end
end
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
def create
result = CreateProject.new(
params: project_params,
user: Current.user
).call
if result.success?
redirect_to result.value
else
@project = Project.new(project_params)
render :new, status: :unprocessable_entity
end
end
endCorrect (controller calls model directly):
# app/models/project.rb
class Project < ApplicationRecord
belongs_to :creator, class_name: "User"
has_many :memberships, dependent: :destroy
after_create_commit :notify_team
private
def notify_team
ProjectMailer.created(self).deliver_later
end
end
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
def create
@project = Current.user.projects.build(project_params)
if @project.save
redirect_to @project
else
render :new, status: :unprocessable_entity
end
end
def update
@project = Current.user.projects.find(params[:id])
if @project.update(project_params)
redirect_to @project
else
render :edit, status: :unprocessable_entity
end
end
private
def project_params
params.expect(project: [:name, :description])
end
endWhen NOT to use:
- Simple CRUD is fine directly in controllers — don't create a model method just to wrap
update(params). - Multi-model coordination that doesn't belong to any single model (e.g., onboarding flow creating User, Organization, and Subscription) may warrant a form object, but not an "application layer."
Reference: Vanilla Rails is Plenty
Vanilla Rails is Plenty
Maximize Rails built-ins before adding gems. Rails ships with comprehensive primitives — delegated_type, store_accessor, normalizes, params.expect, generates_token_for — that replace entire categories of gems. Every gem added is a maintenance liability: version conflicts, security patches, abandoned projects, and API churn.
Incorrect (gem for every feature):
# Gemfile
gem "friendly_id" # slugs
gem "strip_attributes" # normalization
gem "store_model" # JSON attributes
gem "strong_migrations" # migration safety
gem "pundit" # authorization
# app/models/article.rb
class Article < ApplicationRecord
extend FriendlyId
friendly_id :title, use: :slugged
strip_attributes only: [:title, :summary]
endCorrect (vanilla Rails primitives):
# app/models/article.rb
class Article < ApplicationRecord
# Built-in slug generation
before_validation :generate_slug, on: :create
# Rails 7.1 normalizes replaces strip_attributes
normalizes :title, with: -> { _1.strip }
normalizes :summary, with: -> { _1.strip.gsub(/\s+/, " ") }
# Rails store_accessor replaces store_model
store_accessor :metadata, :reading_time, :featured_image_url
private
def generate_slug
self.slug = title.parameterize
end
end
# Controller uses params.expect (Rails 8) instead of strong_parameters ceremony
def article_params
params.expect(article: [:title, :summary, :body, metadata: [:reading_time]])
endBenefits:
- Fewer dependencies to audit, update, and debug
- No version lock-in or abandonment risk
- Team reads one codebase (Rails), not dozens of gem APIs
- Upgrades are simpler with fewer moving parts
Reference: Vanilla Rails is Plenty
Controller Concerns for Cross-Cutting Behavior
Extract shared controller behavior into concerns rather than relying on deep controller inheritance hierarchies. Concerns like Authenticatable, Accountable, and Turbo::Streamable compose horizontally, letting each controller include only the behavior it needs. This avoids the fragile base class problem where changes to a parent controller break unrelated children.
Incorrect (shared behavior duplicated or forced through inheritance):
# Deep inheritance hierarchy — fragile and hard to follow
class AuthenticatedController < ApplicationController
before_action :require_authentication
end
class AccountScopedController < AuthenticatedController
before_action :set_current_account
end
class AdminController < AccountScopedController
before_action :require_admin
end
# Controllers forced into a single inheritance chain
class ProjectsController < AccountScopedController
# Needs authentication + account scoping, but not admin
end
class ReportsController < AdminController
# Inherits 3 layers of before_actions
# Changing AccountScopedController breaks this too
end
# Duplicated behavior when inheritance doesn't fit
class Api::ProjectsController < ApplicationController
before_action :require_authentication # duplicated
before_action :set_current_account # duplicated
private
def require_authentication
# Same logic copied from AuthenticatedController
head :unauthorized unless Current.user
end
def set_current_account
# Same logic copied from AccountScopedController
Current.account = Current.user.accounts.find(params[:account_id])
end
endCorrect (composable concerns):
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :require_authentication
end
private
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session = Session.find_by(id: cookies.signed[:session_id])
end
end
# app/controllers/concerns/accountable.rb
module Accountable
extend ActiveSupport::Concern
included do
before_action :set_current_account
end
private
def set_current_account
Current.account = Current.user.accounts.find(params[:account_id])
end
end
# app/controllers/concerns/admin_authorizable.rb
module AdminAuthorizable
extend ActiveSupport::Concern
included do
before_action :require_admin
end
private
def require_admin
redirect_to root_path, alert: "Not authorized" unless Current.user.admin?
end
end
# Controllers compose only what they need
class ProjectsController < ApplicationController
include Authenticatable
include Accountable
endWhen NOT to use: If only one controller needs the behavior, inline it rather than extracting a concern prematurely. Concerns should emerge from duplication across two or more controllers.
Reference: Basecamp Fizzy
Use CRUD Controllers Over Custom Actions
Controllers should only implement the 7 standard CRUD actions: index, show, new, create, edit, update, destroy. Custom member or collection actions create non-standard routes that are harder to discover, test, and maintain. When you need behavior beyond CRUD, model it as a separate resource instead.
Incorrect (custom actions polluting the controller):
class CardsController < ApplicationController
before_action :set_card, only: [:show, :edit, :update, :destroy, :archive, :unarchive, :duplicate, :move, :assign]
def show
# ...
end
# Non-standard actions — these don't map to CRUD verbs
def archive
@card.update!(archived: true)
redirect_to board_path(@card.board)
end
def unarchive
@card.update!(archived: false)
redirect_to archives_path
end
def duplicate
@new_card = @card.dup
@new_card.save!
redirect_to @new_card
end
def move
@card.update!(list_id: params[:list_id])
redirect_to board_path(@card.board)
end
private
def set_card
@card = Card.find(params[:id])
end
end
# routes.rb — non-RESTful routes accumulate
resources :cards do
member do
post :archive
post :unarchive
post :duplicate
patch :move
end
endCorrect (strict CRUD-only controller):
class CardsController < ApplicationController
before_action :set_card, only: [:show, :edit, :update, :destroy]
def index
@cards = Current.board.cards
end
def show
end
def new
@card = Card.new
end
def create
@card = Current.board.cards.create!(card_params)
redirect_to @card
end
def edit
end
def update
@card.update!(card_params)
redirect_to @card
end
def destroy
@card.destroy!
redirect_to cards_path
end
private
def set_card
@card = Card.find(params[:id])
end
def card_params
params.expect(card: [:title, :description, :list_id])
end
end
# routes.rb — clean, predictable
resources :cardsWhen NOT to use: If you are building a non-resource API endpoint (health checks, webhooks from external services), a dedicated controller with a single action is acceptable.
Reference: Basecamp STYLE.md
Model Non-CRUD Operations as Separate Resources
When an operation does not map to a standard CRUD verb, introduce a new resource rather than adding custom actions. Archiving a card becomes Cards::ArchivalsController#create. Reopening it becomes Cards::ArchivalsController#destroy. This keeps every controller CRUD-only and makes the domain model visible through the routing layer.
Incorrect (custom routes for non-CRUD operations):
# routes.rb — custom actions break RESTful conventions
resources :cards do
member do
post :close
post :reopen
post :pin
delete :unpin
end
end
# cards_controller.rb — accumulates unrelated responsibilities
class CardsController < ApplicationController
def close
@card = Card.find(params[:id])
@card.update!(closed_at: Time.current, closed_by: Current.user)
redirect_to board_path(@card.board), notice: "Card closed"
end
def reopen
@card = Card.find(params[:id])
@card.update!(closed_at: nil, closed_by: nil)
redirect_to @card, notice: "Card reopened"
end
def pin
@card = Card.find(params[:id])
Current.user.pins.create!(pinnable: @card)
redirect_to @card
end
def unpin
@card = Card.find(params[:id])
Current.user.pins.find_by!(pinnable: @card).destroy!
redirect_to @card
end
endCorrect (separate resource controllers for each operation):
# routes.rb — every action maps to standard CRUD
resources :cards do
resource :closure, module: :cards, only: [:create, :destroy]
resource :pin, module: :cards, only: [:create, :destroy]
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
before_action :set_card
def create
@card.close!(by: Current.user)
redirect_to board_path(@card.board), notice: "Card closed"
end
def destroy
@card.reopen!
redirect_to @card, notice: "Card reopened"
end
private
def set_card
@card = Card.find(params[:card_id])
end
end
# app/controllers/cards/pins_controller.rb
class Cards::PinsController < ApplicationController
before_action :set_card
def create
Current.user.pins.create!(pinnable: @card)
redirect_to @card
end
def destroy
Current.user.pins.find_by!(pinnable: @card).destroy!
redirect_to @card
end
private
def set_card
@card = Card.find(params[:card_id])
end
endBenefits:
rake routesbecomes the complete API documentation- Each controller stays under 100 lines
- New operations require new controllers, not modifications to existing ones
- Testing is isolated: closure tests don't touch pin logic
Reference: Basecamp STYLE.md
Nested Resources with scope module
Use scope module: to organize sub-resource controllers into namespaced directories without deeply nesting routes. Each sub-resource gets its own controller in a cards/ directory (e.g., Cards::ClosuresController), keeping routes flat while the filesystem reflects the hierarchy. Combine with controller concerns like CardScoped to DRY up parent resource loading.
Incorrect (deeply nested routes or flat routes with long names):
# config/routes.rb — deeply nested creates verbose URL helpers
resources :boards do
resources :cards do
resources :comments
resources :closures
resources :assignments
end
end
# Generates: board_card_closure_path(@board, @card)
# 3 levels deep — verbose and hard to read
# Alternative mistake: flat routes lose hierarchy
resources :card_closures
resources :card_assignments
resources :card_comments
# No relationship between card and its sub-resources visible in routesCorrect (scope module for organized sub-resources):
# config/routes.rb — flat routes, organized controllers
resources :cards do
resource :closure, module: :cards, only: [:create, :destroy]
resource :assignment, module: :cards, only: [:create, :update, :destroy]
resources :comments, module: :cards
end
# Generates clean paths:
# POST /cards/:card_id/closure => Cards::ClosuresController#create
# DELETE /cards/:card_id/closure => Cards::ClosuresController#destroy
# POST /cards/:card_id/comments => Cards::CommentsController#create
# app/controllers/concerns/card_scoped.rb — shared parent loading
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card
end
private
def set_card
@card = Current.account.cards.find(params[:card_id])
end
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close!(by: Current.user)
redirect_to board_path(@card.board)
end
def destroy
@card.reopen!
redirect_to card_path(@card)
end
end
# app/controllers/cards/comments_controller.rb
class Cards::CommentsController < ApplicationController
include CardScoped
def create
@comment = @card.comments.create!(comment_params.merge(creator: Current.user))
redirect_to card_path(@card)
end
private
def comment_params
params.expect(comment: [:body])
end
endBenefits:
- Controllers live in
app/controllers/cards/— filesystem mirrors route hierarchy - URL helpers are short:
card_closure_path(@card), notboard_card_closure_path(@board, @card) CardScopedconcern loads the parent once — no duplication across sub-resource controllers- Adding a new sub-resource: create controller in
cards/, add one route line
Reference: Basecamp STYLE.md
Use params.expect() for Parameter Validation
Rails 8.0+ provides params.expect() which replaces the params.require(:model).permit(:field) chain with a single, declarative call. It raises ActionController::ExpectedParameterMissing when keys are absent and validates the parameter structure in one expression. This eliminates a class of bugs where require raises a different exception than permit silently drops keys.
Incorrect (legacy require/permit chain):
class ProjectsController < ApplicationController
private
def project_params
params.require(:project).permit(:name, :description, :due_date, :archived)
end
end
class MembershipsController < ApplicationController
private
# Nested permit is verbose and error-prone
def membership_params
params.require(:membership).permit(
:role,
user_attributes: [:id, :name, :email],
permissions: []
)
end
endCorrect (params.expect for clean validation):
class ProjectsController < ApplicationController
private
def project_params
params.expect(project: [:name, :description, :due_date, :archived])
end
end
class MembershipsController < ApplicationController
private
# Nested structures are naturally expressed
def membership_params
params.expect(membership: [:role, user_attributes: [:id, :name, :email], permissions: []])
end
end
# Multiple top-level keys return an array — destructure them
class BatchesController < ApplicationController
private
def extract_params
batch, filters = params.expect(batch: [:name], filters: [:status, :priority])
# batch => { "name" => "..." }
# filters => { "status" => "...", "priority" => "..." }
[batch, filters]
end
endAlternative: For optional parameter groups that may not be present in the request, params.permit without expect is still appropriate since missing keys should not raise errors.
Reference: Basecamp AGENTS.md
Thin Controllers with Rich Domain Models
Controllers handle HTTP concerns only: authentication, parameter parsing, and response formatting. All business logic belongs in domain models. Simple ActiveRecord operations like create! or update! can live in the controller directly — there is no need to wrap trivial persistence in model methods. The test is whether the logic requires domain knowledge: if it does, it belongs in the model.
Incorrect (fat controller with business logic):
class InvoicesController < ApplicationController
def create
@invoice = Invoice.new(invoice_params)
@invoice.number = "INV-#{Date.current.strftime('%Y%m')}-#{Invoice.count + 1}"
@invoice.due_date = Date.current + @invoice.account.payment_terms.days
@invoice.tax_amount = @invoice.line_items.sum { |li| li.amount * li.tax_rate }
@invoice.total = @invoice.line_items.sum(&:amount) + @invoice.tax_amount
if @invoice.total > 10_000
@invoice.requires_approval = true
@invoice.approval_status = "pending"
end
if @invoice.save
@invoice.account.update!(outstanding_balance: @invoice.account.outstanding_balance + @invoice.total)
InvoiceMailer.created(@invoice).deliver_later if @invoice.account.email_notifications?
redirect_to @invoice
else
render :new, status: :unprocessable_entity
end
end
endCorrect (thin controller delegating to rich model):
class InvoicesController < ApplicationController
def create
@invoice = Current.account.invoices.create!(invoice_params)
redirect_to @invoice
rescue ActiveRecord::RecordInvalid
render :new, status: :unprocessable_entity
end
private
def invoice_params
params.expect(invoice: [:recipient_id, line_items_attributes: [:description, :amount, :tax_rate]])
end
end
# app/models/invoice.rb — business logic lives here
class Invoice < ApplicationRecord
belongs_to :account
has_many :line_items, dependent: :destroy
accepts_nested_attributes_for :line_items
before_validation :assign_number, on: :create
before_validation :calculate_totals
after_create_commit :notify_account
after_create_commit :update_outstanding_balance
def requires_approval?
total > 10_000
end
private
def assign_number
self.number = "INV-#{Date.current.strftime('%Y%m')}-#{account.invoices.count + 1}"
self.due_date = Date.current + account.payment_terms.days
end
def calculate_totals
self.tax_amount = line_items.sum { |li| li.amount * li.tax_rate }
self.total = line_items.sum(&:amount) + tax_amount
self.approval_status = "pending" if requires_approval?
end
def notify_account
InvoiceMailer.created(self).deliver_later if account.email_notifications?
end
def update_outstanding_balance
account.increment!(:outstanding_balance, total)
end
endWhen NOT to use: Simple CRUD actions that only call create!, update!, or destroy! do not need model method wrappers. Extracting Invoice#save_invoice that just calls save! adds indirection without value.
Reference: Vanilla Rails is Plenty
Use Database-Backed Infrastructure Over Redis
All persistent state lives in PostgreSQL or SQLite. 37signals replaced Redis with database-backed alternatives — Solid Queue for jobs, Solid Cable for pub/sub, Solid Cache for caching — collapsing the infrastructure stack to a single data store. One fewer process to monitor, one fewer failure mode, one fewer deployment concern.
Incorrect (Redis-dependent infrastructure with multiple moving parts):
# Gemfile — three separate Redis-dependent gems
gem "sidekiq"
gem "redis"
gem "redis-actioncable"
# config/cable.yml — requires a running Redis instance
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
channel_prefix: myapp_production
# config/environments/production.rb — Redis for everything
config.cache_store = :redis_cache_store, {
url: ENV["REDIS_URL"],
expires_in: 1.hour,
error_handler: -> (method:, returning:, exception:) {
Sentry.capture_exception(exception)
}
}
config.active_job.queue_adapter = :sidekiq
# Procfile — two extra processes to manage
web: bin/rails server
worker: bundle exec sidekiq -C config/sidekiq.yml
redis: redis-server /usr/local/etc/redis.confCorrect (database-backed Solid stack with no external dependencies):
# Gemfile — the Solid trifecta, all database-backed
gem "solid_queue"
gem "solid_cable"
gem "solid_cache"
# config/cable.yml — backed by the database
production:
adapter: solid_cable
silence_polling: true
polling_interval: 0.1.seconds
# config/environments/production.rb — everything goes through the database
config.cache_store = :solid_cache_store
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# config/database.yml — dedicated databases for each concern
production:
primary:
<<: *default
database: myapp_production
queue:
<<: *default
database: myapp_production_queue
migrations_paths: db/queue_migrate
cable:
<<: *default
database: myapp_production_cable
migrations_paths: db/cable_migrate
cache:
<<: *default
database: myapp_production_cache
migrations_paths: db/cache_migrate
# Procfile — just the web server, Solid Queue runs via puma plugin
web: bin/rails serverWhen NOT to use:
- If you have an existing large-scale Redis deployment with sub-millisecond latency requirements (e.g., rate limiting at 100k+ req/s), a hybrid approach may be warranted during migration.
Benefits:
- Single infrastructure dependency — one database engine to operate, back up, and monitor
- Jobs, cache entries, and broadcasts survive process restarts without data loss
- Development environment matches production exactly with zero extra setup
- Simplifies deployment to platforms like Kamal, Hatchbox, or bare metal
Reference: 37signals Dev Blog
Path-Based Multi-Tenancy with Current.account
Use path-based tenancy (/{account_id}/...) with middleware that sets Current.account on every request. Access data through Current.account associations (Current.account.recordings) rather than manual where(account_id:) calls. Background jobs automatically preserve tenant context through CurrentAttributes serialization. No external tenant management gems needed.
Incorrect (manual tenant scoping scattered across the codebase):
# app/controllers/recordings_controller.rb — manual scoping in every action
class RecordingsController < ApplicationController
before_action :set_account
def index
# Must remember to scope every query — a single miss leaks data
@recordings = Recording.where(account_id: @account.id)
end
def create
@recording = Recording.new(recording_params)
@recording.account_id = @account.id # easy to forget
@recording.save!
redirect_to @recording
end
private
def set_account
@account = Account.find(params[:account_id])
end
endCorrect (Current.account with association-based scoping):
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :account, :user, :session
end
# app/controllers/concerns/account_scoped.rb
module AccountScoped
extend ActiveSupport::Concern
included do
prepend_before_action :set_current_account
end
private
def set_current_account
Current.account = Account.find(params[:account_id])
end
end
# app/controllers/recordings_controller.rb
class RecordingsController < ApplicationController
include AccountScoped
def index
@recordings = Current.account.recordings
end
def create
@recording = Current.account.recordings.create!(recording_params)
redirect_to @recording
end
end
# config/routes.rb
scope "/:account_id" do
resources :recordings
endWhen NOT to use:
- Single-tenant applications (personal tools, internal dashboards) do not need Current.account scoping. Only introduce multi-tenancy when multiple organizations share the same application instance.
Reference: Basecamp Fizzy AGENTS.md
No Foreign Key Constraints
37signals intentionally removes all foreign key constraints from their databases. Fizzy runs without a single foreign_key: true directive. Data integrity is enforced at the application level through model associations and validations. This gives maximum flexibility for data migrations, bulk deletions, cross-shard operations, and import/export workflows where constraint ordering would create circular dependency problems.
Incorrect (foreign key constraints on all associations):
# db/migrate/20240115_create_cards.rb
class CreateCards < ActiveRecord::Migration[8.0]
def change
create_table :cards, id: :uuid do |t|
t.references :board, null: false, foreign_key: true, type: :uuid
t.references :creator, null: false, foreign_key: { to_table: :users }, type: :uuid
t.references :assignee, foreign_key: { to_table: :users }, type: :uuid
t.references :account, null: false, foreign_key: true, type: :uuid
t.timestamps
end
end
end
# Problems:
# - Cannot delete an account without first deleting all cards, boards, users in exact order
# - Import/export must insert records in dependency order — circular references fail
# - Bulk data cleanup requires careful ordering of DELETE statements
# - Cross-database operations (sharded search) can't maintain FK integrityCorrect (no foreign keys, application-level integrity):
# db/migrate/20240115_create_cards.rb
class CreateCards < ActiveRecord::Migration[8.0]
def change
create_table :cards, id: :uuid do |t|
t.references :board, null: false, type: :uuid
t.references :creator, null: false, type: :uuid
t.references :assignee, type: :uuid
t.references :account, null: false, type: :uuid
t.timestamps
end
add_index :cards, [:account_id, :board_id]
end
end
# app/models/card.rb — integrity at the application layer
class Card < ApplicationRecord
belongs_to :board
belongs_to :creator, class_name: "User"
belongs_to :assignee, class_name: "User", optional: true
belongs_to :account
# dependent: :destroy on the parent handles cleanup
end
# app/models/board.rb
class Board < ApplicationRecord
has_many :cards, dependent: :destroy
# Deletion cascades through Rails, not the database
# Order doesn't matter — ActiveRecord handles it
endBenefits:
- Import/export can insert records in any order — no circular dependency issues
- Bulk deletions don't require topological sorting of foreign key chains
- Sharded search tables can reference records across databases
- Simpler migrations — no foreign key syntax to remember or maintain
NOT NULLconstraints still enforce required associations at the database level
When NOT to use:
- If your application has no import/export, no cross-database operations, and you want maximum database-level safety, foreign keys are fine. The 37signals choice is pragmatic for their specific workflow needs (500+GB exports between Fizzy instances).
Reference: Basecamp Fizzy AGENTS.md
Solid Cable for Real-Time Pub/Sub
Use Solid Cable instead of Redis for ActionCable WebSocket pub/sub. It polls the database at 0.1-second intervals, providing near-real-time updates without an external message broker. Broadcasts are automatically scoped by account for multi-tenancy, and stale messages are trimmed on a configurable schedule to keep the table lean.
Incorrect (Redis-backed ActionCable requiring separate infrastructure):
# config/cable.yml — requires Redis to be running and reachable
production:
adapter: redis
url: <%= ENV.fetch("REDIS_URL") %>
channel_prefix: myapp_production
# app/channels/card_channel.rb — manual tenant scoping in every channel
class CardChannel < ApplicationCable::Channel
def subscribed
# Must manually scope to prevent cross-tenant data leaks
board = Current.account.boards.find(params[:board_id])
stream_for board
rescue ActiveRecord::RecordNotFound
reject
end
end
# Broadcasting requires constructing the stream name manually
CardChannel.broadcast_to(
board,
{ action: "created", card: card.as_json }
)Correct (Solid Cable with database-backed pub/sub and automatic scoping):
# config/cable.yml — backed entirely by the database
production:
adapter: solid_cable
silence_polling: true
polling_interval: 0.1.seconds
message_retention: 1.hour
# config/database.yml — dedicated cable database
production:
cable:
<<: *default
database: myapp_production_cable
migrations_paths: db/cable_migrate
# app/channels/application_cable/connection.rb — tenant-aware connection
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user, :current_account
def connect
self.current_user = find_verified_user
self.current_account = current_user.account
end
private
def find_verified_user
User.find_by(id: cookies.signed[:user_id]) || reject_unauthorized_connection
end
end
end
# app/channels/card_channel.rb — scoping handled by connection identity
class CardChannel < ApplicationCable::Channel
def subscribed
board = current_account.boards.find(params[:board_id])
stream_for board
end
end
# Broadcasting is identical — Solid Cable is a drop-in adapter swap
CardChannel.broadcast_to(board, { action: "created", card: card.as_json })When NOT to use:
- Applications with thousands of concurrent WebSocket connections pushing updates multiple times per second (e.g., live trading dashboards) may need Redis pub/sub or AnyCable for lower latency. For typical collaboration tools, 0.1s polling is imperceptible to users.
Reference: 37signals Dev Blog
Solid Cache for Application Caching
Use Solid Cache as part of the Solid trifecta for database-backed application caching. Unlike Redis, Solid Cache persists to disk and survives process restarts without cold-cache penalties. Combined with HTTP caching (fresh_when), fragment caching, and query caching, it forms a multi-layer caching strategy that needs no external dependencies.
Incorrect (Redis-backed cache store with cold-start vulnerability):
# config/environments/production.rb — Redis dependency for caching
config.cache_store = :redis_cache_store, {
url: ENV["REDIS_URL"],
expires_in: 1.hour,
namespace: "myapp",
pool_size: 5,
error_handler: -> (method:, returning:, exception:) {
Sentry.capture_exception(exception)
}
}
# Redis restart = entire cache is gone, thundering herd on cold start
# Must provision and monitor Redis memory separately
# Cache eviction under memory pressure is unpredictable
# app/controllers/recordings_controller.rb — no HTTP caching layer
class RecordingsController < ApplicationController
def show
@recording = Recording.find(params[:id])
@comments = Rails.cache.fetch("recording/#{@recording.id}/comments", expires_in: 15.minutes) do
@recording.comments.includes(:creator).to_a
end
end
endCorrect (Solid Cache with multi-layer caching strategy):
# config/environments/production.rb — database-backed cache
config.cache_store = :solid_cache_store
config.solid_cache.connects_to = { database: { writing: :cache } }
# config/solid_cache.yml
production:
store_options:
max_age: 1.week
max_size: 256.megabytes
namespace: null
# config/database.yml — dedicated cache database
production:
cache:
<<: *default
database: myapp_production_cache
migrations_paths: db/cache_migrate
# app/controllers/recordings_controller.rb — layered caching
class RecordingsController < ApplicationController
def show
@recording = Recording.find(params[:id])
# Layer 1: HTTP caching — avoids hitting Rails entirely on 304
fresh_when @recording, public: false
# Layer 2: Fragment caching in views (cache key auto-expires on update)
# <%= cache @recording do %>
# <%= render @recording.comments %>
# <% end %>
end
def index
@recordings = Current.account.recordings.active
# Layer 3: Application cache for expensive aggregations
@stats = Rails.cache.fetch(["recording_stats", Current.account], expires_in: 1.hour) do
{
total: @recordings.count,
total_duration: @recordings.sum(:duration),
by_type: @recordings.group(:type).count
}
end
end
endBenefits:
- Cache survives deployments and process restarts — no cold-start thundering herd
- Disk is cheaper than RAM — cache more data for longer at lower cost
- Same backup and replication strategy as your primary database
max_sizeprovides predictable eviction, unlike Redis memory pressure surprises
Reference: 37signals Dev Blog
Solid Queue for Background Jobs
Use Solid Queue instead of Sidekiq for background jobs. It stores jobs in the database, eliminating Redis as a runtime dependency. Solid Queue supports recurring tasks, concurrency controls, semaphore-based throttling, and continuable long-running jobs — all backed by SQL. Jobs automatically serialize tenant context through Current attributes, and Mission Control provides a built-in web UI for monitoring.
Incorrect (Sidekiq with Redis dependency and manual tenant propagation):
# Gemfile
gem "sidekiq"
gem "sidekiq-cron"
gem "redis", ">= 4.0"
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = { url: ENV["REDIS_URL"], size: 25 }
end
Sidekiq.configure_client do |config|
config.redis = { url: ENV["REDIS_URL"], size: 5 }
end
# app/workers/recording_transcription_worker.rb — manual tenant context
class RecordingTranscriptionWorker
include Sidekiq::Worker
sidekiq_options queue: :default, retry: 3
def perform(account_id, recording_id)
# Must manually restore tenant context every time
account = Account.find(account_id)
Current.account = account
recording = account.recordings.find(recording_id)
TranscriptionService.process(recording)
ensure
Current.reset
end
end
# Caller must remember to pass account_id
RecordingTranscriptionWorker.perform_async(Current.account.id, recording.id)Correct (Solid Queue with automatic tenant context and built-in scheduling):
# Gemfile
gem "solid_queue"
gem "mission_control-jobs"
# config/solid_queue.yml — declarative queue configuration
production:
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 5
processes: 2
polling_interval: 0.1
# config/recurring.yml — separate file for recurring tasks
daily_digest:
class: DigestMailerJob
schedule: "every day at 9am"
# app/jobs/recording_transcription_job.rb — Current attributes propagate automatically
class RecordingTranscriptionJob < ApplicationJob
queue_as :default
limits_concurrency to: 2, key: -> (recording) { recording.account_id }, duration: 30.minutes
def perform(recording)
TranscriptionService.process(recording)
end
end
# Current.account is automatically serialized and restored by the framework
RecordingTranscriptionJob.perform_later(recording)
# config/routes.rb — built-in monitoring dashboard
mount MissionControl::Jobs::Engine, at: "/jobs"Alternative: For apps already running Sidekiq in production with custom middleware (e.g., Sidekiq Batches, Sidekiq Enterprise rate limiters), migrate incrementally by routing new job classes to Solid Queue while keeping existing ones on Sidekiq.
Reference: Introducing Solid Queue
Use UUIDs as Primary Keys
Use UUIDs instead of sequential integers as primary keys. Sequential IDs leak information (total record count, creation order) and enable enumeration attacks where an attacker iterates through /recordings/1, /recordings/2, etc. 37signals uses base36-encoded UUIDv7 for shorter, URL-friendly identifiers.
Incorrect (sequential integer primary keys exposing record structure):
# db/migrate/20240101000000_create_recordings.rb — default integer IDs
class CreateRecordings < ActiveRecord::Migration[8.0]
def change
create_table :recordings do |t|
t.belongs_to :account, null: false
t.string :title, null: false
t.timestamps
end
end
end
# URLs expose sequential IDs: /recordings/1, /recordings/2, ...
# Attacker knows there are ~1000 recordings by checking /recordings/1000Correct (UUID primary keys):
# db/migrate/20240101000000_create_recordings.rb
class CreateRecordings < ActiveRecord::Migration[8.0]
def change
create_table :recordings, id: :uuid do |t|
t.references :account, null: false, type: :uuid
t.string :title, null: false
t.timestamps
end
end
end
# config/initializers/generators.rb — UUIDs as default for all models
Rails.application.config.generators do |g|
g.orm :active_record, primary_key_type: :uuid
end
# URLs are opaque: /recordings/a1b2c3d4-e5f6-7890-abcd-ef1234567890
# No enumeration possible, no record count leakageAlternative: 37signals uses base36-encoded UUIDv7 (25-char strings) for shorter URLs. This requires custom ID generation via a HasUuid concern. For most applications, PostgreSQL's native gen_random_uuid() with id: :uuid is simpler and sufficient.
When NOT to use:
- Internal admin tools where ID enumeration is not a security concern and sequential IDs aid debugging.
Reference: Basecamp Fizzy AGENTS.md
Callbacks for Auxiliary Complexity
Use callbacks — especially after_create_commit and after_update_commit — to handle auxiliary concerns like notifications, webhooks, search indexing, and activity logging. This keeps the primary create/update path focused on the core domain operation. The caller says recording.publish! and gets exactly one responsibility; the model itself orchestrates the side effects that follow, discoverable by reading the class.
Incorrect (side effects inlined in controller and domain methods):
# app/controllers/recordings_controller.rb
def create
@recording = current_bucket.recordings.create!(recording_params)
# Side effects mixed into the controller
@recording.subscribers.each do |subscriber|
RecordingMailer.new_recording(subscriber, @recording).deliver_later
end
SearchIndex.reindex(@recording)
WebhookDelivery.enqueue(@recording, event: "recording.created")
Event.create!(action: "created", recordable: @recording, creator: Current.person)
redirect_to @recording
end
# Every action that creates a recording must repeat these side effects
# API controller, import job, console usage — all must remember to notify, index, webhookCorrect (callbacks handle auxiliary concerns):
# app/models/recording.rb
class Recording < ApplicationRecord
include Eventable
include Searchable
include Webhookable
has_many :subscribers, through: :subscriptions
after_create_commit :notify_subscribers
after_create_commit :deliver_webhooks
private
def notify_subscribers
subscribers.each do |subscriber|
RecordingMailer.new_recording(subscriber, self).deliver_later
end
end
end
# app/models/concerns/eventable.rb
module Eventable
extend ActiveSupport::Concern
included do
has_many :events, as: :recordable
after_create_commit -> { events.create!(action: "created", creator: Current.person) }
end
end
# app/controllers/recordings_controller.rb — clean, one responsibility
def create
@recording = current_bucket.recordings.create!(recording_params)
redirect_to @recording
end
# API controller, import job, console — all get the same side effects automaticallyWhen NOT to use:
- Callbacks that silently prevent saves (
before_validationreturning false) create hard-to-debug failures. Keep callbacks to post-commit side effects that do not affect the outcome of the primary operation.
Reference: On Writing Software Well
Concerns for Horizontal Code Sharing
Extract shared functionality into 50-150 line concerns organized by domain responsibility. A model like Card includes Assignable, Boostable, Eventable, Poppable, Searchable, Staged, Taggable — each concern owns one slice of behavior. Concerns compose better than deep inheritance hierarchies because they can be mixed into any model independently, and each one remains small enough to read in a single sitting.
Incorrect (bloated model with mixed responsibilities):
# app/models/card.rb — 800+ lines, everything inline
class Card < ApplicationRecord
has_many :assignments
has_many :assignees, through: :assignments, source: :person
has_many :taggings
has_many :tags, through: :taggings
has_many :events
scope :boosted, -> { where("boost_expires_at > ?", Time.current) }
scope :tagged_with, ->(name) { joins(:tags).where(tags: { name: name }) }
scope :assigned_to, ->(person) { joins(:assignments).where(assignments: { person: person }) }
def boost!(duration: 1.hour)
update!(boost_expires_at: Time.current + duration)
end
def boosted?
boost_expires_at&.future?
end
def assign(person, role: :participant)
assignments.create!(person: person, role: role)
end
def unassign(person)
assignments.where(person: person).destroy_all
end
def tag(name)
tags << Tag.find_or_create_by!(name: name) unless tagged_with?(name)
end
def tagged_with?(name)
tags.exists?(name: name)
end
# ... 500 more lines of search, staging, event tracking, popover logic
endCorrect (model composed of focused concerns):
# app/models/card.rb — clean, scannable
class Card < ApplicationRecord
include Assignable
include Boostable
include Eventable
include Searchable
include Staged
include Taggable
end
# app/models/concerns/boostable.rb — 30 lines, one responsibility
module Boostable
extend ActiveSupport::Concern
included do
scope :boosted, -> { where("boost_expires_at > ?", Time.current) }
end
def boost!(duration: 1.hour)
update!(boost_expires_at: Time.current + duration)
end
def boosted?
boost_expires_at&.future?
end
end
# app/models/concerns/assignable.rb — reusable across models
module Assignable
extend ActiveSupport::Concern
included do
has_many :assignments, as: :assignable, dependent: :destroy
has_many :assignees, through: :assignments, source: :person
scope :assigned_to, ->(person) { joins(:assignments).where(assignments: { person: person }) }
end
def assign(person, role: :participant)
assignments.create!(person: person, role: role)
end
def unassign(person)
assignments.where(person: person).destroy_all
end
endWhen NOT to use:
- Do not create a concern for logic used in only one model. Inline it until a second model needs the same behavior — earn the abstraction.
Reference: Vanilla Rails is Plenty
Counter Caches to Prevent N+1 Count Queries
Use Rails' built-in counter_cache to avoid COUNT(*) queries when displaying association counts. Every time you call project.tasks.count in a list, Rails fires a SQL COUNT query — in a list of 50 projects, that is 50 extra queries. A counter cache stores the count in a dedicated column on the parent, updated automatically on create and destroy. Count access becomes a simple column read: O(1) instead of O(n).
Incorrect (N+1 COUNT queries in a list view):
# app/models/project.rb
class Project < ApplicationRecord
has_many :tasks
has_many :comments
end
# app/views/projects/index.html.erb
<% @projects.each do |project| %>
<div>
<%= project.name %>
<%= project.tasks.count %> tasks <%# SELECT COUNT(*) FROM tasks WHERE project_id = ? %>
<%= project.comments.count %> comments <%# SELECT COUNT(*) FROM comments WHERE project_id = ? %>
</div>
<% end %>
# 50 projects = 100 extra COUNT queries on every page loadCorrect (counter cache columns with O(1) access):
# db/migrate/add_counter_caches_to_projects.rb
class AddCounterCachesToProjects < ActiveRecord::Migration[7.2]
def change
add_column :projects, :tasks_count, :integer, default: 0, null: false
add_column :projects, :comments_count, :integer, default: 0, null: false
end
end
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project, counter_cache: true
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :project, counter_cache: true
end
# app/views/projects/index.html.erb
<% @projects.each do |project| %>
<div>
<%= project.name %>
<%= project.tasks_count %> tasks <%# column read, no query %>
<%= project.comments_count %> comments <%# column read, no query %>
</div>
<% end %>
# 50 projects = 0 extra queriesAlternative: For existing data, reset counters after adding the migration:
# db/migrate/reset_project_counter_caches.rb
Project.find_each do |project|
Project.reset_counters(project.id, :tasks, :comments)
endReference: Basecamp Fizzy
Use delegated_type for Polymorphism
Rails' delegated_type provides polymorphism through delegation rather than single-table inheritance. The parent table holds shared attributes queried across all types, while each type gets its own table for type-specific data. This avoids the STI problem of nullable columns and wide tables, while keeping single-table queries fast on the parent — you can query all entries without joining type-specific tables.
Incorrect (single-table inheritance with nullable columns):
# One table with columns for every type — most are NULL per row
# entries: type, subject, body, url, caption, image_data, video_url, duration, ...
class Entry < ApplicationRecord
end
class Entry::Message < Entry
validates :subject, :body, presence: true
# url, caption, image_data, video_url, duration are always NULL
end
class Entry::Comment < Entry
validates :body, presence: true
# subject, url, image_data, video_url, duration are always NULL
end
class Entry::Share < Entry
validates :url, presence: true
# subject, body, image_data, video_url are always NULL
end
# Table grows wider with every new type
# NULL columns waste space and confuse developersCorrect (delegated_type with focused tables):
# db/migrate — shared columns in entries, type-specific in their own tables
create_table :entries do |t|
t.string :entryable_type, null: false
t.bigint :entryable_id, null: false
t.bigint :account_id, null: false
t.bigint :creator_id, null: false
t.timestamps
end
create_table :messages do |t|
t.string :subject, null: false
t.text :body, null: false
end
create_table :comments do |t|
t.text :body, null: false
end
# app/models/entry.rb
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Comment Share], dependent: :destroy
belongs_to :account
belongs_to :creator, class_name: "Person"
end
# app/models/message.rb
class Message < ApplicationRecord
has_one :entry, as: :entryable, touch: true
validates :subject, :body, presence: true
end
# Single-table queries on the parent — no joins needed
Entry.where(account: current_account).order(created_at: :desc)
# Type-specific access through delegation
entry.entryable # => #<Message subject: "Hello">
entry.message? # => trueBenefits:
- No NULL columns — each type table has only its own attributes
- Single-table queries on entries for feeds, timelines, activity logs
- Adding a new type is a new table and a new class, no migration on existing tables
- Database constraints (NOT NULL) can be enforced per type
Reference: Vanilla Rails is Plenty
Polymorphic Event Model for Activity Tracking
Use a polymorphic Event model as the single source of truth for all activity in the application. Every significant action — card closed, comment created, user assigned — creates an Event record. Events drive activity timelines, notification delivery, and webhook dispatch. The Eventable concern provides a track_event method that models include to record actions automatically.
Incorrect (scattered activity tracking across the codebase):
# Activity tracked differently in every model
class Card < ApplicationRecord
after_update :log_changes
private
def log_changes
# Custom activity logging per model
ActivityLog.create!(
model_type: "Card",
model_id: id,
changes: saved_changes.to_json,
user_id: Current.user&.id
)
end
end
# Notifications handled separately
class Comment < ApplicationRecord
after_create_commit :send_notifications
private
def send_notifications
# Direct notification logic, not connected to activity
card.watchers.each do |watcher|
NotificationMailer.new_comment(watcher, self).deliver_later
end
end
end
# Webhooks in a completely separate system
class WebhookDispatcher
def self.dispatch(action, resource)
Webhook.active.each { |wh| wh.deliver(action, resource) }
end
endCorrect (Event model with Eventable concern):
# app/models/event.rb — single source of truth
class Event < ApplicationRecord
belongs_to :eventable, polymorphic: true
belongs_to :creator, class_name: "User"
store_accessor :particulars # JSON column for action-specific metadata
after_create_commit :dispatch_webhooks
after_create_commit :deliver_notifications
scope :chronologically, -> { order(created_at: :asc) }
scope :reverse_chronologically, -> { order(created_at: :desc) }
private
def dispatch_webhooks = WebhookDeliveryJob.perform_later(self)
def deliver_notifications = NotificationDeliveryJob.perform_later(self)
end
# app/models/concerns/eventable.rb
module Eventable
extend ActiveSupport::Concern
included { has_many :events, as: :eventable, dependent: :destroy }
def track_event(action, creator: Current.user, particulars: {})
events.create!(action: action, creator: creator, particulars: particulars)
end
endCorrect (usage in models):
# app/models/card.rb
class Card < ApplicationRecord
include Eventable
def close!(by:)
update!(closed_at: Time.current)
track_event("card_closed", creator: by)
end
def assign!(to:, by:)
update!(assignee: to)
track_event("card_assigned", creator: by, particulars: { assignee_id: to.id })
end
end
# app/models/comment.rb
class Comment < ApplicationRecord
include Eventable
after_create_commit -> { track_event("comment_created") }
end
# Activity timeline — one query
Event.where(eventable: @card).reverse_chronologicallyBenefits:
- One table, one model, one query for all activity
- Webhooks and notifications wired once in Event, not per-model
particularsJSON stores action-specific metadata without extra columns- Activity feed is a single
Event.where(eventable:)query - Adding a new tracked action requires only a
track_eventcall
Reference: Basecamp Fizzy
Use normalizes Macro for Data Cleaning
Rails 7.1+ provides the normalizes macro for declarative, model-level data cleaning. It runs automatically before validation and on finder methods, ensuring consistent data everywhere — not just on save. This replaces scattered before_validation callbacks with a single, scannable declaration that makes normalization rules immediately visible at the top of the model.
Incorrect (manual before_validation callbacks):
# app/models/user.rb
class User < ApplicationRecord
before_validation :strip_and_downcase_email
before_validation :normalize_phone_number
before_validation :strip_name_fields
private
def strip_and_downcase_email
self.email = email&.strip&.downcase
end
def normalize_phone_number
self.phone = phone&.gsub(/[\s\-\(\)]/, "")
end
def strip_name_fields
self.first_name = first_name&.strip
self.last_name = last_name&.strip
end
end
# Finders don't normalize — inconsistent lookups
User.find_by(email: " Alice@Example.COM ") # => nil (missed match)Correct (normalizes macro):
# app/models/user.rb
class User < ApplicationRecord
normalizes :email, with: -> { _1.strip.downcase }
normalizes :phone, with: -> { _1.gsub(/[\s\-\(\)]/, "") }
normalizes :first_name, :last_name, with: -> { _1.strip }
end
# Finders auto-normalize — consistent lookups
User.find_by(email: " Alice@Example.COM ") # => #<User email: "alice@example.com">
# Works with where too
User.where(email: " BOB@test.com ") # normalizes before queryingBenefits:
- Normalization is declarative and scannable at the top of the model
- Finders auto-normalize, preventing lookup mismatches
- No private callback methods cluttering the model
- Composable: chain transformations in a single lambda
Reference: Basecamp Fizzy
Namespace POROs Under Parent Models
When domain logic doesn't fit into the ActiveRecord model itself, create Plain Old Ruby Objects (POROs) namespaced under the parent model. Place them in app/models/ using nested directories. These are NOT service objects — they are model-adjacent: Event::Description for presentation, Card::Eventable::SystemCommenter for business logic, User::Filtering for view context bundling. The namespace makes ownership clear.
Incorrect (service objects in a separate layer):
# app/services/card_search_filter.rb — service layer breaks the domain model
class CardSearchFilter
def initialize(user, board, params)
@user = user
@board = board
@params = params
end
def call
cards = @board.cards
cards = cards.where(assignee: @user) if @params[:mine]
cards = cards.where(status: @params[:status]) if @params[:status]
cards = cards.tagged_with(@params[:tag]) if @params[:tag]
cards
end
end
# app/services/event_formatter.rb — presentation logic in services
class EventFormatter
def initialize(event)
@event = event
end
def description
case @event.action
when "card_closed" then "#{@event.creator.name} closed this card"
when "card_assigned" then "#{@event.creator.name} assigned this card"
end
end
end
# Controller must know to use the right service
@filter = CardSearchFilter.new(Current.user, @board, params)
@cards = @filter.callCorrect (POROs namespaced under parent models):
# app/models/user/filtering.rb — view context bundled under User
class User::Filtering
attr_reader :user, :board, :params
def initialize(user, board, params = {})
@user = user
@board = board
@params = params
end
def cards
scope = board.cards
scope = scope.where(assignee: user) if params[:mine]
scope = scope.where(status: params[:status]) if params[:status]
scope
end
end
# app/models/event/description.rb — presentation logic under Event
class Event::Description
def initialize(event) = @event = event
def to_s
case @event.action
when "card_closed" then "#{@event.creator.name} closed this card"
when "card_assigned" then "#{@event.creator.name} assigned this card"
when "comment_created" then "#{@event.creator.name} commented"
end
end
end
# Controller usage — the namespace shows ownership
@filtering = User::Filtering.new(Current.user, @board, params)
@cards = @filtering.cardsGuidelines:
- Namespace under the model that "owns" the behavior:
User::Filtering, notFilterService - Place files in matching directory structure:
app/models/user/filtering.rb - These are NOT service objects — they don't replace model methods for core domain logic
- Use for: presentation formatting, complex query building, view context bundling, multi-step transformations
Reference: Basecamp Fizzy
Use store_accessor for JSON Column Access
Rails' store_accessor gives typed, attribute-like access to JSON columns without schema migrations. It generates getter/setter methods, supports dirty tracking, and works with validations and form helpers. Use it for flexible settings, metadata, and configuration that would otherwise require frequent column additions or a separate key-value table.
Incorrect (raw JSON access scattered through the codebase):
# app/models/account.rb
class Account < ApplicationRecord
# settings is a JSON column, but access is manual everywhere
end
# Controller — raw hash access, no type safety
def update_settings
settings = @account.settings || {}
settings["timezone"] = params[:timezone]
settings["email_notifications"] = params[:email_notifications] == "1"
settings["weekly_digest_day"] = params[:weekly_digest_day]
@account.update!(settings: settings)
end
# View — defensive hash access
<%= @account.settings&.dig("timezone") || "UTC" %>
# Querying requires remembering exact key names
Account.where("settings->>'timezone' = ?", "America/New_York")Correct (store_accessor with attribute-like interface):
# app/models/account.rb
class Account < ApplicationRecord
store_accessor :settings, :timezone, :email_notifications, :weekly_digest_day
# Works with validations
validates :timezone, inclusion: { in: ActiveSupport::TimeZone::MAPPING.keys }, allow_nil: true
# Works with defaults via attribute API
attribute :timezone, default: "UTC"
end
# Controller — standard attribute assignment
def update_settings
@account.update!(account_params)
end
private
def account_params
params.expect(account: [:timezone, :email_notifications, :weekly_digest_day])
end
# View — clean attribute access
<%= @account.timezone %>
# Dirty tracking works
@account.timezone_changed? # => true
@account.timezone_was # => "UTC"When NOT to use:
- If you need to query, index, or join on the data frequently, promote it to a proper column.
store_accessorvalues live inside JSON and are expensive to query at scale.
Reference: Basecamp Fizzy
Touch Chains for Cache Invalidation
Add touch: true to belongs_to associations so changes propagate up the object graph automatically. When a comment is updated, its card's updated_at changes, which in turn updates the bucket's updated_at — busting every fragment cache along the chain. No manual cache expiration logic, no Rails.cache.delete calls, no stale data. The updated_at timestamp becomes the cache key, and Rails' fragment caching handles the rest.
Incorrect (manual cache invalidation scattered across the codebase):
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :card
after_save :invalidate_caches
after_destroy :invalidate_caches
private
def invalidate_caches
Rails.cache.delete("card/#{card_id}/comments")
Rails.cache.delete("card/#{card_id}/fragment")
Rails.cache.delete("bucket/#{card.bucket_id}/cards")
card.update_column(:updated_at, Time.current)
card.bucket.update_column(:updated_at, Time.current)
end
end
# Fragile: every new cache site requires a new delete call
# Easy to miss a cache key when adding featuresCorrect (touch chains with automatic cascade invalidation):
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :card, touch: true
end
# app/models/card.rb
class Card < ApplicationRecord
belongs_to :bucket, touch: true
has_many :comments, dependent: :destroy
end
# app/models/bucket.rb
class Bucket < ApplicationRecord
has_many :cards, dependent: :destroy
end
# app/views/buckets/_bucket.html.erb — cache key includes updated_at
<% cache bucket do %>
<h2><%= bucket.name %></h2>
<% bucket.cards.each do |card| %>
<% cache card do %>
<%= render card.comments %>
<% end %>
<% end %>
<% end %>
# Comment saved → card.updated_at touched → bucket.updated_at touched
# All fragment caches auto-expire. Zero manual invalidation.When NOT to use:
- High-write associations where touching the parent on every child write would cause excessive database updates (e.g., a chatroom with thousands of messages per minute). In those cases, use time-based cache expiration instead.
Reference: Basecamp Fizzy
Database Constraints Over ActiveRecord Validations
Prefer database constraints (NOT NULL, UNIQUE, foreign keys, check constraints) over ActiveRecord validations for data integrity. AR validations only protect you when code goes through the model layer — raw SQL, bulk updates, background jobs with update_column, and race conditions all bypass them. Database constraints are the last line of defense and cannot be circumvented.
Incorrect (relying solely on ActiveRecord validations):
# db/migrate/20240115_create_memberships.rb
class CreateMemberships < ActiveRecord::Migration[7.1]
def change
create_table :memberships do |t|
t.references :user
t.references :account
t.string :role
t.timestamps
end
# No database-level constraints — integrity depends entirely on AR
end
end
# app/models/membership.rb
class Membership < ApplicationRecord
belongs_to :user
belongs_to :account
validates :user_id, presence: true
validates :account_id, presence: true
validates :role, presence: true, inclusion: { in: %w[admin member viewer] }
validates :user_id, uniqueness: { scope: :account_id }
# These validations are bypassed by:
# Membership.insert_all([...])
# membership.update_column(:role, "superadmin")
# Raw SQL: ActiveRecord::Base.connection.execute("UPDATE memberships SET user_id = NULL")
# Race condition: two threads both pass uniqueness check before either saves
endCorrect (database constraints with minimal AR validations for UX):
# db/migrate/20240115_create_memberships.rb
class CreateMemberships < ActiveRecord::Migration[7.1]
def change
create_table :memberships do |t|
t.references :user, null: false, foreign_key: true
t.references :account, null: false, foreign_key: true
t.string :role, null: false, default: "member"
t.timestamps
end
add_index :memberships, [:user_id, :account_id], unique: true
add_check_constraint :memberships, "role IN ('admin', 'member', 'viewer')", name: "memberships_role_check"
end
end
# app/models/membership.rb
class Membership < ApplicationRecord
belongs_to :user
belongs_to :account
enum :role, { admin: "admin", member: "member", viewer: "viewer" }, validate: true
# AR validations only for user-facing error messages
validates :user_id, uniqueness: { scope: :account_id, message: "is already a member of this account" }
# Data integrity is guaranteed at the DB level:
# - NULL user_id/account_id → DB rejects
# - Invalid role → DB check constraint rejects
# - Duplicate membership → DB unique index rejects
# - Orphaned foreign key → DB FK constraint rejects
endBenefits:
- Race conditions caught by unique indexes (AR validations have a TOCTOU gap)
- Bulk inserts, raw SQL, and
update_columnall remain safe - Foreign keys prevent orphaned records when parent is deleted
- Check constraints enforce domain rules regardless of code path
When NOT to use:
- Complex cross-model business rules (e.g., "a user can only have 3 active projects") are better as AR validations or application-level checks — encoding these in SQL constraints is fragile and hard to maintain.
Reference: DHH's code review patterns
Enums for Categorical States
Use Rails enums for categorical state that cycles through a fixed set of values and doesn't need per-transition history. Enums give you auto-generated scopes (.active, .archived), predicates (.active?, .archived?), and bang transitions (.active!) for free. They are safer than raw string columns because invalid values raise errors at assignment time.
Incorrect (raw string column for categorical state):
# app/models/project.rb
class Project < ApplicationRecord
# String column — no type safety, no generated methods
scope :active, -> { where(status: "active") }
scope :archived, -> { where(status: "archived") }
scope :on_hold, -> { where(status: "on_hold") }
def active?
status == "active" # typo "actve" silently passes
end
def archive!
update!(status: "archived") # nothing prevents "archvied"
end
validates :status, inclusion: { in: %w[active archived on_hold] }
# Validation catches bad data but only at save time, not assignment
endCorrect (Rails enum with integer-backed column):
# db/migrate/20240115_add_status_to_projects.rb
class AddStatusToProjects < ActiveRecord::Migration[7.1]
def change
add_column :projects, :status, :integer, default: 0, null: false
add_index :projects, :status
end
end
# app/models/project.rb
class Project < ApplicationRecord
enum :status, {
active: 0,
on_hold: 1,
archived: 2
}, validate: true
# All of these are auto-generated:
# Scopes: Project.active, Project.on_hold, Project.archived
# Predicates: project.active?, project.on_hold?, project.archived?
# Transitions: project.archived!
# Raises ArgumentError on invalid: Project.new(status: "invalid")
end
# Usage is clean and discoverable
project = Project.active.first
project.archive! # transitions and saves
project.archived? # => true
Project.archived.count # scoped queryAlternative — explicit hash syntax for clarity:
class Message < ApplicationRecord
enum :visibility, {
everyone: 0,
admins: 1,
creator: 2
}, suffix: true
# Generates: everyone_visibility?, admins_visibility?
# Useful when enum name collides with existing methods
endWhen NOT to use:
- When you need a full audit trail of transitions (who changed state, when, and why) — use record-based state instead. Enums overwrite the previous value with no history.
- When state is binary (done/not done) — a nullable timestamp like
completed_atis simpler and encodes timing.
Reference: Basecamp Fizzy
Records as State Over Boolean Columns
State transitions should create database records instead of flipping boolean flags. When a card is archived, create an Archiving record rather than setting archived: true. This gives you who performed the action, when it happened, and a full reversible history — booleans give you none of that.
Incorrect (boolean column for state):
# db/migrate/20240115_add_archived_to_cards.rb
class AddArchivedToCards < ActiveRecord::Migration[7.1]
def change
add_column :cards, :archived, :boolean, default: false, null: false
add_column :cards, :archived_by_id, :bigint
add_column :cards, :archived_at, :datetime
end
end
# app/models/card.rb
class Card < ApplicationRecord
belongs_to :archived_by, class_name: "User", optional: true
scope :archived, -> { where(archived: true) }
scope :active, -> { where(archived: false) }
def archive(by:)
# Boolean flip — no history, no undo trail, no audit log
update!(archived: true, archived_by: by, archived_at: Time.current)
end
def unarchive
# Previous archive context is lost forever
update!(archived: false, archived_by: nil, archived_at: nil)
end
endCorrect (record-based state with full history):
# db/migrate/20240115_create_archivings.rb
class CreateArchivings < ActiveRecord::Migration[7.1]
def change
create_table :archivings do |t|
t.references :card, null: false, foreign_key: true
t.references :creator, null: false, foreign_key: { to_table: :users }
t.timestamps
end
end
end
# app/models/archiving.rb
class Archiving < ApplicationRecord
belongs_to :card
belongs_to :creator, class_name: "User"
end
# app/models/card.rb
class Card < ApplicationRecord
has_many :archivings, dependent: :destroy
scope :archived, -> { where(id: Archiving.select(:card_id)) }
scope :active, -> { where.not(id: Archiving.select(:card_id)) }
def archive(by:)
archivings.create!(creator: by)
end
def unarchive
archivings.destroy_all
end
def archived?
archivings.exists?
end
end
# Full audit trail: Card.find(42).archivings
# => [#<Archiving card_id: 42, creator_id: 7, created_at: "2024-01-15 09:30:00">]Benefits:
- Every state change records who, when, and is individually reversible
- History is queryable: "show me everything archived last week"
- Multiple archives/unarchives are fully tracked
- No orphaned metadata when state reverts
When NOT to use:
- Simple on/off toggles with no business need for history (e.g., a user's
dark_modepreference) are fine as booleans — not every flag needs an audit trail. - For high-traffic read paths, add an index on the state table's foreign key (
add_index :archivings, :card_id) and consider a denormalized boolean maintained by callbacks for query performance.
Reference: Basecamp Fizzy
Timestamps for State Transitions
Use nullable timestamp columns (completed_at, deactivated_at, read_at) to represent state instead of booleans or string columns. A null completed_at means incomplete; a present value means done and tells you exactly when. This pattern is queryable, indexable, and encodes both state and timing in a single column.
Incorrect (boolean or enum for binary state):
# app/models/todo.rb
class Todo < ApplicationRecord
# Boolean loses "when" — you only know "if"
scope :completed, -> { where(completed: true) }
scope :incomplete, -> { where(completed: false) }
def complete!
update!(completed: true)
# When was it completed? No idea without a separate column.
end
end
# app/models/notification.rb
class Notification < ApplicationRecord
# String column with no type safety
# status can drift: "read", "Read", "READ", "seen"
scope :unread, -> { where(status: "unread") }
def mark_as_read!
update!(status: "read")
end
endCorrect (nullable timestamps encoding state + timing):
# app/models/todo.rb
class Todo < ApplicationRecord
scope :completed, -> { where.not(completed_at: nil) }
scope :incomplete, -> { where(completed_at: nil) }
def complete!
update!(completed_at: Time.current)
end
def uncomplete!
update!(completed_at: nil)
end
def completed?
completed_at.present?
end
end
# app/models/notification.rb
class Notification < ApplicationRecord
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
def mark_as_read!
update!(read_at: Time.current)
end
def read?
read_at.present?
end
end
# Queries are fast and expressive:
# Todo.completed.where(completed_at: 1.week.ago..)
# Notification.unread.where(created_at: ..1.day.ago)Alternative — multiple timestamps for lifecycle tracking:
# app/models/subscription.rb
class Subscription < ApplicationRecord
# Each timestamp captures a lifecycle event
# activated_at, paused_at, cancelled_at, expired_at
scope :active, -> { where.not(activated_at: nil).where(cancelled_at: nil, expired_at: nil) }
scope :paused, -> { where.not(paused_at: nil).where(cancelled_at: nil) }
def active?
activated_at.present? && cancelled_at.nil? && expired_at.nil?
end
endWhen NOT to use:
- When state has more than 2-3 values and you need mutual exclusivity (e.g.,
draft,published,archived,trashed) — use an enum instead, since multiple timestamp columns become unwieldy and can conflict.
Reference: Basecamp Fizzy
Compute at Write Time Not Read Time
Store computed values in the database when data changes rather than computing them at read time. DHH: "Compute at write time, not presentation time." This trades slightly more expensive writes for dramatically cheaper reads — enabling database sorting, pagination, indexing, and caching on values that would otherwise require N+1 queries or in-memory computation on every page load.
Incorrect (computing at read time in views and controllers):
# app/models/project.rb
class Project < ApplicationRecord
has_many :todos
# Computed on every read — no way to sort or paginate by this
def completion_percentage
return 0 if todos.count.zero?
(todos.where.not(completed_at: nil).count.to_f / todos.count * 100).round
end
def last_activity_at
# N+1 risk when called across a collection
[todos.maximum(:updated_at), comments.maximum(:created_at), updated_at].compact.max
end
end
# app/views/projects/index.html.erb
<% @projects.each do |project| %>
<!-- Two queries per project in the loop -->
<span><%= project.completion_percentage %>% complete</span>
<span>Last active: <%= time_ago_in_words(project.last_activity_at) %></span>
<% end %>
# Cannot do: Project.order(:completion_percentage) — it's not a column
# Cannot do: Project.where("completion_percentage > 80") — it's Ruby, not SQLCorrect (precomputing at write time with callbacks):
# db/migrate/20240115_add_computed_columns_to_projects.rb
class AddComputedColumnsToProjects < ActiveRecord::Migration[7.1]
def change
add_column :projects, :completion_percentage, :integer, default: 0, null: false
add_column :projects, :last_activity_at, :datetime
add_column :projects, :todos_count, :integer, default: 0, null: false
add_index :projects, :completion_percentage
add_index :projects, :last_activity_at
end
end
# app/models/todo.rb
class Todo < ApplicationRecord
belongs_to :project, counter_cache: :todos_count
after_save :update_project_completion
after_destroy :update_project_completion
private
def update_project_completion
total = project.todos.count
completed = project.todos.where.not(completed_at: nil).count
percentage = total.zero? ? 0 : (completed.to_f / total * 100).round
project.update_columns(
completion_percentage: percentage,
last_activity_at: Time.current
)
end
end
# app/models/project.rb
class Project < ApplicationRecord
has_many :todos
# Now these are just column reads — no computation
scope :most_active, -> { order(last_activity_at: :desc) }
scope :nearly_done, -> { where(completion_percentage: 80..100) }
end
# Views are trivial — no queries, no computation
# Project.most_active.nearly_done.page(params[:page])Alternative — use `after_touch` for cascading updates:
class Comment < ApplicationRecord
belongs_to :project, touch: true
after_create_commit { project.update_column(:last_activity_at, Time.current) }
endWhen NOT to use:
- When the computed value changes so frequently that write amplification outweighs read savings (e.g., a real-time view counter with thousands of writes per second) — use a cache or materialized view instead.
- When the computation is only needed in a single, rarely-visited view — inline computation is simpler and the cost is negligible.
Reference: DHH's code review patterns
Use _later and _now Suffixes for Async Operations
Methods that enqueue background jobs use the _later suffix. Their synchronous counterparts use the _now suffix. The job class itself delegates to the _now method, keeping the actual logic in the model rather than the job. This convention from STYLE.md makes the async/sync boundary explicit at every call site — you never have to check whether a method fires inline or enqueues.
Incorrect (ambiguous async boundary):
# app/models/recording.rb
class Recording < ApplicationRecord
def transcode
TranscodeJob.perform_later(id)
end
def process_transcode
update!(transcoded_at: Time.current)
attachments.each(&:generate_variants)
notify_creator
end
end
# app/jobs/transcode_job.rb
class TranscodeJob < ApplicationJob
def perform(recording_id)
recording = Recording.find(recording_id)
recording.process_transcode
end
end
# Calling code — is this sync or async?
@recording.transcode # async? sync? have to check
@recording.process_transcode # "process" doesn't clarify timingCorrect (_later/_now convention):
# app/models/recording.rb
class Recording < ApplicationRecord
# Enqueues — the _later suffix makes async intent explicit
def transcode_later
TranscodeJob.perform_later(id)
end
# Executes synchronously — _now suffix signals inline execution
def transcode_now
update!(transcoded_at: Time.current)
attachments.each(&:generate_variants)
notify_creator
end
end
# app/jobs/transcode_job.rb
class TranscodeJob < ApplicationJob
def perform(recording_id)
recording = Recording.find(recording_id)
recording.transcode_now
end
end
# Calling code — intent is unambiguous
@recording.transcode_later # clearly async, enqueues a job
@recording.transcode_now # clearly sync, runs inlineAlternative — when only one variant exists:
class Report < ApplicationRecord
# If there is no synchronous counterpart and the operation is
# always async, use _later without a _now pair
def generate_later
ReportGenerationJob.perform_later(id)
end
endWhen NOT to use:
- Rails built-in conventions like
deliver_lateranddeliver_nowon mailers already follow this pattern. Don't wrap them in additional_later/_nowmethods — call them directly.
Reference: Basecamp STYLE.md
Bang Methods Only When Non-Bang Exists
Only define a bang (!) method when a non-bang counterpart exists in the same class. The ! suffix means "this is the dangerous version" — which only makes sense relative to a safe alternative. Don't use ! merely to signal destructive or mutating behavior. Many destructive Ruby and Rails methods (destroy, delete, truncate) intentionally lack the ! suffix.
Incorrect (bang without a non-bang counterpart):
class Card < ApplicationRecord
# No non-bang version exists — the ! is misleading
def archive!
update!(archived_at: Time.current)
archivings.create!(creator: Current.user)
end
# ! used to signal "dangerous" — but there's no safe alternative
def purge_attachments!
attachments.each(&:purge)
end
# ! on a method that always raises — not meaningful
def validate_permissions!
raise "Unauthorized" unless editable_by?(Current.user)
end
endCorrect (bang paired with non-bang, or no bang at all):
class Card < ApplicationRecord
# Non-bang returns boolean, bang raises on failure
def archive
self.archived_at = Time.current
archivings.build(creator: Current.user)
save
end
def archive!
archive || raise(ActiveRecord::RecordInvalid, self)
end
# No safe version needed — just don't use !
def purge_attachments
attachments.each(&:purge)
end
# Predicate or guard — no ! needed
def ensure_editable_by(user)
raise "Unauthorized" unless editable_by?(user)
end
end
# Rails follows this pattern:
# save / save!
# create / create!
# update / update!
# destroy has no destroy! — it's always "destructive"Reference: Basecamp STYLE.md
Expanded Conditionals Over Guard Clauses
37signals prefers explicit if/else blocks over guard clauses because they show both branches clearly, making the full control flow visible at a glance. Guard clauses hide the "else" path by returning early, which can obscure intent when scanning a method. The only acceptable use of a guard clause is when the return appears at the very beginning of the method and the main body spans multiple lines.
Incorrect (guard clause obscuring both branches):
# app/models/recording.rb
class Recording < ApplicationRecord
def publishable?
return false if draft?
return false unless bucket.publishable?
attachments.any?
end
def publish
return unless publishable?
update!(published_at: Time.current)
notify_subscribers
end
private
def notify_subscribers
return if subscribers.none?
subscribers.each { |sub| RecordingMailer.published(self, sub).deliver_later }
end
endCorrect (expanded conditionals showing both paths):
# app/models/recording.rb
class Recording < ApplicationRecord
def publishable?
if draft?
false
elsif !bucket.publishable?
false
else
attachments.any?
end
end
def publish
if publishable?
update!(published_at: Time.current)
notify_subscribers
end
end
private
def notify_subscribers
if subscribers.any?
subscribers.each { |sub| RecordingMailer.published(self, sub).deliver_later }
end
end
endWhen NOT to use:
- A guard clause at the very top of a long method is acceptable when the main body is 5+ lines and the early return handles a trivial precondition (e.g.,
return if param.blank?before a multi-step process). The key is that the guard must be the first statement.
Reference: Basecamp STYLE.md
Methods Ordered by Call Sequence
Order methods vertically so the code reads top-to-bottom like a narrative. When method A calls method B, place B immediately after A. This eliminates the mental overhead of jumping around a file to trace execution flow. Within a class, class methods come first, then public instance methods (with initialize at the top), then private methods — each group following the same call-sequence rule.
Incorrect (methods in random order, hard to trace):
# app/models/inbox.rb
class Inbox < ApplicationRecord
def summarize
"#{title}: #{entry_count} entries"
end
def archive
entries.each(&:archive)
update!(archived_at: Time.current)
notify_owner
end
private
def notify_owner
InboxMailer.archived(self).deliver_later
end
public
def entry_count
entries.visible.count
end
def title
name.presence || "Untitled Inbox"
end
endCorrect (methods follow call sequence, top to bottom):
# app/models/inbox.rb
class Inbox < ApplicationRecord
def archive
entries.each(&:archive)
update!(archived_at: Time.current)
notify_owner
end
def summarize
"#{title}: #{entry_count} entries"
end
def title
name.presence || "Untitled Inbox"
end
def entry_count
entries.visible.count
end
private
# Called by #archive — positioned directly after public methods,
# mirroring the call order within the private section
def notify_owner
InboxMailer.archived(self).deliver_later
end
endBenefits:
- New contributors read the file once, top to bottom, and understand the full flow.
- Code reviews are faster because reviewers don't need to scroll back and forth.
- Related methods cluster naturally, making future extraction into concerns obvious.
Reference: Basecamp STYLE.md
Method Names Reflect Return Values
Method names should tell the reader what they return and whether they produce side effects. As DHH notes, "collect implies returning an array; use create_mentions when ignoring the return value." Verbs like create, update, send imply side effects. Nouns and adjectives like recipients, visible, total imply a returned value. Use consistent domain language throughout — don't alternate between "container", "source", and "resource" for the same concept.
Incorrect (names don't reflect return values or intent):
# app/models/message.rb
class Message < ApplicationRecord
# "process" — does it return something? cause side effects? both?
def process_mentions
body.scan(/@(\w+)/).flatten.map do |username|
Person.find_by(username: username)
end.compact
end
# "get" prefix is noise and doesn't clarify the return shape
def get_data
{ subject: subject, body: body, author: creator.name }
end
# Inconsistent domain language — "recipients" here, "targets" elsewhere
def compute_targets
room.members.where.not(id: creator_id)
end
end
# Calling code is confusing
mentions = message.process_mentions # are mentions processed or returned?
message.get_data # "get" adds nothing
message.compute_targets # "compute" implies heavy calculationCorrect (names reveal return values and intent):
# app/models/message.rb
class Message < ApplicationRecord
# Noun — returns an array of mentioned people
def mentioned_people
body.scan(/@(\w+)/).flatten.filter_map do |username|
Person.find_by(username: username)
end
end
# "create" verb — side effect is clear, return value is secondary
def create_mentions
mentioned_people.each do |person|
mentions.create!(person: person)
end
end
# Noun — consistent with domain language used everywhere
def recipients
room.members.where.not(id: creator_id)
end
# Adjective — clearly returns a hash/summary representation
def serialized
{ subject: subject, body: body, author: creator.name }
end
end
# Calling code reads clearly
message.mentioned_people # returns people
message.create_mentions # creates records (side effect)
message.recipients # returns who receives thisWhen NOT to use:
- Standard Rails conventions override this rule. Methods like
save,valid?, anddestroyhave well-established meanings in the framework. Don't rename them for consistency with this pattern.
Reference: DHH's code review patterns
Use Positive Names for Methods and Scopes
Name methods, scopes, and boolean attributes in the positive form. Positive names read naturally in conditionals and eliminate double negatives that force readers to mentally invert logic. When you write unless not_deleted? or if !inactive?, the intent is buried under two layers of negation. Positive names like visible?, active?, and published? make conditionals read like plain English.
Incorrect (negative naming forces mental gymnastics):
# app/models/comment.rb
class Comment < ApplicationRecord
scope :not_deleted, -> { where(deleted_at: nil) }
scope :not_spam, -> { where(flagged_as_spam: false) }
def not_deleted?
deleted_at.nil?
end
def not_hidden?
!hidden
end
end
# Double negatives in calling code
comments = Comment.not_deleted.not_spam
comments.each do |comment|
next unless comment.not_hidden? # "unless not hidden" — what?
render_comment(comment)
endCorrect (positive naming reads naturally):
# app/models/comment.rb
class Comment < ApplicationRecord
scope :visible, -> { where(deleted_at: nil) }
scope :authentic, -> { where(flagged_as_spam: false) }
def visible?
deleted_at.nil?
end
def shown?
!hidden
end
end
# Calling code reads like English
comments = Comment.visible.authentic
comments.each do |comment|
next unless comment.shown? # "unless shown" — clear
render_comment(comment)
endAlternative — when the domain naturally uses negation:
# Some domain terms are inherently negative and well-understood.
# "disabled" is acceptable when the domain concept IS disability (e.g., feature flags).
class Feature < ApplicationRecord
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
# Both are positive expressions of their respective states
endWhen NOT to use:
- When the negative form is the established domain term (e.g.,
unpublishedin a CMS where drafts are the primary workflow state). Forcing a positive name likein_draftmay confuse domain experts who think in terms of "unpublished."
Reference: DHH's code review patterns
Related skills
FAQ
What does 37signals-rails do?
37signals-rails: A skill for development. This provides functionality for development workflows.
When should I use 37signals-rails?
When you need to use 37signals-rails for development tasks, or when 37signals-rails: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
37signals-rails.