Files
claude-engineering-plugin/plugins/compound-engineering/skills/dhh-rails-style/references/architecture.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

13 KiB

Architecture - DHH Rails Style

## Routing

Everything maps to CRUD. Nested resources for related actions:

Rails.application.routes.draw do
  resources :boards do
    resources :cards do
      resource :closure
      resource :goldness
      resource :not_now
      resources :assignments
      resources :comments
    end
  end
end

Verb-to-noun conversion:

Action Resource
close a card card.closure
watch a board board.watching
mark as golden card.goldness
archive a card card.archival

Shallow nesting - avoid deep URLs:

resources :boards do
  resources :cards, shallow: true  # /boards/:id/cards, but /cards/:id
end

Singular resources for one-per-parent:

resource :closure   # not resources
resource :goldness

Resolve for URL generation:

# config/routes.rb
resolve("Comment") { |comment| [comment.card, anchor: dom_id(comment)] }

# Now url_for(@comment) works correctly

<multi_tenancy>

Multi-Tenancy (Path-Based)

Middleware extracts tenant from URL prefix:

# lib/tenant_extractor.rb
class TenantExtractor
  def initialize(app)
    @app = app
  end

  def call(env)
    path = env["PATH_INFO"]
    if match = path.match(%r{^/(\d+)(/.*)?$})
      env["SCRIPT_NAME"] = "/#{match[1]}"
      env["PATH_INFO"] = match[2] || "/"
    end
    @app.call(env)
  end
end

Cookie scoping per tenant:

# Cookies scoped to tenant path
cookies.signed[:session_id] = {
  value: session.id,
  path: "/#{Current.account.id}"
}

Background job context - serialize tenant:

class ApplicationJob < ActiveJob::Base
  around_perform do |job, block|
    Current.set(account: job.arguments.first.account) { block.call }
  end
end

Recurring jobs must iterate all tenants:

class DailyDigestJob < ApplicationJob
  def perform
    Account.find_each do |account|
      Current.set(account: account) do
        send_digest_for(account)
      end
    end
  end
end

Controller security - always scope through tenant:

# Good - scoped through user's accessible records
@card = Current.user.accessible_cards.find(params[:id])

# Avoid - direct lookup
@card = Card.find(params[:id])

</multi_tenancy>

## Authentication

Custom passwordless magic link auth (~150 lines total):

# app/models/session.rb
class Session < ApplicationRecord
  belongs_to :user

  before_create { self.token = SecureRandom.urlsafe_base64(32) }
end

# app/models/magic_link.rb
class MagicLink < ApplicationRecord
  belongs_to :user

  before_create do
    self.code = SecureRandom.random_number(100_000..999_999).to_s
    self.expires_at = 15.minutes.from_now
  end

  def expired?
    expires_at < Time.current
  end
end

Why not Devise:

  • ~150 lines vs massive dependency
  • No password storage liability
  • Simpler UX for users
  • Full control over flow

Bearer token for APIs:

module Authentication
  extend ActiveSupport::Concern

  included do
    before_action :authenticate
  end

  private
    def authenticate
      if bearer_token = request.headers["Authorization"]&.split(" ")&.last
        Current.session = Session.find_by(token: bearer_token)
      else
        Current.session = Session.find_by(id: cookies.signed[:session_id])
      end

      redirect_to login_path unless Current.session
    end
end

<background_jobs>

Background Jobs

Jobs are shallow wrappers calling model methods:

class NotifyWatchersJob < ApplicationJob
  def perform(card)
    card.notify_watchers
  end
end

Naming convention:

  • _later suffix for async: card.notify_watchers_later
  • _now suffix for immediate: card.notify_watchers_now
module Watchable
  def notify_watchers_later
    NotifyWatchersJob.perform_later(self)
  end

  def notify_watchers_now
    NotifyWatchersJob.perform_now(self)
  end

  def notify_watchers
    watchers.each do |watcher|
      WatcherMailer.notification(watcher, self).deliver_later
    end
  end
end

Database-backed with Solid Queue:

  • No Redis required
  • Same transactional guarantees as your data
  • Simpler infrastructure

