Files
claude-engineering-plugin/plugins/compound-engineering/skills/dhh-ruby-style/references/patterns.md
Kieran Klaassen 932f4ea69d [2.16.0] Expand DHH Rails/Ruby style skills with 37signals patterns
Massively enhanced reference documentation for both dhh-rails-style and
dhh-ruby-style skills by incorporating patterns from Marc Köhlbrugge's
Unofficial 37signals Coding Style Guide.

dhh-rails-style additions:
- controllers.md: Authorization patterns, rate limiting, Sec-Fetch-Site CSRF
- models.md: Validation philosophy, bang methods, Rails 7.1+ patterns
- frontend.md: Turbo morphing, Stimulus controllers, broadcasting patterns
- architecture.md: Multi-tenancy, database patterns, security, Active Storage
- gems.md: Testing philosophy, expanded what-they-avoid section

dhh-ruby-style additions:
- Development philosophy (ship/validate/refine)
- Rails 7.1+ idioms (params.expect, StringInquirer)
- Extraction guidelines (rule of three)

Credit: Marc Köhlbrugge's unofficial-37signals-coding-style-guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 10:12:55 -08:00

16 KiB

DHH Ruby/Rails Patterns Reference

Comprehensive code patterns extracted from 37signals' Campfire codebase and DHH's public teachings.

Controller Patterns

REST-Pure Controller Design

DHH's controller philosophy is "fundamentalistic" about REST. Every controller maps to a resource with only the 7 standard actions.

# ✅ CORRECT: Standard REST actions only
class MessagesController < ApplicationController
  def index; end
  def show; end
  def new; end
  def create; end
  def edit; end
  def update; end
  def destroy; end
end

# ❌ WRONG: Custom actions
class MessagesController < ApplicationController
  def archive    # NO
  def unarchive  # NO
  def search     # NO
  def drafts     # NO
end

# ✅ CORRECT: New controllers for custom behavior
class Messages::ArchivesController < ApplicationController
  def create  # archives a message
  def destroy # unarchives a message
end

class Messages::DraftsController < ApplicationController
  def index   # lists drafts
end

class Messages::SearchesController < ApplicationController
  def show    # shows search results
end

Controller Concerns for Shared Behavior

# app/controllers/concerns/room_scoped.rb
module RoomScoped
  extend ActiveSupport::Concern

  included do
    before_action :set_room
  end

  private
    def set_room
      @room = Current.user.rooms.find(params[:room_id])
    end
end

# Usage
class MessagesController < ApplicationController
  include RoomScoped
end

Complete Controller Example

class MessagesController < ApplicationController
  include ActiveStorage::SetCurrent, RoomScoped

  before_action :set_room, except: :create
  before_action :set_message, only: %i[ show edit update destroy ]
  before_action :ensure_can_administer, only: %i[ edit update destroy ]

  layout false, only: :index

  def index
    @messages = find_paged_messages
    if @messages.any?
      fresh_when @messages
    else
      head :no_content
    end
  end

  def create
    set_room
    @message = @room.messages.create_with_attachment!(message_params)
    @message.broadcast_create
    deliver_webhooks_to_bots
  rescue ActiveRecord::RecordNotFound
    render action: :room_not_found
  end

  def show
  end

  def edit
  end

  def update
    @message.update!(message_params)
    @message.broadcast_replace_to @room, :messages,
      target: [ @message, :presentation ],
      partial: "messages/presentation",
      attributes: { maintain_scroll: true }
    redirect_to room_message_url(@room, @message)
  end

  def destroy
    @message.destroy
    @message.broadcast_remove_to @room, :messages
  end

  private
    def set_message
      @message = @room.messages.find(params[:id])
    end

    def ensure_can_administer
      head :forbidden unless Current.user.can_administer?(@message)
    end

    def find_paged_messages
      case
      when params[:before].present?
        @room.messages.with_creator.page_before(@room.messages.find(params[:before]))
      when params[:after].present?
        @room.messages.with_creator.page_after(@room.messages.find(params[:after]))
      else
        @room.messages.with_creator.last_page
      end
    end

    def message_params
      params.require(:message).permit(:body, :attachment, :client_message_id)
    end

    def deliver_webhooks_to_bots
      bots_eligible_for_webhook.excluding(@message.creator).each { |bot| bot.deliver_webhook_later(@message) }
    end

    def bots_eligible_for_webhook
      @room.direct? ? @room.users.active_bots : @message.mentionees.active_bots
    end
