skills/diegosouzapw/awesome-omni-skill/37signals-rails-style

37signals-rails-style

SKILL.md

37signals/DHH Rails Style Guide

Core Philosophy

  • "Vanilla Rails is plenty." Maximize what Rails gives you, minimize dependencies, resist abstractions until necessary.
  • Rich domain models over service objects
  • CRUD controllers over custom actions
  • Concerns for horizontal code sharing
  • Records as state over boolean columns
  • Database-backed everything (no Redis)
  • Build it yourself before reaching for gems

Dependencies

Use

  • Rails (edge), turbo-rails, stimulus-rails, importmap-rails, propshaft, solid_queue, solid_cache, solid_cable (database-backed, NO Redis), geared_pagination, bcrypt, rqrcode, redcarpet

Avoid

Gem/Pattern Why
devise Auth is ~150 lines of custom code
pundit/cancancan Authorization lives in models
dry-rb, interactor Over-engineered
view_component ERB partials are fine
sidekiq, redis Use Solid Queue (database-backed)
graphql REST with Turbo is sufficient

Routing: Everything is CRUD

Every action maps to a CRUD verb. Create new resources instead of custom actions:

# Avoid: Custom actions
resources :cards do
  post :close
end

# Good: State changes as resources
resources :cards do
  resource :closure      # POST to close, DELETE to reopen
  resource :pin          # POST to pin, DELETE to unpin
  resource :watch        # POST to watch, DELETE to unwatch
end

Use scope module: for namespaced nested resources. Use resolve for custom polymorphic URL generation.


Controllers

Thin Controllers, Rich Models

Controllers orchestrate; business logic lives in models.

def create
  @card.close  # All logic in model
  respond_to do |format|
    format.turbo_stream { render_card_replacement }
    format.json { head :no_content }
  end
end

Controller Concerns

Use concerns for shared behavior:

  • Resource scoping: CardScoped, BoardScoped - load parent resources via before_action
  • Request context: CurrentRequest - populate Current with request data
  • Security: BlockSearchEngineIndexing, RequestForgeryProtection
  • Turbo helpers: TurboFlash - flash messages via Turbo Stream

Authorization

Check permissions in controller, define permission logic in model:

# Controller
before_action :ensure_permission_to_administer_card, only: [:destroy]

# Model
def can_administer_card?(card)
  admin? || card.creator == self
end

Models & Concerns

Heavy Use of Concerns

Each concern is self-contained with associations, scopes, and methods:

class Card < ApplicationRecord
  include Assignable, Closeable, Eventable, Pinnable, Watchable
end

Concern Structure

module Card::Closeable
  extend ActiveSupport::Concern

  included do
    has_one :closure, dependent: :destroy
    scope :closed, -> { joins(:closure) }
    scope :open, -> { where.missing(:closure) }
  end

  def closed? = closure.present?

  def close(user: Current.user)
    create_closure!(user: user) unless closed?
  end
end

Default Values via Lambdas

belongs_to :account, default: -> { board.account }
belongs_to :creator, class_name: "User", default: -> { Current.user }

Current for Request Context

Use ActiveSupport::CurrentAttributes for session, user, identity, account, and request metadata.

POROs (Plain Old Ruby Objects)

Namespace under parent model: Event::Description, Card::Eventable::SystemCommenter

Use for:

  • Presentation logic - formatting for display
  • Complex operations - multi-step processes
  • View context bundling - collecting UI state

POROs are model-adjacent, NOT controller-adjacent (that would be a service object).


State as Records, Not Booleans

Create separate records instead of boolean columns:

# Separate record gives you: timestamp, who did it, easy scoping
class Closure < ApplicationRecord
  belongs_to :card, touch: true
  belongs_to :user, optional: true
end

card.closure.present?     # Is it closed?
card.closure.user         # Who closed it?
card.closure.created_at   # When?

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

Examples: Closure, Pin, Watch, Publication, Goldness


Authentication

