home / skills / sergiodxa / agent-skills / ruby-on-rails-best-practices
This skill helps you apply Basecamp's Ruby on Rails best practices to structure models, controllers, jobs, and concerns for maintainable code.
npx playbooks add skill sergiodxa/agent-skills --skill ruby-on-rails-best-practicesReview the files below or copy the command above to add this skill to your agents.
---
name: ruby-on-rails-best-practices
description: Ruby on Rails architecture and coding patterns from Basecamp. Use when writing, reviewing, or refactoring Rails code to follow proven conventions for models, controllers, jobs, and concerns. Triggers on tasks involving Rails models, concerns, controllers, background jobs, or Turbo/Hotwire.
---
# Ruby on Rails Best Practices
Architecture patterns and coding conventions extracted from Basecamp's production Rails applications (Fizzy and Campfire). Contains 16 rules across 6 categories focused on code organization, maintainability, and following "The Rails Way" with Basecamp's refinements.
## When to Apply
Reference these guidelines when:
- Organizing models, concerns, and controllers
- Writing background jobs
- Implementing real-time features with Turbo Streams
- Deciding where code should live
- Writing tests for Rails applications
- Reviewing Rails code for architectural consistency
## Rules Summary
### Model Organization (HIGH)
#### model-scoped-concerns - @rules/model-scoped-concerns.md
Place model-specific concerns in `app/models/model_name/` not `app/models/concerns/`.
```ruby
# Directory structure
app/models/
├── card.rb
├── card/
│ ├── closeable.rb # Card::Closeable
│ ├── searchable.rb # Card::Searchable
│ └── assignable.rb # Card::Assignable
# app/models/card.rb
class Card < ApplicationRecord
include Closeable, Searchable, Assignable
# Ruby resolves from Card:: namespace first
end
```
#### concern-naming - @rules/concern-naming.md
Use `-able` suffix for behavior concerns, nouns for feature concerns.
```ruby
# Behaviors: -able suffix
module Card::Closeable # Can be closed
module Card::Searchable # Can be searched
module User::Mentionable # Can be mentioned
# Features: nouns
module User::Avatar # Has avatar
module User::Role # Has role
module Card::Mentions # Has @mentions
```
#### template-method-concerns - @rules/template-method-concerns.md
Use template methods in shared concerns for customizable behavior.
```ruby
# app/models/concerns/searchable.rb (shared)
module Searchable
def search_title
raise NotImplementedError
end
end
# app/models/card/searchable.rb (model-specific)
module Card::Searchable
include ::Searchable
def search_title
title # Implement the hook
end
end
```
### Background Jobs (HIGH)
#### paired-async-methods - @rules/paired-async-methods.md
Pair sync methods with `_later` variants that enqueue jobs.
```ruby
# app/models/card/readable.rb
def remove_inaccessible_notifications
# Sync implementation
end
private
def remove_inaccessible_notifications_later
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
end
# app/jobs/card/remove_inaccessible_notifications_job.rb
class Card::RemoveInaccessibleNotificationsJob < ApplicationJob
def perform(card)
card.remove_inaccessible_notifications
end
end
```
#### thin-jobs - @rules/thin-jobs.md
Jobs call model methods. All logic lives in models.
```ruby
# Bad: Logic in job
class ProcessOrderJob < ApplicationJob
def perform(order)
order.items.each { |i| i.product.decrement!(:stock) }
order.update!(status: :processing)
end
end
# Good: Job delegates to model
class ProcessOrderJob < ApplicationJob
def perform(order)
order.process # Single method call
end
end
```
### Controllers (HIGH)
#### resource-controllers - @rules/resource-controllers.md
Create resource controllers for state changes, not custom actions.
```ruby
# Bad: Custom actions
resources :cards do
post :close
post :reopen
end
# Good: Resource controllers
resources :cards do
resource :closure, only: [:create, :destroy]
end
# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
def create
@card.close
end
def destroy
@card.reopen
end
end
```
#### scoping-concerns - @rules/scoping-concerns.md
Use concerns like `CardScoped` for nested resource setup.
```ruby
# app/controllers/concerns/card_scoped.rb
module CardScoped
extend ActiveSupport::Concern
included do
before_action :set_card
end
private
def set_card
@card = Current.user.accessible_cards.find_by!(number: params[:card_id])
end
end
# Usage
class Cards::CommentsController < ApplicationController
include CardScoped
end
```
#### thin-controllers - @rules/thin-controllers.md
Controllers call rich model APIs directly. No service objects.
```ruby
# Good: Thin controller, rich model
class Cards::ClosuresController < ApplicationController
include CardScoped
def create
@card.close # All logic in model
end
end
```
### Request Context (MEDIUM)
#### current-attributes - @rules/current-attributes.md
Use `Current` for request-scoped data with cascading setters.
```ruby
class Current < ActiveSupport::CurrentAttributes
attribute :session, :user, :account
def session=(value)
super(value)
self.user = session&.user
end
end
```
#### current-in-other-contexts - @rules/current-in-other-contexts.md
`Current` is only auto-populated in web requests. Jobs, mailers, and channels need explicit setup.
```ruby
# Jobs: extend ActiveJob to serialize/restore Current.account
# Mailers from jobs: wrap in Current.with_account { mailer.deliver }
# Channels: set Current in Connection#connect
```
### Associations & Callbacks (MEDIUM)
#### association-extensions - @rules/association-extensions.md
Choose between association extensions and model class methods based on context needs.
```ruby
# Use extension when you need parent context (proxy_association.owner)
has_many :accesses do
def grant_to(users)
board = proxy_association.owner
Access.insert_all(users.map { |u| { user_id: u.id, board_id: board.id, account_id: board.account_id } })
end
end
# Use class method when operation is independent
class Access
def self.grant(board:, users:)
insert_all(users.map { |u| { user_id: u.id, board_id: board.id } })
end
end
```
#### callbacks-patterns - @rules/callbacks-patterns.md
Use `after_commit` for jobs, inline lambdas for simple ops.
```ruby
# Jobs: after_commit
after_create_commit :notify_recipients_later
# Simple ops: inline lambda
after_save -> { board.touch }, if: :published?
# Conditional: remember and check pattern
before_update :remember_changes
after_update_commit :process_changes, if: :should_process?
```
### Turbo & Real-time (MEDIUM)
#### turbo-broadcasts - @rules/turbo-broadcasts.md
Explicit broadcasts from controllers, not callbacks.
```ruby
# app/models/message/broadcasts.rb
module Message::Broadcasts
def broadcast_create
broadcast_append_to room, :messages, target: [room, :messages]
end
end
# Controller calls explicitly
def create
@message = @room.messages.create!(message_params)
@message.broadcast_create
end
```
### Testing (MEDIUM)
#### fixtures-testing - @rules/fixtures-testing.md
Use fixtures, not factories. Mirror concern structure in tests.
```ruby
# test/fixtures/cards.yml
logo:
title: The logo isn't big enough
board: writebook
creator: david
# test/models/card/closeable_test.rb
class Card::CloseableTest < ActiveSupport::TestCase
test "close creates closure" do
card = cards(:logo)
assert_difference -> { Closure.count } do
card.close
end
end
end
```
### Code Organization (LOW-MEDIUM)
#### nested-service-objects - @rules/nested-service-objects.md
Place service objects under model namespace, not `app/services`.
```ruby
# Good: app/models/card/activity_spike/detector.rb
class Card::ActivitySpike::Detector
def initialize(card)
@card = card
end
def detect
# ...
end
end
```
#### code-style - @rules/code-style.md
Prefer expanded conditionals, order methods by invocation.
```ruby
# Expanded conditionals
def find_record
if record = find_by_id(id)
record
else
NullRecord.new
end
end
# Method ordering: caller before callees
def process
step_one
step_two
end
private
def step_one; end
def step_two; end
```
## Philosophy
These patterns embody "Vanilla Rails" - using Rails conventions with minimal additions:
1. **Rich models, thin controllers** - Domain logic in models and concerns
2. **No service object layer** - Controllers talk to models directly
3. **Co-located code** - Concerns, jobs, and services near the models they serve
4. **Explicit over implicit** - Call broadcasts explicitly, not via callbacks
5. **Convention over configuration** - Follow naming patterns for predictability
This skill captures Ruby on Rails architecture and coding patterns inspired by Basecamp’s production apps. It focuses on organizing models, controllers, background jobs, concerns, and Turbo/Hotwire interactions to keep code maintainable and idiomatic. Use it when writing, reviewing, or refactoring Rails code to follow proven, convention-driven patterns.
The skill inspects tasks that involve Rails models, concerns, controllers, background jobs, or Turbo/Hotwire features and suggests concrete changes to align code with the patterns. It enforces co-location of model-specific concerns, thin controllers with rich models, paired async methods for jobs, explicit Turbo broadcasts, and request-scoped Current handling. Advice is actionable: file/layout recommendations, method naming, job delegation, and callback usage.
Should I introduce service objects for complex work?
Prefer co-locating logic under the model namespace (e.g., model/service_name) and exposing a clean model API; avoid a separate global services layer unless integration complexity demands it.
When should I use after_commit vs callbacks?
Use after_commit to enqueue jobs and for post-persist side effects; use simple inline lambdas for trivial operations and the remember-and-check pattern for conditional flows.