[2.16.0] Consolidate DHH styles and add /feature-video command
- Merge dhh-ruby-style into dhh-rails-style for comprehensive Rails conventions - Add testing.md reference covering Rails testing patterns - Add /feature-video command for recording PR demo videos - Update docs and component counts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ description: This skill should be used when writing Ruby and Rails code in DHH's
|
||||
---
|
||||
|
||||
<objective>
|
||||
Apply 37signals/DHH Rails conventions to Ruby and Rails code. This skill provides domain expertise extracted from analyzing production 37signals codebases (Fizzy/Campfire).
|
||||
Apply 37signals/DHH Rails conventions to Ruby and Rails code. This skill provides comprehensive domain expertise extracted from analyzing production 37signals codebases (Fizzy/Campfire) and DHH's code review patterns.
|
||||
</objective>
|
||||
|
||||
<essential_principles>
|
||||
@@ -27,17 +27,28 @@ Apply 37signals/DHH Rails conventions to Ruby and Rails code. This skill provide
|
||||
- redis (database for everything)
|
||||
- view_component (partials work fine)
|
||||
- GraphQL (REST with Turbo sufficient)
|
||||
- factory_bot (fixtures are simpler)
|
||||
- rspec (Minitest ships with Rails)
|
||||
- Tailwind (native CSS with layers)
|
||||
|
||||
**Development Philosophy:**
|
||||
- Ship, Validate, Refine - prototype-quality code to production to learn
|
||||
- Fix root causes, not symptoms
|
||||
- Write-time operations over read-time computations
|
||||
- Database constraints over ActiveRecord validations
|
||||
</essential_principles>
|
||||
|
||||
<intake>
|
||||
What are you working on?
|
||||
|
||||
1. **Controllers** - REST mapping, concerns, Turbo responses
|
||||
2. **Models** - Concerns, state records, callbacks, scopes
|
||||
1. **Controllers** - REST mapping, concerns, Turbo responses, API patterns
|
||||
2. **Models** - Concerns, state records, callbacks, scopes, POROs
|
||||
3. **Views & Frontend** - Turbo, Stimulus, CSS, partials
|
||||
4. **Architecture** - Routing, multi-tenancy, authentication, jobs
|
||||
5. **Code Review** - Review code against DHH style
|
||||
6. **General Guidance** - Philosophy and conventions
|
||||
4. **Architecture** - Routing, multi-tenancy, authentication, jobs, caching
|
||||
5. **Testing** - Minitest, fixtures, integration tests
|
||||
6. **Gems & Dependencies** - What to use vs avoid
|
||||
7. **Code Review** - Review code against DHH style
|
||||
8. **General Guidance** - Philosophy and conventions
|
||||
|
||||
**Specify a number or describe your task.**
|
||||
</intake>
|
||||
@@ -48,9 +59,11 @@ What are you working on?
|
||||
| 1, "controller" | [controllers.md](./references/controllers.md) |
|
||||
| 2, "model" | [models.md](./references/models.md) |
|
||||
| 3, "view", "frontend", "turbo", "stimulus", "css" | [frontend.md](./references/frontend.md) |
|
||||
| 4, "architecture", "routing", "auth", "job" | [architecture.md](./references/architecture.md) |
|
||||
| 5, "review" | Read all references, then review code |
|
||||
| 6, general task | Read relevant references based on context |
|
||||
| 4, "architecture", "routing", "auth", "job", "cache" | [architecture.md](./references/architecture.md) |
|
||||
| 5, "test", "testing", "minitest", "fixture" | [testing.md](./references/testing.md) |
|
||||
| 6, "gem", "dependency", "library" | [gems.md](./references/gems.md) |
|
||||
| 7, "review" | Read all references, then review code |
|
||||
| 8, general task | Read relevant references based on context |
|
||||
|
||||
**After reading relevant references, apply patterns to the user's code.**
|
||||
</routing>
|
||||
@@ -70,6 +83,7 @@ What are you working on?
|
||||
- `chronologically`, `reverse_chronologically`, `alphabetically`, `latest`
|
||||
- `preloaded` (standard eager loading name)
|
||||
- `indexed_by`, `sorted_by` (parameterized)
|
||||
- `active`, `unassigned` (business terms, not SQL-ish)
|
||||
|
||||
## REST Mapping
|
||||
|
||||
@@ -80,6 +94,55 @@ POST /cards/:id/close → POST /cards/:id/closure
|
||||
DELETE /cards/:id/close → DELETE /cards/:id/closure
|
||||
POST /cards/:id/archive → POST /cards/:id/archival
|
||||
```
|
||||
|
||||
## Ruby Syntax Preferences
|
||||
|
||||
```ruby
|
||||
# Symbol arrays with spaces inside brackets
|
||||
before_action :set_message, only: %i[ show edit update destroy ]
|
||||
|
||||
# Private method indentation
|
||||
private
|
||||
def set_message
|
||||
@message = Message.find(params[:id])
|
||||
end
|
||||
|
||||
# Expression-less case for conditionals
|
||||
case
|
||||
when params[:before].present?
|
||||
messages.page_before(params[:before])
|
||||
else
|
||||
messages.last_page
|
||||
end
|
||||
|
||||
# Bang methods for fail-fast
|
||||
@message = Message.create!(params)
|
||||
|
||||
# Ternaries for simple conditionals
|
||||
@room.direct? ? @room.users : @message.mentionees
|
||||
```
|
||||
|
||||
## Key Patterns
|
||||
|
||||
**State as Records:**
|
||||
```ruby
|
||||
Card.joins(:closure) # closed cards
|
||||
Card.where.missing(:closure) # open cards
|
||||
```
|
||||
|
||||
**Current Attributes:**
|
||||
```ruby
|
||||
belongs_to :creator, default: -> { Current.user }
|
||||
```
|
||||
|
||||
**Authorization on Models:**
|
||||
```ruby
|
||||
class User < ApplicationRecord
|
||||
def can_administer?(message)
|
||||
message.creator == self || admin?
|
||||
end
|
||||
end
|
||||
```
|
||||
</quick_reference>
|
||||
|
||||
<reference_index>
|
||||
@@ -89,11 +152,12 @@ All detailed patterns in `references/`:
|
||||
|
||||
| File | Topics |
|
||||
|------|--------|
|
||||
| [controllers.md](./references/controllers.md) | REST mapping, concerns, Turbo responses, API patterns |
|
||||
| [models.md](./references/models.md) | Concerns, state records, callbacks, scopes, POROs |
|
||||
| [frontend.md](./references/frontend.md) | Turbo, Stimulus, CSS architecture, view patterns |
|
||||
| [architecture.md](./references/architecture.md) | Routing, auth, jobs, caching, multi-tenancy, config |
|
||||
| [gems.md](./references/gems.md) | What they use vs avoid, and why |
|
||||
| [controllers.md](./references/controllers.md) | REST mapping, concerns, Turbo responses, API patterns, HTTP caching |
|
||||
| [models.md](./references/models.md) | Concerns, state records, callbacks, scopes, POROs, authorization, broadcasting |
|
||||
| [frontend.md](./references/frontend.md) | Turbo Streams, Stimulus controllers, CSS layers, OKLCH colors, partials |
|
||||
| [architecture.md](./references/architecture.md) | Routing, authentication, jobs, Current attributes, caching, database patterns |
|
||||
| [testing.md](./references/testing.md) | Minitest, fixtures, unit/integration/system tests, testing patterns |
|
||||
| [gems.md](./references/gems.md) | What they use vs avoid, decision framework, Gemfile examples |
|
||||
</reference_index>
|
||||
|
||||
<success_criteria>
|
||||
@@ -105,8 +169,16 @@ Code follows DHH style when:
|
||||
- Database-backed solutions preferred over external services
|
||||
- Tests use Minitest with fixtures
|
||||
- Turbo/Stimulus for interactivity (no heavy JS frameworks)
|
||||
- Native CSS with modern features (layers, OKLCH, nesting)
|
||||
- Authorization logic lives on User model
|
||||
- Jobs are shallow wrappers calling model methods
|
||||
</success_criteria>
|
||||
|
||||
<credits>
|
||||
Based on [The Unofficial 37signals/DHH Rails Style Guide](https://gist.github.com/marckohlbrugge/d363fb90c89f71bd0c816d24d7642aca) by [Marc Köhlbrugge](https://x.com/marckohlbrugge), generated through deep analysis of the Fizzy codebase.
|
||||
Based on [The Unofficial 37signals/DHH Rails Style Guide](https://github.com/marckohlbrugge/unofficial-37signals-coding-style-guide) by [Marc Köhlbrugge](https://x.com/marckohlbrugge), generated through deep analysis of 265 pull requests from the Fizzy codebase.
|
||||
|
||||
**Important Disclaimers:**
|
||||
- LLM-generated guide - may contain inaccuracies
|
||||
- Code examples from Fizzy are licensed under the O'Saasy License
|
||||
- Not affiliated with or endorsed by 37signals
|
||||
</credits>
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
# Testing - DHH Rails Style
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
"Minitest with fixtures - simple, fast, deterministic." The approach prioritizes pragmatism over convention.
|
||||
|
||||
## Why Minitest Over RSpec
|
||||
|
||||
- **Simpler**: Less DSL magic, plain Ruby assertions
|
||||
- **Ships with Rails**: No additional dependencies
|
||||
- **Faster boot times**: Less overhead
|
||||
- **Plain Ruby**: No specialized syntax to learn
|
||||
|
||||
## Fixtures as Test Data
|
||||
|
||||
Rather than factories, fixtures provide preloaded data:
|
||||
- Loaded once, reused across tests
|
||||
- No runtime object creation overhead
|
||||
- Explicit relationship visibility
|
||||
- Deterministic IDs for easier debugging
|
||||
|
||||
### Fixture Structure
|
||||
```yaml
|
||||
# test/fixtures/users.yml
|
||||
david:
|
||||
identity: david
|
||||
account: basecamp
|
||||
role: admin
|
||||
|
||||
jason:
|
||||
identity: jason
|
||||
account: basecamp
|
||||
role: member
|
||||
|
||||
# test/fixtures/rooms.yml
|
||||
watercooler:
|
||||
name: Water Cooler
|
||||
creator: david
|
||||
direct: false
|
||||
|
||||
# test/fixtures/messages.yml
|
||||
greeting:
|
||||
body: Hello everyone!
|
||||
room: watercooler
|
||||
creator: david
|
||||
```
|
||||
|
||||
### Using Fixtures in Tests
|
||||
```ruby
|
||||
test "sending a message" do
|
||||
user = users(:david)
|
||||
room = rooms(:watercooler)
|
||||
|
||||
# Test with fixture data
|
||||
end
|
||||
```
|
||||
|
||||
### Dynamic Fixture Values
|
||||
ERB enables time-sensitive data:
|
||||
```yaml
|
||||
recent_card:
|
||||
title: Recent Card
|
||||
created_at: <%= 1.hour.ago %>
|
||||
|
||||
old_card:
|
||||
title: Old Card
|
||||
created_at: <%= 1.month.ago %>
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Unit Tests
|
||||
Verify business logic using setup blocks and standard assertions:
|
||||
|
||||
```ruby
|
||||
class CardTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@card = cards(:one)
|
||||
@user = users(:david)
|
||||
end
|
||||
|
||||
test "closing a card creates a closure" do
|
||||
assert_difference -> { Card::Closure.count } do
|
||||
@card.close(creator: @user)
|
||||
end
|
||||
|
||||
assert @card.closed?
|
||||
assert_equal @user, @card.closure.creator
|
||||
end
|
||||
|
||||
test "reopening a card destroys the closure" do
|
||||
@card.close(creator: @user)
|
||||
|
||||
assert_difference -> { Card::Closure.count }, -1 do
|
||||
@card.reopen
|
||||
end
|
||||
|
||||
refute @card.closed?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
Test full request/response cycles:
|
||||
|
||||
```ruby
|
||||
class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:david)
|
||||
sign_in @user
|
||||
end
|
||||
|
||||
test "closing a card" do
|
||||
card = cards(:one)
|
||||
|
||||
post card_closure_path(card)
|
||||
|
||||
assert_response :success
|
||||
assert card.reload.closed?
|
||||
end
|
||||
|
||||
test "unauthorized user cannot close card" do
|
||||
sign_in users(:guest)
|
||||
card = cards(:one)
|
||||
|
||||
post card_closure_path(card)
|
||||
|
||||
assert_response :forbidden
|
||||
refute card.reload.closed?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### System Tests
|
||||
Browser-based tests using Capybara:
|
||||
|
||||
```ruby
|
||||
class MessagesTest < ApplicationSystemTestCase
|
||||
test "sending a message" do
|
||||
sign_in users(:david)
|
||||
visit room_path(rooms(:watercooler))
|
||||
|
||||
fill_in "Message", with: "Hello, world!"
|
||||
click_button "Send"
|
||||
|
||||
assert_text "Hello, world!"
|
||||
end
|
||||
|
||||
test "editing own message" do
|
||||
sign_in users(:david)
|
||||
visit room_path(rooms(:watercooler))
|
||||
|
||||
within "#message_#{messages(:greeting).id}" do
|
||||
click_on "Edit"
|
||||
end
|
||||
|
||||
fill_in "Message", with: "Updated message"
|
||||
click_button "Save"
|
||||
|
||||
assert_text "Updated message"
|
||||
end
|
||||
|
||||
test "drag and drop card to new column" do
|
||||
sign_in users(:david)
|
||||
visit board_path(boards(:main))
|
||||
|
||||
card = find("#card_#{cards(:one).id}")
|
||||
target = find("#column_#{columns(:done).id}")
|
||||
|
||||
card.drag_to target
|
||||
|
||||
assert_selector "#column_#{columns(:done).id} #card_#{cards(:one).id}"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
### Time Testing
|
||||
Use `travel_to` for deterministic time-dependent assertions:
|
||||
|
||||
```ruby
|
||||
test "card expires after 30 days" do
|
||||
card = cards(:one)
|
||||
|
||||
travel_to 31.days.from_now do
|
||||
assert card.expired?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### External API Testing with VCR
|
||||
Record and replay HTTP interactions:
|
||||
|
||||
```ruby
|
||||
test "fetches user data from API" do
|
||||
VCR.use_cassette("user_api") do
|
||||
user_data = ExternalApi.fetch_user(123)
|
||||
|
||||
assert_equal "John", user_data[:name]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Background Job Testing
|
||||
Assert job enqueueing and email delivery:
|
||||
|
||||
```ruby
|
||||
test "closing card enqueues notification job" do
|
||||
card = cards(:one)
|
||||
|
||||
assert_enqueued_with(job: NotifyWatchersJob, args: [card]) do
|
||||
card.close
|
||||
end
|
||||
end
|
||||
|
||||
test "welcome email is sent on signup" do
|
||||
assert_emails 1 do
|
||||
Identity.create!(email: "new@example.com")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Testing Turbo Streams
|
||||
```ruby
|
||||
test "message creation broadcasts to room" do
|
||||
room = rooms(:watercooler)
|
||||
|
||||
assert_turbo_stream_broadcasts [room, :messages] do
|
||||
room.messages.create!(body: "Test", creator: users(:david))
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Testing Principles
|
||||
|
||||
### 1. Test Observable Behavior
|
||||
Focus on what the code does, not how it does it:
|
||||
|
||||
```ruby
|
||||
# ❌ Testing implementation
|
||||
test "calls notify method on each watcher" do
|
||||
card.expects(:notify).times(3)
|
||||
card.close
|
||||
end
|
||||
|
||||
# ✅ Testing behavior
|
||||
test "watchers receive notifications when card closes" do
|
||||
assert_difference -> { Notification.count }, 3 do
|
||||
card.close
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 2. Don't Mock Everything
|
||||
|
||||
```ruby
|
||||
# ❌ Over-mocked test
|
||||
test "sending message" do
|
||||
room = mock("room")
|
||||
user = mock("user")
|
||||
message = mock("message")
|
||||
|
||||
room.expects(:messages).returns(stub(create!: message))
|
||||
message.expects(:broadcast_create)
|
||||
|
||||
MessagesController.new.create
|
||||
end
|
||||
|
||||
# ✅ Test the real thing
|
||||
test "sending message" do
|
||||
sign_in users(:david)
|
||||
post room_messages_url(rooms(:watercooler)),
|
||||
params: { message: { body: "Hello" } }
|
||||
|
||||
assert_response :success
|
||||
assert Message.exists?(body: "Hello")
|
||||
end
|
||||
```
|
||||
|
||||
### 3. Tests Ship with Features
|
||||
Same commit, not TDD-first but together. Neither before (strict TDD) nor after (deferred testing).
|
||||
|
||||
### 4. Security Fixes Always Include Regression Tests
|
||||
Every security fix must include a test that would have caught the vulnerability.
|
||||
|
||||
### 5. Integration Tests Validate Complete Workflows
|
||||
Don't just test individual pieces - test that they work together.
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
test/
|
||||
├── controllers/ # Integration tests for controllers
|
||||
├── fixtures/ # YAML fixtures for all models
|
||||
├── helpers/ # Helper method tests
|
||||
├── integration/ # API integration tests
|
||||
├── jobs/ # Background job tests
|
||||
├── mailers/ # Mailer tests
|
||||
├── models/ # Unit tests for models
|
||||
├── system/ # Browser-based system tests
|
||||
└── test_helper.rb # Test configuration
|
||||
```
|
||||
|
||||
## Test Helper Setup
|
||||
|
||||
```ruby
|
||||
# test/test_helper.rb
|
||||
ENV["RAILS_ENV"] ||= "test"
|
||||
require_relative "../config/environment"
|
||||
require "rails/test_help"
|
||||
|
||||
class ActiveSupport::TestCase
|
||||
fixtures :all
|
||||
|
||||
parallelize(workers: :number_of_processors)
|
||||
end
|
||||
|
||||
class ActionDispatch::IntegrationTest
|
||||
include SignInHelper
|
||||
end
|
||||
|
||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||
driven_by :selenium, using: :headless_chrome
|
||||
end
|
||||
```
|
||||
|
||||
## Sign In Helper
|
||||
|
||||
```ruby
|
||||
# test/support/sign_in_helper.rb
|
||||
module SignInHelper
|
||||
def sign_in(user)
|
||||
session = user.identity.sessions.create!
|
||||
cookies.signed[:session_id] = session.id
|
||||
end
|
||||
end
|
||||
```
|
||||
@@ -1,201 +0,0 @@
|
||||
---
|
||||
name: dhh-ruby-style
|
||||
description: This skill should be used when writing Ruby and Rails code in DHH's distinctive 37signals style. It applies when writing Ruby code, Rails applications, creating models, controllers, or any Ruby file. Triggers on Ruby/Rails code generation, refactoring requests, code review, or when the user mentions DHH, 37signals, Basecamp, HEY, or Campfire style. Embodies REST purity, fat models, thin controllers, Current attributes, Hotwire patterns, and the "clarity over cleverness" philosophy.
|
||||
---
|
||||
|
||||
# DHH Ruby/Rails Style Guide
|
||||
|
||||
Write Ruby and Rails code following DHH's philosophy: **clarity over cleverness**, **convention over configuration**, **developer happiness** above all.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Controller Actions
|
||||
- **Only 7 REST actions**: `index`, `show`, `new`, `create`, `edit`, `update`, `destroy`
|
||||
- **New behavior?** Create a new controller, not a custom action
|
||||
- **Action length**: 1-5 lines maximum
|
||||
- **Empty actions are fine**: Let Rails convention handle rendering
|
||||
|
||||
```ruby
|
||||
class MessagesController < ApplicationController
|
||||
before_action :set_message, only: %i[ show edit update destroy ]
|
||||
|
||||
def index
|
||||
@messages = @room.messages.with_creator.last_page
|
||||
fresh_when @messages
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def create
|
||||
@message = @room.messages.create_with_attachment!(message_params)
|
||||
@message.broadcast_create
|
||||
end
|
||||
|
||||
private
|
||||
def set_message
|
||||
@message = @room.messages.find(params[:id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:body, :attachment)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Private Method Indentation
|
||||
Indent private methods one level under `private` keyword:
|
||||
|
||||
```ruby
|
||||
private
|
||||
def set_message
|
||||
@message = Message.find(params[:id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:body)
|
||||
end
|
||||
```
|
||||
|
||||
### Model Design (Fat Models)
|
||||
Models own business logic, authorization, and broadcasting:
|
||||
|
||||
```ruby
|
||||
class Message < ApplicationRecord
|
||||
belongs_to :room
|
||||
belongs_to :creator, class_name: "User"
|
||||
has_many :mentions
|
||||
|
||||
scope :with_creator, -> { includes(:creator) }
|
||||
scope :page_before, ->(cursor) { where("id < ?", cursor.id).order(id: :desc).limit(50) }
|
||||
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages, target: "messages"
|
||||
end
|
||||
|
||||
def mentionees
|
||||
mentions.includes(:user).map(&:user)
|
||||
end
|
||||
end
|
||||
|
||||
class User < ApplicationRecord
|
||||
def can_administer?(message)
|
||||
message.creator == self || admin?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Current Attributes
|
||||
Use `Current` for request context, never pass `current_user` everywhere:
|
||||
|
||||
```ruby
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user, :session
|
||||
end
|
||||
|
||||
# Usage anywhere in app
|
||||
Current.user.can_administer?(@message)
|
||||
```
|
||||
|
||||
### Ruby Syntax Preferences
|
||||
|
||||
```ruby
|
||||
# Symbol arrays with spaces inside brackets
|
||||
before_action :set_message, only: %i[ show edit update destroy ]
|
||||
|
||||
# Modern hash syntax exclusively
|
||||
params.require(:message).permit(:body, :attachment)
|
||||
|
||||
# Single-line blocks with braces
|
||||
users.each { |user| user.notify }
|
||||
|
||||
# Ternaries for simple conditionals
|
||||
@room.direct? ? @room.users : @message.mentionees
|
||||
|
||||
# Bang methods for fail-fast
|
||||
@message = Message.create!(params)
|
||||
@message.update!(message_params)
|
||||
|
||||
# Predicate methods with question marks
|
||||
@room.direct?
|
||||
user.can_administer?(@message)
|
||||
@messages.any?
|
||||
|
||||
# Expression-less case for cleaner conditionals
|
||||
case
|
||||
when params[:before].present?
|
||||
@room.messages.page_before(params[:before])
|
||||
when params[:after].present?
|
||||
@room.messages.page_after(params[:after])
|
||||
else
|
||||
@room.messages.last_page
|
||||
end
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
|---------|------------|---------|
|
||||
| Setter methods | `set_` prefix | `set_message`, `set_room` |
|
||||
| Parameter methods | `{model}_params` | `message_params` |
|
||||
| Association names | Semantic, not generic | `creator` not `user` |
|
||||
| Scopes | Chainable, descriptive | `with_creator`, `page_before` |
|
||||
| Predicates | End with `?` | `direct?`, `can_administer?` |
|
||||
|
||||
### Hotwire/Turbo Patterns
|
||||
Broadcasting is model responsibility:
|
||||
|
||||
```ruby
|
||||
# In model
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages, target: "messages"
|
||||
end
|
||||
|
||||
# In controller
|
||||
@message.broadcast_replace_to @room, :messages,
|
||||
target: [ @message, :presentation ],
|
||||
partial: "messages/presentation",
|
||||
attributes: { maintain_scroll: true }
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Rescue specific exceptions, fail fast with bang methods:
|
||||
|
||||
```ruby
|
||||
def create
|
||||
@message = @room.messages.create_with_attachment!(message_params)
|
||||
@message.broadcast_create
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render action: :room_not_found
|
||||
end
|
||||
```
|
||||
|
||||
### Architecture Preferences
|
||||
|
||||
| Traditional | DHH Way |
|
||||
|-------------|---------|
|
||||
| PostgreSQL | SQLite (for single-tenant) |
|
||||
| Redis + Sidekiq | Solid Queue |
|
||||
| Redis cache | Solid Cache |
|
||||
| Kubernetes | Single Docker container |
|
||||
| Service objects | Fat models |
|
||||
| Policy objects (Pundit) | Authorization on User model |
|
||||
| FactoryBot | Fixtures |
|
||||
|
||||
## Detailed References
|
||||
|
||||
For comprehensive patterns and examples, see:
|
||||
- [patterns.md](./references/patterns.md) - Complete code patterns with explanations
|
||||
- [resources.md](./references/resources.md) - Links to source material and further reading
|
||||
|
||||
## Philosophy Summary
|
||||
|
||||
1. **REST purity**: 7 actions only; new controllers for variations
|
||||
2. **Fat models**: Authorization, broadcasting, business logic in models
|
||||
3. **Thin controllers**: 1-5 line actions; extract complexity
|
||||
4. **Convention over configuration**: Empty methods, implicit rendering
|
||||
5. **Minimal abstractions**: No service objects for simple cases
|
||||
6. **Current attributes**: Thread-local request context everywhere
|
||||
7. **Hotwire-first**: Model-level broadcasting, Turbo Streams, Stimulus
|
||||
8. **Readable code**: Semantic naming, small methods, no comments needed
|
||||
9. **Pragmatic testing**: System tests over unit tests, real integrations
|
||||
@@ -1,830 +0,0 @@
|
||||
# DHH Ruby/Rails Patterns Reference
|
||||
|
||||
Comprehensive code patterns extracted from 37signals' Campfire codebase and DHH's public teachings.
|
||||
|
||||
## Controller Patterns
|
||||
|
||||
### REST-Pure Controller Design
|
||||
|
||||
DHH's controller philosophy is "fundamentalistic" about REST. Every controller maps to a resource with only the 7 standard actions.
|
||||
|
||||
```ruby
|
||||
# ✅ CORRECT: Standard REST actions only
|
||||
class MessagesController < ApplicationController
|
||||
def index; end
|
||||
def show; end
|
||||
def new; end
|
||||
def create; end
|
||||
def edit; end
|
||||
def update; end
|
||||
def destroy; end
|
||||
end
|
||||
|
||||
# ❌ WRONG: Custom actions
|
||||
class MessagesController < ApplicationController
|
||||
def archive # NO
|
||||
def unarchive # NO
|
||||
def search # NO
|
||||
def drafts # NO
|
||||
end
|
||||
|
||||
# ✅ CORRECT: New controllers for custom behavior
|
||||
class Messages::ArchivesController < ApplicationController
|
||||
def create # archives a message
|
||||
def destroy # unarchives a message
|
||||
end
|
||||
|
||||
class Messages::DraftsController < ApplicationController
|
||||
def index # lists drafts
|
||||
end
|
||||
|
||||
class Messages::SearchesController < ApplicationController
|
||||
def show # shows search results
|
||||
end
|
||||
```
|
||||
|
||||
### Controller Concerns for Shared Behavior
|
||||
|
||||
```ruby
|
||||
# app/controllers/concerns/room_scoped.rb
|
||||
module RoomScoped
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_room
|
||||
end
|
||||
|
||||
private
|
||||
def set_room
|
||||
@room = Current.user.rooms.find(params[:room_id])
|
||||
end
|
||||
end
|
||||
|
||||
# Usage
|
||||
class MessagesController < ApplicationController
|
||||
include RoomScoped
|
||||
end
|
||||
```
|
||||
|
||||
### Complete Controller Example
|
||||
|
||||
```ruby
|
||||
class MessagesController < ApplicationController
|
||||
include ActiveStorage::SetCurrent, RoomScoped
|
||||
|
||||
before_action :set_room, except: :create
|
||||
before_action :set_message, only: %i[ show edit update destroy ]
|
||||
before_action :ensure_can_administer, only: %i[ edit update destroy ]
|
||||
|
||||
layout false, only: :index
|
||||
|
||||
def index
|
||||
@messages = find_paged_messages
|
||||
if @messages.any?
|
||||
fresh_when @messages
|
||||
else
|
||||
head :no_content
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
set_room
|
||||
@message = @room.messages.create_with_attachment!(message_params)
|
||||
@message.broadcast_create
|
||||
deliver_webhooks_to_bots
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render action: :room_not_found
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@message.update!(message_params)
|
||||
@message.broadcast_replace_to @room, :messages,
|
||||
target: [ @message, :presentation ],
|
||||
partial: "messages/presentation",
|
||||
attributes: { maintain_scroll: true }
|
||||
redirect_to room_message_url(@room, @message)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@message.destroy
|
||||
@message.broadcast_remove_to @room, :messages
|
||||
end
|
||||
|
||||
private
|
||||
def set_message
|
||||
@message = @room.messages.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_can_administer
|
||||
head :forbidden unless Current.user.can_administer?(@message)
|
||||
end
|
||||
|
||||
def find_paged_messages
|
||||
case
|
||||
when params[:before].present?
|
||||
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
|
||||
when params[:after].present?
|
||||
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
||||
else
|
||||
@room.messages.with_creator.last_page
|
||||
end
|
||||
end
|
||||
|
||||
def message_params
|
||||
params.require(:message).permit(:body, :attachment, :client_message_id)
|
||||
end
|
||||
|
||||
def deliver_webhooks_to_bots
|
||||
bots_eligible_for_webhook.excluding(@message.creator).each { |bot| bot.deliver_webhook_later(@message) }
|
||||
end
|
||||
|
||||
def bots_eligible_for_webhook
|
||||
@room.direct? ? @room.users.active_bots : @message.mentionees.active_bots
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Model Patterns
|
||||
|
||||
### Semantic Association Naming
|
||||
|
||||
```ruby
|
||||
class Message < ApplicationRecord
|
||||
# ✅ Semantic names that express domain concepts
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :room
|
||||
has_many :mentions
|
||||
has_many :mentionees, through: :mentions, source: :user
|
||||
|
||||
# ❌ Generic names
|
||||
belongs_to :user # Too generic - creator is clearer
|
||||
end
|
||||
|
||||
class Room < ApplicationRecord
|
||||
has_many :memberships
|
||||
has_many :users, through: :memberships
|
||||
has_many :messages, dependent: :destroy
|
||||
|
||||
# Semantic scope
|
||||
scope :direct, -> { where(direct: true) }
|
||||
|
||||
def direct?
|
||||
direct
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Scope Design
|
||||
|
||||
```ruby
|
||||
class Message < ApplicationRecord
|
||||
# Eager loading scopes
|
||||
scope :with_creator, -> { includes(:creator) }
|
||||
scope :with_attachments, -> { includes(attachment_attachment: :blob) }
|
||||
|
||||
# Cursor-based pagination scopes
|
||||
scope :page_before, ->(cursor) {
|
||||
where("id < ?", cursor.id).order(id: :desc).limit(50)
|
||||
}
|
||||
scope :page_after, ->(cursor) {
|
||||
where("id > ?", cursor.id).order(id: :asc).limit(50)
|
||||
}
|
||||
scope :last_page, -> { order(id: :desc).limit(50) }
|
||||
|
||||
# Status scopes as chainable lambdas
|
||||
scope :recent, -> { where("created_at > ?", 24.hours.ago) }
|
||||
scope :pinned, -> { where(pinned: true) }
|
||||
end
|
||||
```
|
||||
|
||||
### Custom Creation Methods
|
||||
|
||||
```ruby
|
||||
class Message < ApplicationRecord
|
||||
def self.create_with_attachment!(params)
|
||||
transaction do
|
||||
message = create!(params.except(:attachment))
|
||||
message.attach_file(params[:attachment]) if params[:attachment].present?
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
def attach_file(attachment)
|
||||
file.attach(attachment)
|
||||
update!(has_attachment: true)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Authorization on Models
|
||||
|
||||
```ruby
|
||||
class User < ApplicationRecord
|
||||
def can_administer?(message)
|
||||
message.creator == self || admin?
|
||||
end
|
||||
|
||||
def can_access?(room)
|
||||
rooms.include?(room) || admin?
|
||||
end
|
||||
|
||||
def can_invite_to?(room)
|
||||
room.creator == self || admin?
|
||||
end
|
||||
end
|
||||
|
||||
# Usage in controller
|
||||
def ensure_can_administer
|
||||
head :forbidden unless Current.user.can_administer?(@message)
|
||||
end
|
||||
```
|
||||
|
||||
### Model Broadcasting
|
||||
|
||||
```ruby
|
||||
class Message < ApplicationRecord
|
||||
after_create_commit :broadcast_create
|
||||
after_update_commit :broadcast_update
|
||||
after_destroy_commit :broadcast_destroy
|
||||
|
||||
def broadcast_create
|
||||
broadcast_append_to room, :messages,
|
||||
target: "messages",
|
||||
partial: "messages/message"
|
||||
end
|
||||
|
||||
def broadcast_update
|
||||
broadcast_replace_to room, :messages,
|
||||
target: dom_id(self, :presentation),
|
||||
partial: "messages/presentation"
|
||||
end
|
||||
|
||||
def broadcast_destroy
|
||||
broadcast_remove_to room, :messages
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Current Attributes Pattern
|
||||
|
||||
### Definition
|
||||
|
||||
```ruby
|
||||
# app/models/current.rb
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
attribute :session
|
||||
attribute :request_id
|
||||
attribute :user_agent
|
||||
|
||||
resets { Time.zone = nil }
|
||||
|
||||
def user=(user)
|
||||
super
|
||||
Time.zone = user&.time_zone
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Setting in Controller
|
||||
|
||||
```ruby
|
||||
class ApplicationController < ActionController::Base
|
||||
before_action :set_current_attributes
|
||||
|
||||
private
|
||||
def set_current_attributes
|
||||
Current.user = authenticate_user
|
||||
Current.session = session
|
||||
Current.request_id = request.request_id
|
||||
Current.user_agent = request.user_agent
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Usage Throughout App
|
||||
|
||||
```ruby
|
||||
# In models
|
||||
class Message < ApplicationRecord
|
||||
before_create :set_creator
|
||||
|
||||
private
|
||||
def set_creator
|
||||
self.creator ||= Current.user
|
||||
end
|
||||
end
|
||||
|
||||
# In views
|
||||
<%= Current.user.name %>
|
||||
|
||||
# In jobs
|
||||
class NotificationJob < ApplicationJob
|
||||
def perform(message)
|
||||
# Current is reset in jobs - pass what you need
|
||||
message.room.users.each { |user| notify(user, message) }
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Ruby Idioms
|
||||
|
||||
### Guard Clauses Over Nested Conditionals
|
||||
|
||||
```ruby
|
||||
# ✅ Guard clauses
|
||||
def process_message
|
||||
return unless message.valid?
|
||||
return if message.spam?
|
||||
return unless Current.user.can_access?(message.room)
|
||||
|
||||
message.deliver
|
||||
end
|
||||
|
||||
# ❌ Nested conditionals
|
||||
def process_message
|
||||
if message.valid?
|
||||
unless message.spam?
|
||||
if Current.user.can_access?(message.room)
|
||||
message.deliver
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Expression-less Case Statements
|
||||
|
||||
```ruby
|
||||
# ✅ Clean case without expression
|
||||
def status_class
|
||||
case
|
||||
when urgent? then "bg-red"
|
||||
when pending? then "bg-yellow"
|
||||
when completed? then "bg-green"
|
||||
else "bg-gray"
|
||||
end
|
||||
end
|
||||
|
||||
# For routing/dispatch logic
|
||||
def find_paged_messages
|
||||
case
|
||||
when params[:before].present?
|
||||
messages.page_before(params[:before])
|
||||
when params[:after].present?
|
||||
messages.page_after(params[:after])
|
||||
else
|
||||
messages.last_page
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Method Chaining
|
||||
|
||||
```ruby
|
||||
# ✅ Fluent, chainable API
|
||||
@room.messages
|
||||
.with_creator
|
||||
.with_attachments
|
||||
.excluding(@message.creator)
|
||||
.page_before(cursor)
|
||||
|
||||
# On collections
|
||||
bots_eligible_for_webhook
|
||||
.excluding(@message.creator)
|
||||
.each { |bot| bot.deliver_webhook_later(@message) }
|
||||
```
|
||||
|
||||
### Implicit Returns
|
||||
|
||||
```ruby
|
||||
# ✅ Implicit return - the Ruby way
|
||||
def full_name
|
||||
"#{first_name} #{last_name}"
|
||||
end
|
||||
|
||||
def can_administer?(message)
|
||||
message.creator == self || admin?
|
||||
end
|
||||
|
||||
# ❌ Explicit return (only when needed for early exit)
|
||||
def full_name
|
||||
return "#{first_name} #{last_name}" # Unnecessary
|
||||
end
|
||||
```
|
||||
|
||||
## View Patterns
|
||||
|
||||
### Helper Methods for Complex HTML
|
||||
|
||||
```ruby
|
||||
# app/helpers/messages_helper.rb
|
||||
module MessagesHelper
|
||||
def message_container(message, &block)
|
||||
tag.div(
|
||||
id: dom_id(message),
|
||||
class: message_classes(message),
|
||||
data: {
|
||||
controller: "message",
|
||||
message_id_value: message.id,
|
||||
action: "click->message#select"
|
||||
},
|
||||
&block
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def message_classes(message)
|
||||
classes = ["message"]
|
||||
classes << "message--mine" if message.creator == Current.user
|
||||
classes << "message--highlighted" if message.highlighted?
|
||||
classes.join(" ")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Turbo Frame Patterns
|
||||
|
||||
```erb
|
||||
<%# app/views/messages/index.html.erb %>
|
||||
<%= turbo_frame_tag "messages", data: { turbo_action: "advance" } do %>
|
||||
<%= render @messages %>
|
||||
|
||||
<% if @messages.any? %>
|
||||
<%= link_to "Load more",
|
||||
room_messages_path(@room, before: @messages.last.id),
|
||||
data: { turbo_frame: "messages" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
```
|
||||
|
||||
### Stimulus Controller Integration
|
||||
|
||||
```erb
|
||||
<div data-controller="message-form"
|
||||
data-message-form-submit-url-value="<%= room_messages_path(@room) %>">
|
||||
<%= form_with model: [@room, Message.new],
|
||||
data: { action: "submit->message-form#submit" } do |f| %>
|
||||
<%= f.text_area :body,
|
||||
data: { action: "keydown.enter->message-form#submitOnEnter" } %>
|
||||
<%= f.submit "Send" %>
|
||||
<% end %>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### System Tests First
|
||||
|
||||
```ruby
|
||||
# test/system/messages_test.rb
|
||||
class MessagesTest < ApplicationSystemTestCase
|
||||
test "sending a message" do
|
||||
sign_in users(:david)
|
||||
visit room_path(rooms(:watercooler))
|
||||
|
||||
fill_in "Message", with: "Hello, world!"
|
||||
click_button "Send"
|
||||
|
||||
assert_text "Hello, world!"
|
||||
end
|
||||
|
||||
test "editing own message" do
|
||||
sign_in users(:david)
|
||||
visit room_path(rooms(:watercooler))
|
||||
|
||||
within "#message_#{messages(:greeting).id}" do
|
||||
click_on "Edit"
|
||||
end
|
||||
|
||||
fill_in "Message", with: "Updated message"
|
||||
click_button "Save"
|
||||
|
||||
assert_text "Updated message"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Fixtures Over Factories
|
||||
|
||||
```yaml
|
||||
# test/fixtures/users.yml
|
||||
david:
|
||||
name: David
|
||||
email: david@example.com
|
||||
admin: true
|
||||
|
||||
jason:
|
||||
name: Jason
|
||||
email: jason@example.com
|
||||
admin: false
|
||||
|
||||
# test/fixtures/rooms.yml
|
||||
watercooler:
|
||||
name: Water Cooler
|
||||
creator: david
|
||||
direct: false
|
||||
|
||||
# test/fixtures/messages.yml
|
||||
greeting:
|
||||
body: Hello everyone!
|
||||
room: watercooler
|
||||
creator: david
|
||||
```
|
||||
|
||||
### Integration Tests for API
|
||||
|
||||
```ruby
|
||||
# test/integration/messages_api_test.rb
|
||||
class MessagesApiTest < ActionDispatch::IntegrationTest
|
||||
test "creating a message via API" do
|
||||
post room_messages_url(rooms(:watercooler)),
|
||||
params: { message: { body: "API message" } },
|
||||
headers: auth_headers(users(:david))
|
||||
|
||||
assert_response :success
|
||||
assert Message.exists?(body: "API message")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Configuration Patterns
|
||||
|
||||
### Solid Queue Setup
|
||||
|
||||
```ruby
|
||||
# config/queue.yml
|
||||
default: &default
|
||||
dispatchers:
|
||||
- polling_interval: 1
|
||||
batch_size: 500
|
||||
workers:
|
||||
- queues: "*"
|
||||
threads: 5
|
||||
processes: 1
|
||||
polling_interval: 0.1
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
workers:
|
||||
- queues: "*"
|
||||
threads: 10
|
||||
processes: 2
|
||||
```
|
||||
|
||||
### Database Configuration for SQLite
|
||||
|
||||
```ruby
|
||||
# config/database.yml
|
||||
default: &default
|
||||
adapter: sqlite3
|
||||
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||
timeout: 5000
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
database: storage/production.sqlite3
|
||||
```
|
||||
|
||||
### Single Container Deployment
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM ruby:3.3
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsqlite3-dev \
|
||||
libvips \
|
||||
ffmpeg
|
||||
|
||||
WORKDIR /rails
|
||||
COPY . .
|
||||
RUN bundle install
|
||||
RUN rails assets:precompile
|
||||
|
||||
EXPOSE 80 443
|
||||
CMD ["./bin/rails", "server", "-b", "0.0.0.0"]
|
||||
```
|
||||
|
||||
## Development Philosophy
|
||||
|
||||
### Ship, Validate, Refine
|
||||
|
||||
```ruby
|
||||
# 1. Merge prototype-quality code to test real usage
|
||||
# 2. Iterate based on real feedback
|
||||
# 3. Polish what works, remove what doesn't
|
||||
```
|
||||
|
||||
DHH merges features early to validate in production. Perfect code that no one uses is worse than rough code that gets feedback.
|
||||
|
||||
### Fix Root Causes
|
||||
|
||||
```ruby
|
||||
# ✅ Prevent race conditions at the source
|
||||
config.active_job.enqueue_after_transaction_commit = true
|
||||
|
||||
# ❌ Bandaid fix with retries
|
||||
retry_on ActiveRecord::RecordNotFound, wait: 1.second
|
||||
```
|
||||
|
||||
Address underlying issues rather than symptoms.
|
||||
|
||||
### Vanilla Rails Over Abstractions
|
||||
|
||||
```ruby
|
||||
# ✅ Direct ActiveRecord
|
||||
@card.comments.create!(comment_params)
|
||||
|
||||
# ❌ Service layer indirection
|
||||
CreateCommentService.call(@card, comment_params)
|
||||
```
|
||||
|
||||
Use Rails conventions. Only abstract when genuine pain emerges.
|
||||
|
||||
## Rails 7.1+ Idioms
|
||||
|
||||
### params.expect (PR #120)
|
||||
|
||||
```ruby
|
||||
# ✅ Rails 7.1+ style
|
||||
def card_params
|
||||
params.expect(card: [:title, :description, tags: []])
|
||||
end
|
||||
|
||||
# Returns 400 Bad Request if structure invalid
|
||||
|
||||
# Old style
|
||||
def card_params
|
||||
params.require(:card).permit(:title, :description, tags: [])
|
||||
end
|
||||
```
|
||||
|
||||
### StringInquirer (PR #425)
|
||||
|
||||
```ruby
|
||||
# ✅ Readable predicates
|
||||
event.action.inquiry.completed?
|
||||
event.action.inquiry.pending?
|
||||
|
||||
# Usage
|
||||
case
|
||||
when event.action.inquiry.completed?
|
||||
send_notification
|
||||
when event.action.inquiry.failed?
|
||||
send_alert
|
||||
end
|
||||
|
||||
# Old style
|
||||
event.action == "completed"
|
||||
```
|
||||
|
||||
### Positive Naming
|
||||
|
||||
```ruby
|
||||
# ✅ Positive names
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :visible, -> { where(visible: true) }
|
||||
scope :published, -> { where.not(published_at: nil) }
|
||||
|
||||
# ❌ Negative names
|
||||
scope :not_deleted, -> { ... } # Use :active
|
||||
scope :non_hidden, -> { ... } # Use :visible
|
||||
scope :is_not_draft, -> { ... } # Use :published
|
||||
```
|
||||
|
||||
## Extraction Guidelines
|
||||
|
||||
### Rule of Three
|
||||
|
||||
```ruby
|
||||
# First time: Just do it inline
|
||||
def process
|
||||
# inline logic
|
||||
end
|
||||
|
||||
# Second time: Still inline, note the duplication
|
||||
def process_again
|
||||
# same logic
|
||||
end
|
||||
|
||||
# Third time: NOW extract
|
||||
module Processing
|
||||
def shared_logic
|
||||
# extracted
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Wait for genuine pain before extracting.
|
||||
|
||||
### Start in Controller, Extract When Complex
|
||||
|
||||
```ruby
|
||||
# Phase 1: Logic in controller
|
||||
def index
|
||||
@cards = @board.cards.where(status: params[:status])
|
||||
end
|
||||
|
||||
# Phase 2: Move to model scope
|
||||
def index
|
||||
@cards = @board.cards.by_status(params[:status])
|
||||
end
|
||||
|
||||
# Phase 3: Extract concern if reused
|
||||
def index
|
||||
@cards = @board.cards.filtered(params)
|
||||
end
|
||||
```
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Don't Add Service Objects for Simple Cases
|
||||
|
||||
```ruby
|
||||
# ❌ Over-abstraction
|
||||
class MessageCreationService
|
||||
def initialize(room, params, user)
|
||||
@room = room
|
||||
@params = params
|
||||
@user = user
|
||||
end
|
||||
|
||||
def call
|
||||
message = @room.messages.build(@params)
|
||||
message.creator = @user
|
||||
message.save!
|
||||
BroadcastService.new(message).call
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
# ✅ Keep it in the model
|
||||
class Message < ApplicationRecord
|
||||
def self.create_with_broadcast!(params)
|
||||
create!(params).tap(&:broadcast_create)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Don't Use Policy Objects for Simple Auth
|
||||
|
||||
```ruby
|
||||
# ❌ Separate policy class
|
||||
class MessagePolicy
|
||||
def initialize(user, message)
|
||||
@user = user
|
||||
@message = message
|
||||
end
|
||||
|
||||
def update?
|
||||
@message.creator == @user || @user.admin?
|
||||
end
|
||||
end
|
||||
|
||||
# ✅ Method on User model
|
||||
class User < ApplicationRecord
|
||||
def can_administer?(message)
|
||||
message.creator == self || admin?
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Don't Mock Everything
|
||||
|
||||
```ruby
|
||||
# ❌ Over-mocked test
|
||||
test "sending message" do
|
||||
room = mock("room")
|
||||
user = mock("user")
|
||||
message = mock("message")
|
||||
|
||||
room.expects(:messages).returns(stub(create!: message))
|
||||
message.expects(:broadcast_create)
|
||||
|
||||
MessagesController.new.create
|
||||
end
|
||||
|
||||
# ✅ Test the real thing
|
||||
test "sending message" do
|
||||
sign_in users(:david)
|
||||
post room_messages_url(rooms(:watercooler)),
|
||||
params: { message: { body: "Hello" } }
|
||||
|
||||
assert_response :success
|
||||
assert Message.exists?(body: "Hello")
|
||||
end
|
||||
```
|
||||
@@ -1,179 +0,0 @@
|
||||
# DHH Ruby Style Resources
|
||||
|
||||
Links to source material, documentation, and further reading for mastering DHH's Ruby/Rails style.
|
||||
|
||||
## Primary Source Code
|
||||
|
||||
### Campfire (Once)
|
||||
The main codebase this style guide is derived from.
|
||||
|
||||
- **Repository**: https://github.com/basecamp/once-campfire
|
||||
- **Messages Controller**: https://github.com/basecamp/once-campfire/blob/main/app/controllers/messages_controller.rb
|
||||
- **JavaScript/Stimulus**: https://github.com/basecamp/once-campfire/tree/main/app/javascript
|
||||
- **Deployment**: Single Docker container with SQLite
|
||||
|
||||
### Other 37signals Open Source
|
||||
- **Solid Queue**: https://github.com/rails/solid_queue - Database-backed Active Job backend
|
||||
- **Solid Cache**: https://github.com/rails/solid_cache - Database-backed Rails cache
|
||||
- **Solid Cable**: https://github.com/rails/solid_cable - Database-backed Action Cable adapter
|
||||
- **Kamal**: https://github.com/basecamp/kamal - Zero-downtime deployment tool
|
||||
- **Turbo**: https://github.com/hotwired/turbo-rails - Hotwire's SPA-like page accelerator
|
||||
- **Stimulus**: https://github.com/hotwired/stimulus - Modest JavaScript framework
|
||||
|
||||
## Articles & Blog Posts
|
||||
|
||||
### Controller Organization
|
||||
- **How DHH Organizes His Rails Controllers**: https://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/
|
||||
- Definitive article on REST-pure controller design
|
||||
- Documents the "only 7 actions" philosophy
|
||||
- Shows how to create new controllers instead of custom actions
|
||||
|
||||
### Testing Philosophy
|
||||
- **37signals Dev - Pending Tests**: https://dev.37signals.com/pending-tests/
|
||||
- How 37signals handles incomplete tests
|
||||
- Pragmatic approach to test coverage
|
||||
- **37signals Dev - All About QA**: https://dev.37signals.com/all-about-qa/
|
||||
- QA philosophy at 37signals
|
||||
- Balance between automated and manual testing
|
||||
|
||||
### Architecture & Deployment
|
||||
- **Deploy Campfire on Railway**: https://railway.com/deploy/campfire
|
||||
- Single-container deployment example
|
||||
- SQLite in production patterns
|
||||
|
||||
## Official Documentation
|
||||
|
||||
### Rails Guides (DHH's Vision)
|
||||
- **Rails Doctrine**: https://rubyonrails.org/doctrine
|
||||
- The philosophical foundation
|
||||
- Convention over configuration explained
|
||||
- "Optimize for programmer happiness"
|
||||
|
||||
### Hotwire
|
||||
- **Hotwire**: https://hotwired.dev/
|
||||
- Official Hotwire documentation
|
||||
- Turbo Drive, Frames, and Streams
|
||||
- **Turbo Handbook**: https://turbo.hotwired.dev/handbook/introduction
|
||||
- **Stimulus Handbook**: https://stimulus.hotwired.dev/handbook/introduction
|
||||
|
||||
### Current Attributes
|
||||
- **Rails API - CurrentAttributes**: https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html
|
||||
- Official documentation for the Current pattern
|
||||
- Thread-isolated attribute singleton
|
||||
|
||||
## Videos & Talks
|
||||
|
||||
### DHH Keynotes
|
||||
- **RailsConf Keynotes**: Search YouTube for "DHH RailsConf"
|
||||
- Annual state of Rails addresses
|
||||
- Philosophy and direction discussions
|
||||
|
||||
### Hotwire Tutorials
|
||||
- **Hotwire Demo by DHH**: Original demo showing the approach
|
||||
- **GoRails Hotwire Series**: Practical implementation tutorials
|
||||
|
||||
## Books
|
||||
|
||||
### By DHH & 37signals
|
||||
- **Getting Real**: https://basecamp.com/gettingreal
|
||||
- Product development philosophy
|
||||
- Less is more approach
|
||||
- **Remote**: Working remotely philosophy
|
||||
- **It Doesn't Have to Be Crazy at Work**: Calm company culture
|
||||
|
||||
### Rails Books
|
||||
- **Agile Web Development with Rails**: The original Rails book
|
||||
- **The Rails Way**: Comprehensive Rails patterns
|
||||
|
||||
## Gems & Tools Used
|
||||
|
||||
### Core Stack
|
||||
```ruby
|
||||
# Gemfile patterns from Campfire
|
||||
gem "rails", "~> 8.0"
|
||||
gem "sqlite3"
|
||||
gem "propshaft" # Asset pipeline
|
||||
gem "importmap-rails" # JavaScript imports
|
||||
gem "turbo-rails" # Hotwire Turbo
|
||||
gem "stimulus-rails" # Hotwire Stimulus
|
||||
gem "solid_queue" # Job backend
|
||||
gem "solid_cache" # Cache backend
|
||||
gem "solid_cable" # WebSocket backend
|
||||
gem "kamal" # Deployment
|
||||
gem "thruster" # HTTP/2 proxy
|
||||
gem "image_processing" # Active Storage variants
|
||||
```
|
||||
|
||||
### Development
|
||||
```ruby
|
||||
group :development do
|
||||
gem "web-console"
|
||||
gem "rubocop-rails-omakase" # 37signals style rules
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
end
|
||||
```
|
||||
|
||||
## RuboCop Configuration
|
||||
|
||||
37signals publishes their RuboCop rules:
|
||||
- **rubocop-rails-omakase**: https://github.com/rails/rubocop-rails-omakase
|
||||
- Official Rails/37signals style rules
|
||||
- Use this for consistent style enforcement
|
||||
|
||||
```yaml
|
||||
# .rubocop.yml
|
||||
inherit_gem:
|
||||
rubocop-rails-omakase: rubocop.yml
|
||||
|
||||
# Project-specific overrides if needed
|
||||
```
|
||||
|
||||
## Community Resources
|
||||
|
||||
### Forums & Discussion
|
||||
- **Ruby on Rails Discourse**: https://discuss.rubyonrails.org/
|
||||
- **Reddit r/rails**: https://reddit.com/r/rails
|
||||
|
||||
### Podcasts
|
||||
- **Remote Ruby**: Ruby/Rails discussions
|
||||
- **Ruby Rogues**: Long-running Ruby podcast
|
||||
- **The Bike Shed**: Thoughtbot's development podcast
|
||||
|
||||
## Key Philosophy Documents
|
||||
|
||||
### The Rails Doctrine Pillars
|
||||
1. Optimize for programmer happiness
|
||||
2. Convention over Configuration
|
||||
3. The menu is omakase
|
||||
4. No one paradigm
|
||||
5. Exalt beautiful code
|
||||
6. Provide sharp knives
|
||||
7. Value integrated systems
|
||||
8. Progress over stability
|
||||
9. Push up a big tent
|
||||
|
||||
### DHH Quotes to Remember
|
||||
|
||||
> "The vast majority of Rails controllers can use the same seven actions."
|
||||
|
||||
> "If you're adding a custom action, you're probably missing a controller."
|
||||
|
||||
> "Clear code is better than clever code."
|
||||
|
||||
> "The test file should be a love letter to the code."
|
||||
|
||||
> "SQLite is enough for most applications."
|
||||
|
||||
## Version History
|
||||
|
||||
This style guide is based on:
|
||||
- Campfire source code (2024)
|
||||
- Rails 8.0 conventions
|
||||
- Ruby 3.3 syntax preferences
|
||||
- Hotwire 2.0 patterns
|
||||
|
||||
Last updated: 2024
|
||||
Reference in New Issue
Block a user