[2.9.0] Rename plugin to compound-engineering
BREAKING: Plugin renamed from compounding-engineering to compound-engineering. Users will need to reinstall with the new name: claude /plugin install compound-engineering Changes: - Renamed plugin directory and all references - Updated documentation counts (24 agents, 19 commands) - Added julik-frontend-races-reviewer to docs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
201
plugins/compound-engineering/skills/dhh-ruby-style/SKILL.md
Normal file
201
plugins/compound-engineering/skills/dhh-ruby-style/SKILL.md
Normal file
@@ -0,0 +1,201 @@
|
||||
---
|
||||
name: dhh-ruby-style
|
||||
description: Write Ruby and Rails code in DHH's distinctive 37signals style. Use this skill 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:
|
||||
- `references/patterns.md` - Complete code patterns with explanations
|
||||
- `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
|
||||
@@ -0,0 +1,699 @@
|
||||
# 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"]
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -0,0 +1,179 @@
|
||||
# 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