Vanilla Rails
Design and review Rails applications using the Vanilla Rails philosophy from 37signals/Basecamp.
Based on Fizzy
This skill is informed by Fizzy - a production Rails application from 37signals.
Key Fizzy patterns:
- •Controllers call Active Record directly:
@board.update!(board_params),@card.comments.create!(comment_params) - •Models composed of concerns:
include Closeable, Golden, Postponable, Watchable - •State tracked with dedicated models:
has_one :closure,has_one :goldness - •No
app/services/directory - •Complex multi-step processes use plain objects or ActiveRecord models with state
Quick Start
Vanilla Rails embraces Rails's built-in patterns and avoids premature abstraction:
Core Philosophy: Thin controllers that directly invoke a rich domain model. No service layers or other artifacts unless genuinely justified.
┌─────────────────────────────────────────┐
│ CONTROLLERS │
│ (Thin - HTTP concerns only) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ MODELS │
│ (Rich - Business logic lives here) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ ACTIVE RECORD / DATABASE │
└─────────────────────────────────────────┘
Core Rule: Don't add layers beyond what Rails provides unless you have a clear, justified reason.
What Would You Like To Do?
- •Review code changes - Run
/vanilla:reviewfor Vanilla Rails architecture review - •Analyze codebase - Run
/vanilla:analyzeto identify over-engineering - •Plan simplification - Run
/vanilla:simplify [goal]to plan refactoring toward Vanilla Rails - •Review PR/implementation - I'll evaluate against Vanilla Rails principles
Core Principles
The Three Rules
- •Thin Controllers - Controllers only parse params and invoke model methods
- •Rich Domain Model - Business logic belongs in models
- •No Premature Abstraction - Don't create service layers by default
Common Anti-Patterns
| Anti-Pattern | Example | Fix |
|---|---|---|
| Fat service | 100-line service with domain logic | Move logic to model |
| Anemic model | Model with only attributes and associations | Add business methods |
| Controller as orchestrator | Controller calling multiple services | Call rich model methods |
| Premature service | Simple CRUD wrapped in service | Use plain Active Record |
| Service explosion | DoSomethingService for every action | Most should be model methods |
See Anti-Patterns Reference for complete list.
When Services Are Actually OK
Services are justified when:
- •Coordinating multiple models (orchestration, not domain logic)
- •External API interactions
- •Multi-step workflows with transaction boundaries
- •Operations that don't naturally belong to any single model
Fizzy uses plain objects for this:
# Multi-step signup with ActiveModel::Model
class Signup
include ActiveModel::Model
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :full_name, presence: true
def create_identity
@identity = Identity.find_or_create_by!(email_address: email_address)
@identity.send_magic_link(for: :sign_up)
end
def complete
# Complex account creation with rollback handling
end
end
Fizzy uses ActiveRecord models for stateful operations:
# Stateful import with status tracking
class Account::Import < ApplicationRecord
enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
def process(start: nil, callback: nil)
processing!
# Import logic with ZIP file handling
mark_completed
rescue => e
mark_as_failed
raise e
end
end
Style Preferences
Conditional Returns
Prefer expanded conditionals over guard clauses (unless returning early at method start for non-trivial bodies).
# Bad - Guard clause
def todos_for_new_group
ids = params.require(:todolist)[:todo_ids]
return [] unless ids
@bucket.recordings.todos.find(ids.split(","))
end
# Good - Expanded conditional
def todos_for_new_group
if ids = params.require(:todolist)[:todo_ids]
@bucket.recordings.todos.find(ids.split(","))
else
[]
end
end
Method Ordering
- •
classmethods - •
publicmethods (withinitializeat top) - •
privatemethods
Order methods vertically by invocation order to help readers follow code flow.
CRUD Controllers
Model endpoints as REST operations. Don't add custom actions - introduce new resources instead.
# Bad resources :cards do post :close post :reopen end # Good resources :cards do resource :closure end
Visibility Modifiers
No newline under visibility modifiers; indent content under them.
class SomeClass
def some_method
# ...
end
private
def some_private_method
# ...
end
end
If a module only has private methods, mark private at top with extra newline but don't indent.
Async Operations
Write shallow job classes that delegate to domain models:
- •Use
_latersuffix for methods that enqueue jobs - •Use
_nowsuffix for synchronous methods
# Fizzy pattern: _later enqueues, _now does the work
module Event::Relaying
extend ActiveSupport::Concern
included do
after_create_commit :relay_later
end
def relay_later
Event::RelayJob.perform_later(self)
end
def relay_now
# actual implementation
end
end
class Event::RelayJob < ApplicationJob
def perform(event)
event.relay_now
end
end
Bang Methods
Only use ! for methods with a counterpart without !. Don't use ! to flag destructive actions.
Pattern Catalog
| Pattern | Use When | Reference |
|---|---|---|
| Plain Active Record | Simple CRUD, no coordination needed | plain-activerecord.md |
| Rich Model API | Complex behavior single model should own | rich-models.md |
| Concern | Shared behavior across models | concerns.md |
| Delegated Type | "Is-a" relationships with shared identity | delegated-type.md |
| Service/Form | Only when genuinely justified | when-to-use-services.md |
Red Flags (Over-Engineering)
Run /vanilla:analyze to detect:
- •🔴 Service objects for simple operations
- •🔴 Business logic in services instead of models
- •🔴 Controllers with more than 10 lines
- •🔴 "Managers", "Handlers", "Processors" that are just proxies
- •⚠️ Anemic models (attributes + associations only)
- •⚠️ Domain logic scattered across service objects
- •⚠️ Unnecessary abstraction layers
Examples
See examples/ directory for before/after comparisons showing the Vanilla Rails approach.
Philosophy
"Vanilla Rails is plenty." - DHH
Most applications don't need layers beyond what Rails provides. Embrace:
- •
ActiveRecordmodels as the home of business logic - •Controllers as thin wrappers around model calls
- •Callbacks and concerns for code organization
- •Jobs and mailers called from models when appropriate
Resist:
- •Service layers as default architecture
- •Premature extraction
- •"Clean Architecture" for simple CRUD
- •Pattern-driven development
For more depth, read the Vanilla Rails blog post.