end

Model Patterns

Semantic Association Naming

class Message < ApplicationRecord
  # ✅ Semantic names that express domain concepts
  belongs_to :creator, class_name: "User"
  belongs_to :room
  has_many :mentions
  has_many :mentionees, through: :mentions, source: :user

  # ❌ Generic names
  belongs_to :user  # Too generic - creator is clearer
end

class Room < ApplicationRecord
  has_many :memberships
  has_many :users, through: :memberships
  has_many :messages, dependent: :destroy

  # Semantic scope
  scope :direct, -> { where(direct: true) }

  def direct?
    direct
  end
end

Scope Design

class Message < ApplicationRecord
  # Eager loading scopes
  scope :with_creator, -> { includes(:creator) }
  scope :with_attachments, -> { includes(attachment_attachment: :blob) }

  # Cursor-based pagination scopes
  scope :page_before, ->(cursor) {
    where("id < ?", cursor.id).order(id: :desc).limit(50)
  }
  scope :page_after, ->(cursor) {
    where("id > ?", cursor.id).order(id: :asc).limit(50)
  }
  scope :last_page, -> { order(id: :desc).limit(50) }

  # Status scopes as chainable lambdas
  scope :recent, -> { where("created_at > ?", 24.hours.ago) }
  scope :pinned, -> { where(pinned: true) }
end

Custom Creation Methods

class Message < ApplicationRecord
  def self.create_with_attachment!(params)
    transaction do
      message = create!(params.except(:attachment))
      message.attach_file(params[:attachment]) if params[:attachment].present?
      message
    end
  end

  def attach_file(attachment)
    file.attach(attachment)
    update!(has_attachment: true)
  end
end

Authorization on Models

class User < ApplicationRecord
  def can_administer?(message)
    message.creator == self || admin?
  end

  def can_access?(room)
    rooms.include?(room) || admin?
  end

  def can_invite_to?(room)
    room.creator == self || admin?
  end
end

# Usage in controller
def ensure_can_administer
  head :forbidden unless Current.user.can_administer?(@message)
end

Model Broadcasting

class Message < ApplicationRecord
  after_create_commit :broadcast_create
  after_update_commit :broadcast_update
  after_destroy_commit :broadcast_destroy

  def broadcast_create
    broadcast_append_to room, :messages,
      target: "messages",
      partial: "messages/message"
  end

  def broadcast_update
    broadcast_replace_to room, :messages,
      target: dom_id(self, :presentation),
      partial: "messages/presentation"
  end

  def broadcast_destroy
    broadcast_remove_to room, :messages
  end
end

Current Attributes Pattern

Definition

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user
  attribute :session
  attribute :request_id
  attribute :user_agent

  resets { Time.zone = nil }

  def user=(user)
    super
    Time.zone = user&.time_zone
  end
end

Setting in Controller

class ApplicationController < ActionController::Base
  before_action :set_current_attributes

  private
    def set_current_attributes
      Current.user = authenticate_user
      Current.session = session
      Current.request_id = request.request_id
      Current.user_agent = request.user_agent
    end
end

Usage Throughout App

# In models
class Message < ApplicationRecord
  before_create :set_creator

  private
    def set_creator
      self.creator ||= Current.user
    end
end

# In views
<%= Current.user.name %>

# In jobs
class NotificationJob < ApplicationJob
  def perform(message)
    # Current is reset in jobs - pass what you need
    message.room.users.each { |user| notify(user, message) }
  end
end

Ruby Idioms

Guard Clauses Over Nested Conditionals

# ✅ Guard clauses
def process_message
  return unless message.valid?
  return if message.spam?
  return unless Current.user.can_access?(message.room)

  message.deliver
end

# ❌ Nested conditionals
def process_message
  if message.valid?
    unless message.spam?
      if Current.user.can_access?(message.room)
        message.deliver
      end
    end
  end
end

Expression-less Case Statements

