# Rails Dhh

> DHH/37signals Rails conventions — CRUD controllers, rich models with concerns, Hotwire, vanilla CSS, Solid Stack, Minitest.

- Skill: `diegosouzapw/rails-dhh` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add diegosouzapw/rails-dhh`
- Raw SKILL.md: https://api.skillmd.com/api/skills/diegosouzapw/rails-dhh/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: diegosouzapw (https://skillmd.com/u/diegosouzapw)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/diegosouzapw/rails-dhh

---


# Rails the DHH/37signals Way

This skill encodes the complete DHH/37signals Rails philosophy extracted from The Rails Doctrine, production patterns from 37signals' open-source codebases (Fizzy, Campfire, Writebook), DHH's Rails World 2025 keynote, and 265 analyzed pull requests.

**Core thesis: "Vanilla Rails is plenty."** Maximize what Rails gives out of the box. Minimize dependencies. Resist abstractions until absolutely necessary. Delete more than you add.

## Reference Guides

For detailed patterns on specific topics, read the companion files in this skill directory:

- **`hotwire.md`** — Turbo Drive/Frames/Streams/Morphing decision framework, Stimulus controller patterns, anti-patterns, gotchas
- **`css.md`** — Vanilla CSS with `@layer`, OKLCH color system, `:has()`, container queries, `@starting-style`
- **`testing.md`** — Minitest with fixtures, integration tests, DHH's testing philosophy
- **`deployment.md`** — Kamal 2, Solid Queue production config, safe migrations, Rails 8.1 features

---

## Quick Reference — Before Writing Code

Ask yourself:

1. **Can I use an existing Rails convention?** If yes, use it.
2. **Am I creating a new abstraction?** Does it earn its keep? Can you point to 3+ variations?
3. **Am I adding a dependency?** Can you build it yourself in fewer lines than the gem's README?
4. **Is this a new action on an existing controller?** Make it a new resource instead.
5. **Am I using a boolean column for state?** Use a record instead.
6. **Am I putting business logic in the controller?** Move it to the model.
7. **Am I writing a service object?** Put the logic in a concern on the model.
8. **Am I reaching for Turbo Streams?** Could morphing or a simple redirect handle it?
9. **Am I writing a Stimulus controller?** Could Turbo handle this without JS?
10. **Am I writing a system test?** Write an integration test instead.
11. **Am I writing CSS with JavaScript?** Could `:has()`, container queries, or `@starting-style` handle it?
12. **Is this migration safe?** Would it lock a large table? Use `strong_migrations`.
13. **Does this code earn its place?** Would DHH call it "anemic"?

---

## Philosophy — The Eight Pillars

1. **Rich domain models** over service objects
2. **CRUD controllers** over custom actions
3. **Concerns** for horizontal code sharing
4. **Records as state** over boolean columns
5. **Database-backed everything** (Solid Stack, no Redis)
6. **Build it yourself** before reaching for gems
7. **Ship, Validate, Refine** — deploy prototype quality, iterate on real feedback
8. **Delete more than you add** — fight "anemic" code (thin wrappers that explain nothing)

---

## The Stack

| Component        | Tool                          | NOT This              |
|------------------|-------------------------------|-----------------------|
| Framework        | Rails 8.x                    |                       |
| Ruby             | 3.2+ (3.3+ for YJIT)        |                       |
| Frontend         | Hotwire (Turbo 8 + Stimulus) | React, Vue, Svelte   |
| JavaScript       | Importmap-rails (no Node.js) | Webpack, esbuild, Vite |
| CSS              | Vanilla CSS with `@layer`    | Tailwind, Sass, PostCSS |
| Background Jobs  | Solid Queue                  | Sidekiq, Resque       |
| Caching          | Solid Cache                  | Redis, Memcached      |
| WebSockets       | Solid Cable                  | Redis-backed ActionCable |
| Asset Pipeline   | Propshaft                    | Sprockets             |
| Testing          | Minitest + fixtures          | RSpec + FactoryBot    |
| Auth             | `rails generate authentication` | Devise             |
| Deployment       | Kamal 2 + Thruster           | Heroku, Fly.io        |
| Server           | Puma                         |                       |

---

## Architecture Rules

### Everything is CRUD

Every action maps to a CRUD verb. When something doesn't fit, create a new resource:

```ruby
# ❌ BAD: Custom actions on existing resource
resources :cards do
  post :close
  post :reopen
end

# ✅ GOOD: New resources for each state change
resources :cards do
  resource :closure      # POST to close, DELETE to reopen
  resource :pin          # POST to pin, DELETE to unpin
  resource :watch        # POST to watch, DELETE to unwatch
