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>
13 KiB
Architecture - DHH Rails Style
## RoutingEverything 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>
## AuthenticationCustom 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:
_latersuffix for async:card.notify_watchers_later_nowsuffix 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>
## CachingHTTP 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
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
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 TrackingEvents 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>