Files
claude-engineering-plugin/plugins/compound-engineering/skills/dhh-rails-style/references/architecture.md
Kieran Klaassen 4fb831ac85 feat(skills): Add dhh-rails-style skill for 37signals Rails conventions
Router-pattern skill with sectioned references:
- controllers.md: REST mapping, concerns, Turbo, API patterns
- models.md: Concerns, state records, callbacks, scopes
- frontend.md: Turbo, Stimulus, CSS architecture
- architecture.md: Routing, auth, jobs, caching, config
- gems.md: What they use vs avoid, decision framework

Based on analysis of Fizzy (Campfire) codebase.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 14:30:17 -08:00

6.3 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

Multi-tenancy via URL (not subdomain):

# /{account_id}/boards/...
scope "/:account_id" do
  resources :boards
end

Benefits:

  • No subdomain DNS complexity
  • Deep links work naturally
  • Middleware extracts account_id, moves to SCRIPT_NAME
  • Current.account available everywhere
## 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 </background_jobs>

<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.