# ✅ Clean case without expression
def status_class
  case
  when urgent? then "bg-red"
  when pending? then "bg-yellow"
  when completed? then "bg-green"
  else "bg-gray"
  end
end

# For routing/dispatch logic
def find_paged_messages
  case
  when params[:before].present?
    messages.page_before(params[:before])
  when params[:after].present?
    messages.page_after(params[:after])
  else
    messages.last_page
  end
end

Method Chaining

# ✅ Fluent, chainable API
@room.messages
     .with_creator
     .with_attachments
     .excluding(@message.creator)
     .page_before(cursor)

# On collections
bots_eligible_for_webhook
  .excluding(@message.creator)
  .each { |bot| bot.deliver_webhook_later(@message) }

Implicit Returns

# ✅ Implicit return - the Ruby way
def full_name
  "#{first_name} #{last_name}"
end

def can_administer?(message)
  message.creator == self || admin?
end

# ❌ Explicit return (only when needed for early exit)
def full_name
  return "#{first_name} #{last_name}"  # Unnecessary
end

View Patterns

Helper Methods for Complex HTML

# app/helpers/messages_helper.rb
module MessagesHelper
  def message_container(message, &block)
    tag.div(
      id: dom_id(message),
      class: message_classes(message),
      data: {
        controller: "message",
        message_id_value: message.id,
        action: "click->message#select"
      },
      &block
    )
  end

  private
    def message_classes(message)
      classes = ["message"]
      classes << "message--mine" if message.creator == Current.user
      classes << "message--highlighted" if message.highlighted?
      classes.join(" ")
    end
end

Turbo Frame Patterns

<%# app/views/messages/index.html.erb %>
<%= turbo_frame_tag "messages", data: { turbo_action: "advance" } do %>
  <%= render @messages %>

  <% if @messages.any? %>
    <%= link_to "Load more",
          room_messages_path(@room, before: @messages.last.id),
          data: { turbo_frame: "messages" } %>
  <% end %>
<% end %>

Stimulus Controller Integration

<div data-controller="message-form"
     data-message-form-submit-url-value="<%= room_messages_path(@room) %>">
  <%= form_with model: [@room, Message.new],
        data: { action: "submit->message-form#submit" } do |f| %>
    <%= f.text_area :body,
          data: { action: "keydown.enter->message-form#submitOnEnter" } %>
    <%= f.submit "Send" %>
  <% end %>
</div>

Testing Patterns

System Tests First

# test/system/messages_test.rb
class MessagesTest < ApplicationSystemTestCase
  test "sending a message" do
    sign_in users(:david)
    visit room_path(rooms(:watercooler))

    fill_in "Message", with: "Hello, world!"
    click_button "Send"

    assert_text "Hello, world!"
  end

  test "editing own message" do
    sign_in users(:david)
    visit room_path(rooms(:watercooler))

    within "#message_#{messages(:greeting).id}" do
      click_on "Edit"
    end

    fill_in "Message", with: "Updated message"
    click_button "Save"

    assert_text "Updated message"
  end
end

Fixtures Over Factories

# test/fixtures/users.yml
david:
  name: David
  email: david@example.com
  admin: true

jason:
  name: Jason
  email: jason@example.com
  admin: false

# test/fixtures/rooms.yml
watercooler:
  name: Water Cooler
  creator: david
  direct: false

# test/fixtures/messages.yml
greeting:
  body: Hello everyone!
  room: watercooler
  creator: david

Integration Tests for API

# test/integration/messages_api_test.rb
class MessagesApiTest < ActionDispatch::IntegrationTest
  test "creating a message via API" do
    post room_messages_url(rooms(:watercooler)),
      params: { message: { body: "API message" } },
      headers: auth_headers(users(:david))

    assert_response :success
    assert Message.exists?(body: "API message")
  end
end

Configuration Patterns

Solid Queue Setup

# config/queue.yml
default: &default
  dispatchers:
    - polling_interval: 1
      batch_size: 500
  workers:
    - queues: "*"
      threads: 5
      processes: 1
      polling_interval: 0.1

development:
  <<: *default

production:
  <<: *default
  workers:
    - queues: "*"
      threads: 10
      processes: 2

Database Configuration for SQLite

# config/database.yml
default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: storage/development.sqlite3