Transaction safety:

# config/application.rb
config.active_job.enqueue_after_transaction_commit = true

Error handling by type:

class DeliveryJob < ApplicationJob
  # Transient errors - retry with backoff
  retry_on Net::OpenTimeout, Net::ReadTimeout,
           Resolv::ResolvError,
           wait: :polynomially_longer

  # Permanent errors - log and discard
  discard_on Net::SMTPSyntaxError do |job, error|
    Sentry.capture_exception(error, level: :info)
  end
end

Batch processing with continuable:

class ProcessCardsJob < ApplicationJob
  include ActiveJob::Continuable

  def perform
    Card.in_batches.each_record do |card|
      checkpoint!  # Resume from here if interrupted
      process(card)
    end
  end
end

</background_jobs>

<database_patterns>

Database Patterns

UUIDs as primary keys (time-sortable UUIDv7):

# migration
create_table :cards, id: :uuid do |t|
  t.references :board, type: :uuid, foreign_key: true
end

Benefits: No ID enumeration, distributed-friendly, client-side generation.

State as records (not booleans):

# Instead of closed: boolean
class Card::Closure < ApplicationRecord
  belongs_to :card
  belongs_to :creator, class_name: "User"
end

# Queries become joins
Card.joins(:closure)          # closed
Card.where.missing(:closure)  # open

Hard deletes - no soft delete:

# Just destroy
card.destroy!

# Use events for history
card.record_event(:deleted, by: Current.user)

Simplifies queries, uses event logs for auditing.

Counter caches for performance:

class Comment < ApplicationRecord
  belongs_to :card, counter_cache: true
end

# card.comments_count available without query

Account scoping on every table:

class Card < ApplicationRecord
  belongs_to :account
  default_scope { where(account: Current.account) }
end

</database_patterns>

<current_attributes>

Current Attributes

Use Current for request-scoped state:

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

  delegate :user, to: :session, allow_nil: true

  def account=(account)
    super
    Time.zone = account&.time_zone || "UTC"
  end
end

Set in controller:

class ApplicationController < ActionController::Base
  before_action :set_current_request

  private
    def set_current_request
      Current.session = authenticated_session
      Current.account = Account.find(params[:account_id])
      Current.request_id = request.request_id
    end
end

Use throughout app:

class Card < ApplicationRecord
  belongs_to :creator, default: -> { Current.user }
end

</current_attributes>

## Caching

HTTP caching with ETags:

fresh_when etag: [@card, Current.user.timezone]

Fragment caching:

<% cache card do %>
  <%= render card %>
<% end %>

Russian doll caching:

<% cache @board do %>
  <% @board.cards.each do |card| %>
    <% cache card do %>
      <%= render card %>
    <% end %>
  <% end %>
<% end %>

Cache invalidation via touch: true:

class Card < ApplicationRecord
  belongs_to :board, touch: true
end

Solid Cache - database-backed:

  • No Redis required
  • Consistent with application data
  • Simpler infrastructure
## Configuration

ENV.fetch with defaults:

# config/application.rb
config.active_job.queue_adapter = ENV.fetch("QUEUE_ADAPTER", "solid_queue").to_sym
config.cache_store = ENV.fetch("CACHE_STORE", "solid_cache").to_sym

Multiple databases:

# config/database.yml
production:
  primary:
    <<: *default
  cable:
    <<: *default
    migrations_paths: db/cable_migrate
  queue:
    <<: *default
    migrations_paths: db/queue_migrate
  cache:
    <<: *default
    migrations_paths: db/cache_migrate

Switch between SQLite and MySQL via ENV:

adapter = ENV.fetch("DATABASE_ADAPTER", "sqlite3")

CSP extensible via ENV:

config.content_security_policy do |policy|
  policy.default_src :self
  policy.script_src :self, *ENV.fetch("CSP_SCRIPT_SRC", "").split(",")
end
## Testing

Minitest, not RSpec:

class CardTest < ActiveSupport::TestCase
  test "closing a card creates a closure" do
    card = cards(:one)

    card.close

    assert card.closed?
    assert_not_nil card.closure
  end
