NYC
skills/sergiodxa/agent-skills/ruby-on-rails-best-practices

ruby-on-rails-best-practices

SKILL.md

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/.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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.

# 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
Weekly Installs
21
First Seen
Feb 1, 2026
Installed on
opencode19
github-copilot18
gemini-cli17
codex17
kimi-cli12
amp12