Awesome-omni-skill rails-dhh

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

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data-ai/rails-dhh" ~/.claude/skills/diegosouzapw-awesome-omni-skill-rails-dhh && rm -rf "$T"
manifest: skills/data-ai/rails-dhh/SKILL.md
source content

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

ComponentToolNOT This
FrameworkRails 8.x
Ruby3.2+ (3.3+ for YJIT)
FrontendHotwire (Turbo 8 + Stimulus)React, Vue, Svelte
JavaScriptImportmap-rails (no Node.js)Webpack, esbuild, Vite
CSSVanilla CSS with
@layer
Tailwind, Sass, PostCSS
Background JobsSolid QueueSidekiq, Resque
CachingSolid CacheRedis, Memcached
WebSocketsSolid CableRedis-backed ActionCable
Asset PipelinePropshaftSprockets
TestingMinitest + fixturesRSpec + FactoryBot
Auth
rails generate authentication
Devise
DeploymentKamal 2 + ThrusterHeroku, Fly.io
ServerPuma

Architecture Rules

Everything is CRUD

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

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

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:

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

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:

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
:

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:

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

PatternWhy It's AvoidedInstead
Service objectsBusiness logic belongs in modelsRich models with concerns
Form objectsRarely neededActiveModel when absolutely needed
Decorators/PresentersExtra indirectionHelpers + partials
GraphQLOver-engineered for most appsREST + Turbo
DeviseAuth is ~150 lines of custom code
rails generate authentication
RSpecMinitest is simpler and fasterMinitest + fixtures
FactoryBotFixtures are faster and simplerYAML fixtures
TailwindVanilla CSS with modern features is sufficient
@layer
, nesting, OKLCH,
:has()
RedisDatabase-backed everythingSolid Stack
ViewComponentExtra abstraction over ERBPartials
SidekiqRequires RedisSolid Queue
dry-rb
gems
Over-engineered for most Rails appsPlain Ruby
Interactor/CommandService objects by another nameModel methods
pundit
/
cancancan
Authorization logic belongs in modelsModel methods + concerns
React/Vue/SvelteConflicts with Hotwire server-first approachTurbo + Stimulus
Sass/PostCSSNative CSS is powerful enough nowVanilla CSS
System tests"Always brittle, always broken, always slow" — DHHController integration tests

Code Style

Ruby Conventions (from Fizzy)

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

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

Recurring jobs in

config/recurring.yml
:

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:

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