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

7.3 KiB

Models - DHH Rails Style

<model_concerns>

Concerns for Horizontal Behavior

Models heavily use concerns. A typical Card model includes 14+ concerns:

class Card < ApplicationRecord
  include Assignable
  include Attachments
  include Broadcastable
  include Closeable
  include Colored
  include Eventable
  include Golden
  include Mentions
  include Multistep
  include Pinnable
  include Postponable
  include Readable
  include Searchable
  include Taggable
  include Watchable
end

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

Naming: Adjectives describing capability (Closeable, Publishable, Watchable) </model_concerns>

<state_records>

State as Records, Not Booleans

Instead of boolean columns, create separate records:

# Instead of:
closed: boolean
is_golden: boolean
postponed: boolean

# Create records:
class Card::Closure < ApplicationRecord
  belongs_to :card
  belongs_to :creator, class_name: "User"
end

class Card::Goldness < ApplicationRecord
  belongs_to :card
  belongs_to :creator, class_name: "User"
end

class Card::NotNow < ApplicationRecord
  belongs_to :card
  belongs_to :creator, class_name: "User"
end

Benefits:

  • Automatic timestamps (when it happened)
  • Track who made changes
  • Easy filtering via joins and where.missing
  • Enables rich UI showing when/who

In the model:

module Closeable
  extend ActiveSupport::Concern

  included do
    has_one :closure, dependent: :destroy
  end

  def closed?
    closure.present?
  end

  def close(creator: Current.user)
    create_closure!(creator: creator)
  end

  def reopen
    closure&.destroy
  end
end

Querying:

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

</state_records>

## Callbacks - Used Sparingly

Only 38 callback occurrences across 30 files in Fizzy. Guidelines:

Use for:

  • after_commit for async work
  • before_save for derived data
  • after_create_commit for side effects

Avoid:

  • Complex callback chains
  • Business logic in callbacks
  • Synchronous external calls
class Card < ApplicationRecord
  after_create_commit :notify_watchers_later
  before_save :update_search_index, if: :title_changed?

  private
    def notify_watchers_later
      NotifyWatchersJob.perform_later(self)
    end
end
## Scope Naming

Standard scope names:

class Card < ApplicationRecord
  scope :chronologically, -> { order(created_at: :asc) }
  scope :reverse_chronologically, -> { order(created_at: :desc) }
  scope :alphabetically, -> { order(title: :asc) }
  scope :latest, -> { reverse_chronologically.limit(10) }

  # Standard eager loading
  scope :preloaded, -> { includes(:creator, :assignees, :tags) }

  # Parameterized
  scope :indexed_by, ->(column) { order(column => :asc) }
  scope :sorted_by, ->(column, direction = :asc) { order(column => direction) }
end
## Plain Old Ruby Objects

POROs namespaced under parent models:

# app/models/event/description.rb
class Event::Description
  def initialize(event)
    @event = event
  end

  def to_s
    # Presentation logic for event description
  end
end

# app/models/card/eventable/system_commenter.rb
class Card::Eventable::SystemCommenter
  def initialize(card)
    @card = card
  end

  def comment(message)
    # Business logic
  end
end

# app/models/user/filtering.rb
class User::Filtering
  # View context bundling
end

NOT used for service objects. Business logic stays in models.

<verbs_predicates>

Method Naming

Verbs - Actions that change state:

card.close
card.reopen
card.gild      # make golden
card.ungild
board.publish
board.archive

Predicates - Queries derived from state:

card.closed?    # closure.present?
card.golden?    # goldness.present?
board.published?

Avoid generic setters:

# Bad
card.set_closed(true)
card.update_golden_status(false)

# Good
card.close
card.ungild

</verbs_predicates>

<validation_philosophy>

Validation Philosophy

Minimal validations on models. Use contextual validations on form/operation objects:

# Model - minimal
class User < ApplicationRecord
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end

# Form object - contextual
class Signup
  include ActiveModel::Model

  attr_accessor :email, :name, :terms_accepted

  validates :email, :name, presence: true
  validates :terms_accepted, acceptance: true

  def save
    return false unless valid?
    User.create!(email: email, name: name)
  end
end

Prefer database constraints over model validations for data integrity:

# migration
add_index :users, :email, unique: true
add_foreign_key :cards, :boards

</validation_philosophy>

<error_handling>

Let It Crash Philosophy

Use bang methods that raise exceptions on failure:

# Preferred - raises on failure
@card = Card.create!(card_params)
@card.update!(title: new_title)
@comment.destroy!

# Avoid - silent failures
@card = Card.create(card_params)  # returns false on failure
if @card.save
  # ...
end

Let errors propagate naturally. Rails handles ActiveRecord::RecordInvalid with 422 responses. </error_handling>

<default_values>

Default Values with Lambdas

Use lambda defaults for associations with Current:

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

class Comment < ApplicationRecord
  belongs_to :commenter, class_name: "User", default: -> { Current.user }
end

Lambdas ensure dynamic resolution at creation time. </default_values>

<rails_71_patterns>

Rails 7.1+ Model Patterns

Normalizes - clean data before validation:

class User < ApplicationRecord
  normalizes :email, with: ->(email) { email.strip.downcase }
  normalizes :phone, with: ->(phone) { phone.gsub(/\D/, "") }
end

Delegated Types - replace polymorphic associations:

class Message < ApplicationRecord
  delegated_type :messageable, types: %w[Comment Reply Announcement]
end

# Now you get:
message.comment?        # true if Comment
message.comment         # returns the Comment
Message.comments        # scope for Comment messages

Store Accessor - structured JSON storage:

class User < ApplicationRecord
  store :settings, accessors: [:theme, :notifications_enabled], coder: JSON
end

user.theme = "dark"
user.notifications_enabled = true

</rails_71_patterns>

<concern_guidelines>

Concern Guidelines

  • 50-150 lines per concern (most are ~100)
  • Cohesive - related functionality only
  • Named for capabilities - Closeable, Watchable, not CardHelpers
  • Self-contained - associations, scopes, methods together
  • Not for mere organization - create when genuine reuse needed

Touch chains for cache invalidation:

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

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

When comment updates, card's updated_at changes, which cascades to board.

Transaction wrapping for related updates:

class Card < ApplicationRecord
  def close(creator: Current.user)
    transaction do
      create_closure!(creator: creator)
      record_event(:closed)
      notify_watchers_later
    end
  end
end

</concern_guidelines>