Custom passwordless magic link auth (~150 lines). No Devise.

Key components:

  • Authentication concern with require_authentication, resume_session, start_new_session_for
  • Session model (belongs_to identity)
  • MagicLink model with expiration and consumption
  • Bearer token authentication for API access

Views & Turbo/Hotwire

  • Turbo Streams for partial updates (turbo_stream.replace, turbo_stream.before)
  • Morphing for complex updates (method: :morph)
  • Partials over ViewComponents - standard ERB partials with caching
  • Stimulus controllers - single-purpose, small (~50 lines), static values/static classes for config, this.dispatch() for events, this.#privateMethod() for private methods

Background Jobs

  • Shallow jobs, rich models - jobs just call model methods
  • _later and _now convention - mark_as_read_later queues job, mark_as_read_now executes immediately
  • Solid Queue - database-backed, no Redis
  • Recurring jobs via config/recurring.yml

Testing

  • Request specs for controllers (not controller specs)
  • Ship tests with features in the same commit
  • Use change { }, as: :turbo_stream, as: :json

What They Avoid

  • No service objects - use model methods
  • No form objects (usually) - exception: Signup as ActiveModel
  • No decorators/presenters - use view helpers
  • No GraphQL - REST with Turbo

Naming Conventions

Methods

  • Verbs for actions: close, reopen, publish
  • Predicates for state: closed?, published?

Concerns

Adjectives describing capability: Closeable, Publishable, Watchable, Searchable

Controllers

Nouns matching the resource: Cards::ClosuresController, Boards::PublicationsController

Scopes

  • Ordering: chronologically, reverse_chronologically, alphabetically, latest
  • Preloading: preloaded as standard name for eager loading
  • Parameterized: indexed_by(index), sorted_by(sort)

Caching

HTTP Caching

  • fresh_when etag: [...] for conditional GET
  • Global etag { "v1" } in ApplicationController (bump to bust caches)
  • Concern-level ETags for timezone, authentication

Fragment Caching

  • cache card do in views
  • cached: true for collection rendering
  • touch: true on associations for cache invalidation

Database Patterns

  • UUIDs for primary keys
  • Every model has account_id for multi-tenancy
  • URL-based multi-tenancy: /{account_id}/boards/...
  • No foreign key constraints - removed for flexibility

CSS Architecture

  • Vanilla CSS no Sass, PostCSS, or Tailwind.
  • CSS Cascade Layers @layer reset, base, components, modules, utilities
  • OKLCH color system with CSS variables
  • Modern features @starting-style, color-mix(), :has(), native nesting, container queries

API Design

  • Same controllers, different format via respond_to
  • Response codes: Create → 201 Created + Location, Update/Delete → 204 No Content
  • Bearer token authentication

Callbacks

Use sparingly:

  • after_commit :relay_later, on: :create for async work
  • before_save :set_defaults for derived data
  • Avoid complex chains, avoid synchronous external calls

Summary

  1. Start with vanilla Rails - Don't add abstractions until you feel the pain
  2. Models are rich - Business logic lives in models, not services
  3. Controllers are thin - Just orchestration and response formatting
  4. Everything is CRUD - New resource over new action
  5. State is records - Not boolean columns
  6. Concerns are compositions - Horizontal behavior sharing
  7. Build before buying - Auth, search, jobs - all custom
  8. Database is king - No Redis, no Elasticsearch
  9. Test with fixtures - Deterministic, fast, simple
  10. Ship incrementally - Many small commits
  11. Tests ship with features - Not TDD, not afterthought, but together
  12. Refactor toward consistency - Establish patterns, then update old code
  13. CSS uses the platform - Native layers, nesting, OKLCH - no preprocessors
  14. Design tokens everywhere - CSS variables for colors, spacing, typography

The best code is the code you don't write. The second best is the code that's obviously correct.

Weekly Installs
1
GitHub Stars
4
First Seen
10 days ago
Installed on
amp1
cline1
opencode1
cursor1
kimi-cli1
codex1