end
```

Controllers have only the 7 RESTful actions: `index`, `show`, `new`, `create`, `edit`, `update`, `destroy`. If you need something else, you need a new controller.

### Thin Controllers, Rich Models

Controllers orchestrate. Models contain business logic:

```ruby
class Cards::ClosuresController < ApplicationController
  include CardScoped

  def create
    @card.close  # All logic lives in the model
    respond_to do |format|
      format.turbo_stream { render_card_replacement }
      format.html { redirect_to @card.board }
    end
  end

  def destroy
    @card.reopen
    respond_to do |format|
      format.turbo_stream { render_card_replacement }
      format.html { redirect_to @card.board }
    end
  end
end
```

### State as Records, Not Booleans

Instead of `closed: boolean`, create a separate model. This gives you who did it, when, and easy scoping for free:

```ruby
# Migration
create_table :closures do |t|
  t.references :card, null: false, foreign_key: true
  t.references :user, foreign_key: true
  t.timestamps
end

# Model
class Closure < ApplicationRecord
  belongs_to :card, touch: true
  belongs_to :user, optional: true
end

# Concern on parent
module Card::Closeable
  extend ActiveSupport::Concern

  included do
    has_one :closure, dependent: :destroy
    scope :closed, -> { joins(:closure) }
    scope :open, -> { where.missing(:closure) }
  end

  def close(user: Current.user)
    unless closed?
      transaction do
        create_closure!(user: user)
        track_event(:closed, creator: user)
      end
    end
  end

  def closed?
    closure.present?
  end

  def reopen
    closure&.destroy!
  end
end
```

### Concerns for Horizontal Sharing

Models include many small, self-contained concerns:

```ruby
class Card < ApplicationRecord
  include Assignable, Closeable, Eventable, Pinnable,
    Searchable, Taggable, Watchable

  belongs_to :board
  belongs_to :creator, class_name: "User", default: -> { Current.user }
end
```

Each concern owns its associations, scopes, validations, and methods. A concern should be understandable in isolation.

Controller concerns share behavior across controllers:

```ruby
module CardScoped
  extend ActiveSupport::Concern

  included do
    before_action :set_card
  end

  private
    def set_card
      @card = Card.find(params[:card_id])
    end
end
```

### Database-Backed Everything

No Redis. Use the Solid Stack:
- `solid_queue` for background jobs
- `solid_cache` for caching
- `solid_cable` for WebSockets

Configure separate databases in `config/database.yml`:

```yaml
production:
  primary:
    <<: *default
    database: app_production
  queue:
    <<: *default
    database: app_production_queue
    migrations_paths: db/queue_migrate
  cache:
    <<: *default
    database: app_production_cache
    migrations_paths: db/cache_migrate
  cable:
    <<: *default
    database: app_production_cable
    migrations_paths: db/cable_migrate
```

### Fight Anemic Code

DHH's test: **Does this abstraction earn its keep?** If you can't point to 3+ variations that need an abstraction, inline it.

- If a private method just wraps one line, inline it
- If a concern has one method, put it on the model
- If a helper reformats one value, do it in the view
- If a service object just calls one model method, remove it

### Data Integrity at the Database Level

Enforce constraints in the database, not just application validations:

```ruby
create_table :cards do |t|
  t.string :title, null: false
  t.references :board, null: false, foreign_key: true
  t.integer :position, null: false, default: 0
  t.timestamps
end

add_index :cards, [:board_id, :position], unique: true
```

---

## What NOT to Do

| Pattern                 | Why It's Avoided                                    | Instead                          |
|-------------------------|-----------------------------------------------------|----------------------------------|
| Service objects         | Business logic belongs in models                    | Rich models with concerns        |
| Form objects            | Rarely needed                                       | ActiveModel when absolutely needed |
| Decorators/Presenters   | Extra indirection                                   | Helpers + partials               |
| GraphQL                 | Over-engineered for most apps                       | REST + Turbo                     |
| Devise                  | Auth is ~150 lines of custom code                   | `rails generate authentication`  |
| RSpec                   | Minitest is simpler and faster                      | Minitest + fixtures              |
| FactoryBot             | Fixtures are faster and simpler                     | YAML fixtures                    |
| Tailwind               | Vanilla CSS with modern features is sufficient      | `@layer`, nesting, OKLCH, `:has()` |
| Redis                  | Database-backed everything                          | Solid Stack                      |
| ViewComponent          | Extra abstraction over ERB                          | Partials                         |
| Sidekiq                | Requires Redis                                      | Solid Queue                      |
| `dry-rb` gems          | Over-engineered for most Rails apps                 | Plain Ruby                       |
| Interactor/Command     | Service objects by another name                     | Model methods                    |
| `pundit`/`cancancan`   | Authorization logic belongs in models               | Model methods + concerns         |
| React/Vue/Svelte       | Conflicts with Hotwire server-first approach        | Turbo + Stimulus                 |
| Sass/PostCSS           | Native CSS is powerful enough now                   | Vanilla CSS                      |
| System tests           | "Always brittle, always broken, always slow" — DHH  | Controller integration tests     |

---

## Code Style

### Ruby Conventions (from Fizzy)

```ruby
# Symbol arrays with spaces inside brackets
before_action :set_message, only: %i[ show edit update destroy ]

