Files
claude-engineering-plugin/plugins/compound-engineering/skills/dhh-ruby-style/references/patterns.md
Kieran Klaassen 6c5b3e40db [2.9.0] Rename plugin to compound-engineering
BREAKING: Plugin renamed from compounding-engineering to compound-engineering.
Users will need to reinstall with the new name:

  claude /plugin install compound-engineering

Changes:
- Renamed plugin directory and all references
- Updated documentation counts (24 agents, 19 commands)
- Added julik-frontend-races-reviewer to docs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 17:32:16 -08:00

14 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"]

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