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

5.6 KiB

Gems - DHH Rails Style

<what_they_use>

What 37signals Uses

Core Rails stack:

  • turbo-rails, stimulus-rails, importmap-rails
  • propshaft (asset pipeline)

Database-backed services (Solid suite):

  • solid_queue - background jobs
  • solid_cache - caching
  • solid_cable - WebSockets/Action Cable

Authentication & Security:

  • bcrypt (for any password hashing needed)

Their own gems:

  • geared_pagination (cursor-based pagination)
  • lexxy (rich text editor)
  • mittens (mailer utilities)

Utilities:

  • rqrcode (QR code generation)
  • redcarpet + rouge (Markdown rendering)
  • web-push (push notifications)

Deployment & Operations:

  • kamal (Docker deployment)
  • thruster (HTTP/2 proxy)
  • mission_control-jobs (job monitoring)
  • autotuner (GC tuning) </what_they_use>

<what_they_avoid>

What They Deliberately Avoid

Authentication:

devise → Custom ~150-line auth

Why: Full control, no password liability with magic links, simpler.

Authorization:

pundit/cancancan → Simple role checks in models

Why: Most apps don't need policy objects. A method on the model suffices:

class Board < ApplicationRecord
  def editable_by?(user)
    user.admin? || user == creator
  end
end

Background Jobs:

sidekiq → Solid Queue

Why: Database-backed means no Redis, same transactional guarantees.

Caching:

redis → Solid Cache

Why: Database is already there, simpler infrastructure.

Search:

elasticsearch → Custom sharded search

Why: Built exactly what they need, no external service dependency.

View Layer:

view_component → Standard partials

Why: Partials work fine. ViewComponents add complexity without clear benefit for their use case.

API:

GraphQL → REST with Turbo

Why: REST is sufficient when you control both ends. GraphQL complexity not justified.

Factories:

factory_bot → Fixtures

Why: Fixtures are simpler, faster, and encourage thinking about data relationships upfront.

Service Objects:

Interactor, Trailblazer → Fat models

Why: Business logic stays in models. Methods like card.close instead of CardCloser.call(card).

Form Objects:

Reform, dry-validation → params.expect + model validations

Why: Rails 7.1's params.expect is clean enough. Contextual validations on model.

Decorators:

Draper → View helpers + partials

Why: Helpers and partials are simpler. No decorator indirection.

CSS:

Tailwind, Sass → Native CSS

Why: Modern CSS has nesting, variables, layers. No build step needed.

Frontend:

React, Vue, SPAs → Turbo + Stimulus

Why: Server-rendered HTML with sprinkles of JS. SPA complexity not justified.

Testing:

RSpec → Minitest

Why: Simpler, faster boot, less DSL magic, ships with Rails. </what_they_avoid>

<testing_philosophy>

Testing Philosophy

Minitest - simpler, faster:

class CardTest < ActiveSupport::TestCase
  test "closing creates closure" do
    card = cards(:one)
    assert_difference -> { Card::Closure.count } do
      card.close
    end
    assert card.closed?
  end
end

Fixtures - loaded once, deterministic:

# test/fixtures/cards.yml
open_card:
  title: Open Card
  board: main
  creator: alice

closed_card:
  title: Closed Card
  board: main
  creator: bob

Dynamic timestamps with ERB:

recent:
  title: Recent
  created_at: <%= 1.hour.ago %>

old:
  title: Old
  created_at: <%= 1.month.ago %>

Time travel for time-dependent tests:

test "expires after 15 minutes" do
  magic_link = MagicLink.create!(user: users(:alice))

  travel 16.minutes

  assert magic_link.expired?
end

VCR for external APIs:

VCR.use_cassette("stripe/charge") do
  charge = Stripe::Charge.create(amount: 1000)
  assert charge.paid
end

Tests ship with features - same commit, not before or after. </testing_philosophy>

<decision_framework>

Decision Framework

Before adding a gem, ask:

  1. Can vanilla Rails do this?

    • ActiveRecord can do most things Sequel can
    • ActionMailer handles email fine
    • ActiveJob works for most job needs
  2. Is the complexity worth it?

    • 150 lines of custom code vs. 10,000-line gem
    • You'll understand your code better
    • Fewer upgrade headaches
  3. Does it add infrastructure?

    • Redis? Consider database-backed alternatives
    • External service? Consider building in-house
    • Simpler infrastructure = fewer failure modes
  4. Is it from someone you trust?

    • 37signals gems: battle-tested at scale
    • Well-maintained, focused gems: usually fine
    • Kitchen-sink gems: probably overkill

The philosophy:

"Build solutions before reaching for gems."

Not anti-gem, but pro-understanding. Use gems when they genuinely solve a problem you have, not a problem you might have. </decision_framework>

<gem_patterns>

Gem Usage Patterns

Pagination:

# geared_pagination - cursor-based
class CardsController < ApplicationController
  def index
    @cards = @board.cards.geared(page: params[:page])
  end
end

Markdown:

# redcarpet + rouge
class MarkdownRenderer
  def self.render(text)
    Redcarpet::Markdown.new(
      Redcarpet::Render::HTML.new(filter_html: true),
      autolink: true,
      fenced_code_blocks: true
    ).render(text)
  end
end

Background jobs:

# solid_queue - no Redis
class ApplicationJob < ActiveJob::Base
  queue_as :default
  # Just works, backed by database
end

Caching:

# solid_cache - no Redis
# config/environments/production.rb
config.cache_store = :solid_cache_store

</gem_patterns>