end

Fixtures instead of factories:

# test/fixtures/cards.yml
one:
  title: First Card
  board: main
  creator: alice

two:
  title: Second Card
  board: main
  creator: bob

Integration tests for controllers:

class CardsControllerTest < ActionDispatch::IntegrationTest
  test "closing a card" do
    card = cards(:one)
    sign_in users(:alice)

    post card_closure_path(card)

    assert_response :success
    assert card.reload.closed?
  end
end

Tests ship with features - same commit, not TDD-first but together.

Regression tests for security fixes - always.

## Event Tracking

Events are the single source of truth:

class Event < ApplicationRecord
  belongs_to :creator, class_name: "User"
  belongs_to :eventable, polymorphic: true

  serialize :particulars, coder: JSON
end

Eventable concern:

module Eventable
  extend ActiveSupport::Concern

  included do
    has_many :events, as: :eventable, dependent: :destroy
  end

  def record_event(action, particulars = {})
    events.create!(
      creator: Current.user,
      action: action,
      particulars: particulars
    )
  end
end

Webhooks driven by events - events are the canonical source.

<email_patterns>

Email Patterns

Multi-tenant URL helpers:

class ApplicationMailer < ActionMailer::Base
  def default_url_options
    options = super
    if Current.account
      options[:script_name] = "/#{Current.account.id}"
    end
    options
  end
end

Timezone-aware delivery:

class NotificationMailer < ApplicationMailer
  def daily_digest(user)
    Time.use_zone(user.timezone) do
      @user = user
      @digest = user.digest_for_today
      mail(to: user.email, subject: "Daily Digest")
    end
  end
end

Batch delivery:

emails = users.map { |user| NotificationMailer.digest(user) }
ActiveJob.perform_all_later(emails.map(&:deliver_later))

One-click unsubscribe (RFC 8058):

class ApplicationMailer < ActionMailer::Base
  after_action :set_unsubscribe_headers

  private
    def set_unsubscribe_headers
      headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
      headers["List-Unsubscribe"] = "<#{unsubscribe_url}>"
    end
end

</email_patterns>

<security_patterns>

Security Patterns

XSS prevention - escape in helpers:

def formatted_content(text)
  # Escape first, then mark safe
  simple_format(h(text)).html_safe
end

SSRF protection:

# Resolve DNS once, pin the IP
def fetch_safely(url)
  uri = URI.parse(url)
  ip = Resolv.getaddress(uri.host)

  # Block private networks
  raise "Private IP" if private_ip?(ip)

  # Use pinned IP for request
  Net::HTTP.start(uri.host, uri.port, ipaddr: ip) { |http| ... }
end

def private_ip?(ip)
  ip.start_with?("127.", "10.", "192.168.") ||
    ip.match?(/^172\.(1[6-9]|2[0-9]|3[0-1])\./)
end

Content Security Policy:

# config/initializers/content_security_policy.rb
Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.script_src :self
    policy.style_src :self, :unsafe_inline
    policy.base_uri :none
    policy.form_action :self
    policy.frame_ancestors :self
  end
end

ActionText sanitization:

# config/initializers/action_text.rb
Rails.application.config.after_initialize do
  ActionText::ContentHelper.allowed_tags = %w[
    strong em a ul ol li p br h1 h2 h3 h4 blockquote
  ]
end

</security_patterns>

<active_storage>

Active Storage Patterns

Variant preprocessing:

class User < ApplicationRecord
  has_one_attached :avatar do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
    attachable.variant :medium, resize_to_limit: [300, 300], preprocessed: true
  end
end

Direct upload expiry - extend for slow connections:

# config/initializers/active_storage.rb
Rails.application.config.active_storage.service_urls_expire_in = 48.hours

Avatar optimization - redirect to blob:

def show
  expires_in 1.year, public: true
  redirect_to @user.avatar.variant(:thumb).processed.url, allow_other_host: true
end

Mirror service for migrations:

# config/storage.yml
production:
  service: Mirror
  primary: amazon
  mirrors: [google]

</active_storage>