AgentSkillsCN

vanilla-rails

采用 37signals/Basecamp 的 Vanilla Rails 理念设计并审查 Rails 应用程序。强调精简控制器、丰富领域模型,避免不必要的服务层。适用于分析 Rails 代码库、审查 PR,或向更简洁的架构进行重构时使用。可通过“服务层”、“服务对象”、“精简控制器”、“丰富模型”、“Vanilla Rails”、“DHH 风格”、“过度工程化”、“不必要的抽象”等短语触发。

SKILL.md
--- frontmatter
name: vanilla-rails
description: Design and review Rails applications using Vanilla Rails philosophy from 37signals/Basecamp. Emphasizes thin controllers, rich domain models, and avoiding unnecessary service layers. Use when analyzing Rails codebases, reviewing PRs, or refactoring toward simpler architecture. Triggers on "service layer", "service object", "thin controller", "rich model", "vanilla rails", "dhh style", "over-engineering", "unnecessary abstraction".
allowed-tools:
  - Grep
  - Glob
  - Read
  - Task

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.

code
┌─────────────────────────────────────────┐
│              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?

  1. Review code changes - Run /vanilla:review for Vanilla Rails architecture review
  2. Analyze codebase - Run /vanilla:analyze to identify over-engineering
  3. Plan simplification - Run /vanilla:simplify [goal] to plan refactoring toward Vanilla Rails
  4. Review PR/implementation - I'll evaluate against Vanilla Rails principles

Core Principles

The Three Rules

  1. Thin Controllers - Controllers only parse params and invoke model methods
  2. Rich Domain Model - Business logic belongs in models
  3. No Premature Abstraction - Don't create service layers by default

Common Anti-Patterns

Anti-PatternExampleFix
Fat service100-line service with domain logicMove logic to model
Anemic modelModel with only attributes and associationsAdd business methods
Controller as orchestratorController calling multiple servicesCall rich model methods
Premature serviceSimple CRUD wrapped in serviceUse plain Active Record
Service explosionDoSomethingService for every actionMost 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:

ruby
# 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:

ruby
# 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).

ruby
# 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

  1. class methods
  2. public methods (with initialize at top)
  3. private methods

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.

ruby
# 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.

ruby
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 _later suffix for methods that enqueue jobs
  • Use _now suffix for synchronous methods
ruby
# 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

PatternUse WhenReference
Plain Active RecordSimple CRUD, no coordination neededplain-activerecord.md
Rich Model APIComplex behavior single model should ownrich-models.md
ConcernShared behavior across modelsconcerns.md
Delegated Type"Is-a" relationships with shared identitydelegated-type.md
Service/FormOnly when genuinely justifiedwhen-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:

  • ActiveRecord models 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.