production:
  <<: *default
  database: storage/production.sqlite3

Single Container Deployment

# Dockerfile
FROM ruby:3.3

RUN apt-get update && apt-get install -y \
    libsqlite3-dev \
    libvips \
    ffmpeg

WORKDIR /rails
COPY . .
RUN bundle install
RUN rails assets:precompile

EXPOSE 80 443
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]

Development Philosophy

Ship, Validate, Refine

# 1. Merge prototype-quality code to test real usage
# 2. Iterate based on real feedback
# 3. Polish what works, remove what doesn't

DHH merges features early to validate in production. Perfect code that no one uses is worse than rough code that gets feedback.

Fix Root Causes

# ✅ Prevent race conditions at the source
config.active_job.enqueue_after_transaction_commit = true

# ❌ Bandaid fix with retries
retry_on ActiveRecord::RecordNotFound, wait: 1.second

Address underlying issues rather than symptoms.

Vanilla Rails Over Abstractions

# ✅ Direct ActiveRecord
@card.comments.create!(comment_params)

# ❌ Service layer indirection
CreateCommentService.call(@card, comment_params)

Use Rails conventions. Only abstract when genuine pain emerges.

Rails 7.1+ Idioms

params.expect (PR #120)

# ✅ Rails 7.1+ style
def card_params
  params.expect(card: [:title, :description, tags: []])
end

# Returns 400 Bad Request if structure invalid

# Old style
def card_params
  params.require(:card).permit(:title, :description, tags: [])
end

StringInquirer (PR #425)

# ✅ Readable predicates
event.action.inquiry.completed?
event.action.inquiry.pending?

# Usage
case
when event.action.inquiry.completed?
  send_notification
when event.action.inquiry.failed?
  send_alert
end

# Old style
event.action == "completed"

Positive Naming

# ✅ Positive names
scope :active, -> { where(active: true) }
scope :visible, -> { where(visible: true) }
scope :published, -> { where.not(published_at: nil) }

# ❌ Negative names
scope :not_deleted, -> { ... }  # Use :active
scope :non_hidden, -> { ... }   # Use :visible
scope :is_not_draft, -> { ... } # Use :published

Extraction Guidelines

Rule of Three

# First time: Just do it inline
def process
  # inline logic
end

# Second time: Still inline, note the duplication
def process_again
  # same logic
end

# Third time: NOW extract
module Processing
  def shared_logic
    # extracted
  end
end

Wait for genuine pain before extracting.

Start in Controller, Extract When Complex

# Phase 1: Logic in controller
def index
  @cards = @board.cards.where(status: params[:status])
end

# Phase 2: Move to model scope
def index
  @cards = @board.cards.by_status(params[:status])
end

# Phase 3: Extract concern if reused
def index
  @cards = @board.cards.filtered(params)
end

Anti-Patterns to Avoid

Don't Add Service Objects for Simple Cases

# ❌ Over-abstraction
class MessageCreationService
  def initialize(room, params, user)
    @room = room
    @params = params
    @user = user
  end

  def call
    message = @room.messages.build(@params)
    message.creator = @user
    message.save!
    BroadcastService.new(message).call
    message
  end
end

# ✅ Keep it in the model
class Message < ApplicationRecord
  def self.create_with_broadcast!(params)
    create!(params).tap(&:broadcast_create)
  end
end

Don't Use Policy Objects for Simple Auth

# ❌ Separate policy class
class MessagePolicy
  def initialize(user, message)
    @user = user
    @message = message
  end

  def update?
    @message.creator == @user || @user.admin?
  end
end

# ✅ Method on User model
class User < ApplicationRecord
  def can_administer?(message)
    message.creator == self || admin?
  end
end

Don't Mock Everything

# ❌ Over-mocked test
test "sending message" do
  room = mock("room")
  user = mock("user")
  message = mock("message")

  room.expects(:messages).returns(stub(create!: message))
  message.expects(:broadcast_create)

  MessagesController.new.create
end

# ✅ Test the real thing
test "sending message" do
  sign_in users(:david)
  post room_messages_url(rooms(:watercooler)),
    params: { message: { body: "Hello" } }

  assert_response :success
  assert Message.exists?(body: "Hello")
end