# Private method indentation — 2 spaces after `private`
private
  def set_message
    @message = Message.find(params[:id])
  end

# Bang methods for fail-fast
@message = Message.create!(message_params)

# Ternaries for simple conditionals
@room.direct? ? "DM" : @room.name

# Default values via lambdas
belongs_to :creator, class_name: "User", default: -> { Current.user }

# Expression-less case for complex conditionals
case
when params[:before].present?
  messages.page_before(params[:before])
else
  messages.last_page
end

# Current for request context
Current.user
Current.session
Current.account
```

### Naming Conventions

- **Verb methods for actions:** `card.close`, `card.reopen`, `card.gild`
- **Predicate methods for state:** `card.closed?`, `card.golden?`, `card.pinned?`
- **Concerns are adjectives:** `Closeable`, `Publishable`, `Watchable`, `Taggable`
- **Controllers are nouns:** `Cards::ClosuresController`, `Cards::PinsController`
- **`_later` and `_now` for async patterns:**

```ruby
def mark_as_read_later(user:)
  MarkCardAsReadJob.perform_later(self, user)
end

def mark_as_read_now(user:)
  readings.find_or_create_by!(user: user).touch
end
```

### Background Jobs — Shallow Wrappers

Jobs just call model methods. All logic stays in the model:

```ruby
class NotifyRecipientsJob < ApplicationJob
  def perform(notifiable)
    notifiable.notify_recipients
  end
end
```

Recurring jobs in `config/recurring.yml`:

```yaml
deliver_bundled_notifications:
  command: "Notification::Bundle.deliver_all_later"
  schedule: every 30 minutes

cleanup_magic_links:
  command: "MagicLink.cleanup"
  schedule: every 4 hours
```

### HTTP Caching

Use ETags and `fresh_when` aggressively:

```ruby
class ApplicationController < ActionController::Base
  etag { "v1" }  # Bump to bust all caches on deploy
end

# In specific controllers
fresh_when etag: [@board, @page.records]
```

---

## Hotwire Decision Framework (Summary)

> **Full reference with code examples and anti-patterns:** Read `hotwire.md` in this directory.

```
1. Turbo Drive (free, zero code — handles all page navigation)
        ↓ not enough?
2. Morphing Page Refreshes (same-URL redirects, broadcasts_refreshes)
        ↓ not enough?
3. Turbo Frames (one self-contained region: inline edit, lazy load, modal)
        ↓ not enough?
4. Turbo Streams (multiple unrelated DOM updates from one response)
        ↓ not enough?
5. Stimulus (client-side interactivity: toggles, clipboard, animations)
        ↓ not enough?
6. Custom JS (last resort, wrap in Stimulus controller)
```

- **Different URL?** → Turbo Drive handles it. Do nothing.
- **Same-page redirect after form?** → Morphing: `turbo_refreshes_with method: :morph, scroll: :preserve`
- **Real-time multi-user?** → `broadcasts_refreshes` on model + `touch: true` on children
- **One self-contained region?** → Turbo Frame
- **Multiple unrelated DOM updates?** → Turbo Streams (HTTP response)
- **Client-side interactivity only?** → Stimulus controller

---

## File Organization

```
app/
├── controllers/
│   ├── concerns/             # Authentication, CardScoped, etc.
│   ├── cards/
│   │   ├── closures_controller.rb
│   │   ├── pins_controller.rb
│   │   └── watches_controller.rb
│   ├── cards_controller.rb
│   └── application_controller.rb
├── models/
│   ├── concerns/             # Closeable, Pinnable, Watchable, etc.
│   ├── card/
│   │   └── closeable.rb      # Namespaced concerns for complex models
│   ├── card.rb
│   ├── closure.rb
│   ├── current.rb            # CurrentAttributes
│   └── application_record.rb
├── views/
│   ├── cards/
│   │   ├── _card.html.erb
│   │   ├── index.html.erb
│   │   └── show.html.erb
│   ├── cards/closures/
│   │   └── create.turbo_stream.erb
│   ├── layouts/
│   └── shared/               # Shared partials
├── javascript/
│   └── controllers/          # Stimulus controllers
├── assets/
│   └── stylesheets/          # Flat structure, one file per concept
│       ├── application.css   # @layer imports only
│       ├── _reset.css
│       ├── base.css
│       ├── colors.css        # OKLCH + dark mode
│       ├── buttons.css
│       ├── inputs.css
│       └── utilities.css
├── jobs/                     # Shallow wrappers
└── mailers/
config/
├── database.yml              # PostgreSQL + Solid databases
├── deploy.yml                # Kamal 2
├── recurring.yml             # Solid Queue recurring jobs
├── solid_queue.yml           # Worker/dispatcher config
└── importmap.rb              # JS dependencies (no Node.js)
test/
├── fixtures/                 # YAML fixtures
├── models/
├── controllers/              # Integration tests (NOT system tests)
└── test_helper.rb
```

