Claude Code Plugins

Community-maintained marketplace

Feedback

Rails Conventions & Patterns

@Kaakati/rails-enterprise-dev
1
0

Comprehensive Ruby on Rails conventions, design patterns, and idiomatic code standards. Use this skill when writing any Rails code including controllers, models, services, or when making architectural decisions about code organization, naming conventions, and Rails best practices.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name Rails Conventions & Patterns
description Comprehensive Ruby on Rails conventions, design patterns, and idiomatic code standards. Use this skill when writing any Rails code including controllers, models, services, or when making architectural decisions about code organization, naming conventions, and Rails best practices.

Rails Conventions & Patterns Skill

This skill provides authoritative guidance on Ruby on Rails conventions, design patterns, and idiomatic code standards for production applications.

When to Use This Skill

  • Writing new Rails controllers, models, or services
  • Refactoring existing Rails code
  • Making decisions about code organization
  • Choosing between different Rails patterns
  • Ensuring code follows Rails conventions
  • Reviewing Rails code for convention compliance

Ruby & Rails Versions

ruby: "3.2+ (prefer 3.3+ for YJIT benefits)"
rails: "7.1+ (prefer 8.0+ for new projects)"

Rails 7.x/8.x Modern Features

Rails 7.1+ Features

# Composite Primary Keys
class BookOrder < ApplicationRecord
  self.primary_key = [:shop_id, :id]
  belongs_to :shop
  has_many :line_items, foreign_key: [:shop_id, :order_id]
end

# ActiveRecord::Encryption (sensitive data)
class User < ApplicationRecord
  encrypts :email, deterministic: true
  encrypts :ssn, :credit_card
end

# Horizontal Sharding
class ApplicationRecord < ActiveRecord::Base
  connects_to shards: {
    default: { writing: :primary, reading: :primary_replica },
    shard_two: { writing: :primary_shard_two }
  }
end

# Async Query Loading
posts = Post.where(published: true).load_async
# Do other work
posts.to_a # Wait for results

# Normalize values before validation
class User < ApplicationRecord
  normalizes :email, with: -> { _1.strip.downcase }
  normalizes :phone, with: -> { _1.gsub(/\D/, '') }
end

Rails 8.0+ Features

# Improved Solid Queue (built-in job backend)
# config/application.rb
config.active_job.queue_adapter = :solid_queue

# Solid Cache (built-in caching)
# config/application.rb
config.cache_store = :solid_cache_store

# Authentication generator
rails generate authentication

# Built-in rate limiting
class Api::PostsController < Api::BaseController
  rate_limit to: 10, within: 1.minute, only: :create
end

# Per-environment credentials
rails credentials:edit --environment production

Modern Ruby 3.3+ Features

# Pattern matching in case expressions
case user
in { role: "admin", active: true }
  grant_full_access
in { role: "user", active: true }
  grant_standard_access
else
  deny_access
end

# Endless method definitions (one-liners)
def full_name = "#{first_name} #{last_name}"
def published? = published_at.present?

# Data class (immutable value objects, Ruby 3.2+)
User = Data.define(:id, :name, :email)
user = User.new(id: 1, name: "Alice", email: "alice@example.com")

# YJIT optimization (Ruby 3.3+)
# config/application.rb
if defined?(RubyVM::YJIT.enable)
  RubyVM::YJIT.enable
end

File Organization Standards

Models

location: "app/models/"
max_lines: 200
guidance: |
  Focus on associations, validations, scopes, and essential callbacks.
  Extract business logic to Service Objects.
  Keep models focused on data persistence and domain rules.

Controllers

location: "app/controllers/"
max_lines: 100
guidance: |
  Limit to REST actions. Use before_action for shared logic.
  Complex operations delegate to Service Objects.
  Follow "Skinny Controller, Fat Model (but not too fat)" pattern.

Comprehensive Controller Patterns

RESTful Controller Structure

class PostsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_post, only: [:show, :edit, :update, :destroy]
  before_action :authorize_post, only: [:edit, :update, :destroy]

  # GET /posts
  def index
    @posts = Post.published.page(params[:page])
  end

  # GET /posts/:id
  def show
    # @post set by before_action
  end

  # GET /posts/new
  def new
    @post = Post.new
  end

  # POST /posts
  def create
    @post = CreatePostService.call(current_user, post_params)

    if @post.persisted?
      redirect_to @post, notice: "Post created successfully"
    else
      render :new, status: :unprocessable_entity
    end
  end

  # GET /posts/:id/edit
  def edit
    # @post set by before_action
  end

  # PATCH /posts/:id
  def update
    if UpdatePostService.call(@post, post_params)
      redirect_to @post, notice: "Post updated successfully"
    else
      render :edit, status: :unprocessable_entity
    end
  end

  # DELETE /posts/:id
  def destroy
    @post.destroy!
    redirect_to posts_url, notice: "Post deleted successfully"
  end

  private

  def set_post
    @post = Post.find(params[:id])
  end

  def authorize_post
    authorize @post # Pundit
  end

  def post_params
    params.require(:post).permit(:title, :body, :published)
  end
end

API Controller Patterns

# app/controllers/api/base_controller.rb
module Api
  class BaseController < ActionController::API
    include ActionController::HttpAuthentication::Token::ControllerMethods

    before_action :authenticate_api_user!

    rescue_from ActiveRecord::RecordNotFound, with: :not_found
    rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
    rescue_from Pundit::NotAuthorizedError, with: :forbidden

    private

    def authenticate_api_user!
      authenticate_or_request_with_http_token do |token, options|
        @current_user = User.find_by(api_token: token)
      end
    end

    def not_found(exception)
      render json: { error: exception.message }, status: :not_found
    end

    def unprocessable_entity(exception)
      render json: { errors: exception.record.errors }, status: :unprocessable_entity
    end

    def forbidden
      render json: { error: "Forbidden" }, status: :forbidden
    end
  end
end

# app/controllers/api/v1/posts_controller.rb
module Api
  module V1
    class PostsController < Api::BaseController
      def index
        posts = Post.published.page(params[:page])
        render json: PostBlueprint.render(posts, root: :posts)
      end

      def create
        post = CreatePostService.call(current_user, post_params)

        if post.persisted?
          render json: PostBlueprint.render(post), status: :created
        else
          render json: { errors: post.errors }, status: :unprocessable_entity
        end
      end
    end
  end
end

Hotwire Controller Patterns

class PostsController < ApplicationController
  # Turbo Stream responses
  def create
    @post = CreatePostService.call(current_user, post_params)

    respond_to do |format|
      if @post.persisted?
        format.turbo_stream
        format.html { redirect_to @post }
      else
        format.turbo_stream { render :form_errors, status: :unprocessable_entity }
        format.html { render :new, status: :unprocessable_entity }
      end
    end
  end

  def update
    respond_to do |format|
      if UpdatePostService.call(@post, post_params)
        format.turbo_stream
        format.html { redirect_to @post }
      else
        format.turbo_stream { render :form_errors, status: :unprocessable_entity }
        format.html { render :edit, status: :unprocessable_entity }
      end
    end
  end
end

# app/views/posts/create.turbo_stream.erb
<%= turbo_stream.prepend "posts", @post %>
<%= turbo_stream.update "new_post_form", "" %>

Nested Resource Controllers

class CommentsController < ApplicationController
  before_action :set_post
  before_action :set_comment, only: [:show, :edit, :update, :destroy]

  # GET /posts/:post_id/comments
  def index
    @comments = @post.comments.page(params[:page])
  end

  # POST /posts/:post_id/comments
  def create
    @comment = @post.comments.build(comment_params)
    @comment.user = current_user

    if @comment.save
      redirect_to [@post, @comment]
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_post
    @post = Post.find(params[:post_id])
  end

  def set_comment
    @comment = @post.comments.find(params[:id])
  end
end

Controller Concerns

# app/controllers/concerns/paginatable.rb
module Paginatable
  extend ActiveSupport::Concern

  included do
    before_action :set_pagination_params
  end

  private

  def set_pagination_params
    @page = params[:page] || 1
    @per_page = params[:per_page] || 25
  end

  def paginate(collection)
    collection.page(@page).per(@per_page)
  end
end

# Usage
class PostsController < ApplicationController
  include Paginatable

  def index
    @posts = paginate(Post.published)
  end
end

Services

location: "app/services/"
naming: "{Domain}Manager::{Action} (e.g., OrdersManager::CreateOrder)"
structure: |
  class OrdersManager::CreateOrder
    def initialize(user:, params:)
      @user = user
      @params = params
    end

    def call
      # Single public entry point
      # Returns Result object or raises
    end

    private

    # Small, focused private methods
  end

Methods

max_lines: 15
max_params: 4
guidance: "If method needs more params, use a Parameter Object or Hash"

Naming Conventions

classes: "PascalCase"
methods: "snake_case"
predicates: "end with ? (e.g., active?, valid?)"
dangerous_methods: "end with ! (e.g., save!, destroy!)"
constants: "SCREAMING_SNAKE_CASE"
private_methods: "Prefix with purpose, not underscore"

Ruby Idioms

Prefer

  • Guard clauses over nested conditionals
  • Explicit returns for clarity
  • &. (safe navigation) over try
  • Keyword arguments for 2+ parameters
  • Struct/Data for simple value objects
  • frozen_string_literal: true pragma

Avoid

  • unless with else
  • Nested ternaries
  • and/or for control flow
  • Monkey patching in application code

Pattern Decision Tree

Always inspect existing codebase patterns before recommending any pattern.

Service Object

# Use when:
# - Business logic spans multiple models
# - Operation has multiple steps
# - Logic doesn't belong to any single model
# - Need to orchestrate external services

# Avoid when:
# - Simple CRUD operation
# - Logic clearly belongs to one model
# - Single-line delegation

# Inspect first:
# ls app/services/
# Check existing service naming convention

Form Object

# Use when:
# - Form spans multiple models
# - Complex validations not tied to persistence
# - Wizard/multi-step forms

# Avoid when:
# - Standard single-model form
# - Simple attribute updates

# Inspect first:
# ls app/forms/ 2>/dev/null
# grep -r 'include ActiveModel' app/ --include='*.rb'

Query Object

# Use when:
# - Complex queries with multiple conditions
# - Query logic reused across controllers
# - Query needs composition/chaining

# Avoid when:
# - Simple scope suffices
# - One-off query

# Inspect first:
# ls app/queries/ 2>/dev/null
# grep -r 'class.*Query' app/ --include='*.rb'

Concern

# Use when:
# - Truly shared behavior across 3+ unrelated models
# - Behavior is cohesive and self-contained

# Avoid when:
# - Only 1-2 models share the code
# - Behavior is not cohesive
# - Just to 'clean up' a model

# Inspect first:
# ls app/models/concerns/ app/controllers/concerns/
# Check how many models use each concern

Decorator/Presenter

# Use when:
# - View logic becoming complex
# - Same presentation logic in multiple views
# - Need to augment model for display

# Avoid when:
# - Simple attribute display
# - One-off formatting

# Inspect first:
# ls app/decorators/ app/presenters/ 2>/dev/null
# grep 'draper' Gemfile

ActionMailer Conventions

Mailer Structure

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  default from: 'notifications@example.com'

  def welcome_email(user)
    @user = user
    @url = root_url

    mail(
      to: email_address_with_name(@user.email, @user.name),
      subject: 'Welcome to My App'
    )
  end

  def password_reset(user, token)
    @user = user
    @token = token
    @reset_url = edit_password_reset_url(token: @token)

    mail(to: @user.email, subject: 'Password Reset Instructions')
  end

  private

  def email_address_with_name(email, name)
    Mail::Address.new(email).tap { |a| a.display_name = name }.format
  end
end

# app/views/user_mailer/welcome_email.html.erb
<h1>Welcome <%= @user.name %>!</h1>
<p>Click here to get started: <%= link_to 'Get Started', @url %></p>

# app/views/user_mailer/welcome_email.text.erb
Welcome <%= @user.name %>!

Click here to get started: <%= @url %>

Mailer Testing

# spec/mailers/user_mailer_spec.rb
RSpec.describe UserMailer, type: :mailer do
  describe '#welcome_email' do
    let(:user) { create(:user, email: 'user@example.com') }
    let(:mail) { UserMailer.welcome_email(user) }

    it 'renders the subject' do
      expect(mail.subject).to eq('Welcome to My App')
    end

    it 'renders the receiver email' do
      expect(mail.to).to eq([user.email])
    end

    it 'renders the sender email' do
      expect(mail.from).to eq(['notifications@example.com'])
    end

    it 'contains user name' do
      expect(mail.body.encoded).to match(user.name)
    end
  end
end

Mailer Previews (Rails 4.1+)

# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def welcome_email
    UserMailer.welcome_email(User.first)
  end

  def password_reset
    user = User.first
    token = SecureRandom.urlsafe_base64
    UserMailer.password_reset(user, token)
  end
end

# Visit: http://localhost:3000/rails/mailers/user_mailer/welcome_email

Background Delivery

# Deliver later (asynchronous)
UserMailer.welcome_email(@user).deliver_later

# Deliver later with delay
UserMailer.welcome_email(@user).deliver_later(wait: 1.hour)

# Deliver later at specific time
UserMailer.welcome_email(@user).deliver_later(wait_until: Date.tomorrow.noon)

# Deliver now (synchronous)
UserMailer.welcome_email(@user).deliver_now

Background Job Conventions

ActiveJob Structure

# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  # Global retry configuration
  retry_on StandardError, wait: :exponentially_longer, attempts: 5
  retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3

  # Discard specific errors
  discard_on ActiveJob::DeserializationError

  # Global error handling
  rescue_from(Exception) do |exception|
    ErrorTracker.notify(exception)
    raise exception
  end
end

# app/jobs/send_welcome_email_job.rb
class SendWelcomeEmailJob < ApplicationJob
  queue_as :mailers

  def perform(user)
    UserMailer.welcome_email(user).deliver_now
  end
end

# Usage
SendWelcomeEmailJob.perform_later(user)

Sidekiq-Specific Patterns

# app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob
  queue_as :orders

  # Sidekiq-specific options
  sidekiq_options retry: 3,
                  backtrace: true,
                  dead: true

  def perform(order_id)
    order = Order.find(order_id)
    OrderProcessor.new(order).process!
  end
end

# config/sidekiq.yml
:queues:
  - critical
  - default
  - mailers
  - low_priority

:schedule:
  daily_cleanup:
    cron: '0 0 * * *'  # Daily at midnight
    class: DailyCleanupJob

Job Testing

# spec/jobs/send_welcome_email_job_spec.rb
RSpec.describe SendWelcomeEmailJob, type: :job do
  include ActiveJob::TestHelper

  let(:user) { create(:user) }

  it 'enqueues the job' do
    expect {
      SendWelcomeEmailJob.perform_later(user)
    }.to have_enqueued_job(SendWelcomeEmailJob).with(user)
  end

  it 'sends welcome email' do
    expect {
      perform_enqueued_jobs do
        SendWelcomeEmailJob.perform_later(user)
      end
    }.to change { ActionMailer::Base.deliveries.count }.by(1)
  end

  it 'retries on failure' do
    allow(UserMailer).to receive(:welcome_email).and_raise(StandardError)

    expect {
      SendWelcomeEmailJob.perform_later(user)
    }.to have_enqueued_job(SendWelcomeEmailJob).on_queue(:mailers)
  end
end

Action Cable (WebSocket) Conventions

Channel Structure

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if verified_user = User.find_by(id: cookies.encrypted[:user_id])
        verified_user
      else
        reject_unauthorized_connection
      end
    end
  end
end

# app/channels/chat_channel.rb
class ChatChannel < ApplicationCable::Channel
  def subscribed
    # Stream from specific room
    stream_from "chat_#{params[:room_id]}"

    # Or stream for current user
    stream_for current_user
  end

  def unsubscribed
    # Cleanup when channel is unsubscribed
    stop_all_streams
  end

  def speak(data)
    # Receive data from client
    message = current_user.messages.create!(
      content: data['message'],
      room_id: params[:room_id]
    )

    # Broadcast to all subscribers
    ActionCable.server.broadcast(
      "chat_#{params[:room_id]}",
      message: render_message(message)
    )
  end

  private

  def render_message(message)
    ApplicationController.render(
      partial: 'messages/message',
      locals: { message: message }
    )
  end
end

Client-Side JavaScript

// app/javascript/channels/chat_channel.js
import consumer from "./consumer"

consumer.subscriptions.create(
  { channel: "ChatChannel", room_id: roomId },
  {
    connected() {
      console.log("Connected to chat")
    },

    disconnected() {
      console.log("Disconnected from chat")
    },

    received(data) {
      const messages = document.getElementById('messages')
      messages.insertAdjacentHTML('beforeend', data.message)
    },

    speak(message) {
      this.perform('speak', { message: message })
    }
  }
)

Broadcasting from Models

# app/models/message.rb
class Message < ApplicationRecord
  belongs_to :user
  belongs_to :room

  after_create_commit :broadcast_message

  private

  def broadcast_message
    broadcast_append_to(
      [room, :messages],
      target: "messages",
      partial: "messages/message",
      locals: { message: self }
    )
  end
end

Cable Testing

# spec/channels/chat_channel_spec.rb
RSpec.describe ChatChannel, type: :channel do
  let(:user) { create(:user) }
  let(:room) { create(:room) }

  before do
    stub_connection(current_user: user)
  end

  it 'successfully subscribes' do
    subscribe(room_id: room.id)
    expect(subscription).to be_confirmed
    expect(subscription).to have_stream_from("chat_#{room.id}")
  end

  it 'broadcasts messages' do
    subscribe(room_id: room.id)

    expect {
      perform :speak, message: 'Hello'
    }.to have_broadcasted_to("chat_#{room.id}")
  end
end

Enhanced Concern Best Practices

When to Use Concerns

# GOOD: Truly shared behavior across unrelated models
# app/models/concerns/publishable.rb
module Publishable
  extend ActiveSupport::Concern

  included do
    scope :published, -> { where(published: true) }
    scope :draft, -> { where(published: false) }

    validates :published_at, presence: true, if: :published?
  end

  def publish!
    update!(published: true, published_at: Time.current)
  end

  def unpublish!
    update!(published: false, published_at: nil)
  end
end

# Used in multiple unrelated models
class Post < ApplicationRecord
  include Publishable
end

class Video < ApplicationRecord
  include Publishable
end

class Podcast < ApplicationRecord
  include Publishable
end

Concern with Dependencies

# app/models/concerns/taggable.rb
module Taggable
  extend ActiveSupport::Concern

  included do
    # Dependencies injection
    has_many :taggings, as: :taggable, dependent: :destroy
    has_many :tags, through: :taggings

    scope :tagged_with, ->(tag_name) {
      joins(:tags).where(tags: { name: tag_name })
    }
  end

  # Instance methods
  def tag_names=(names)
    self.tags = names.map { |n| Tag.find_or_create_by(name: n.strip) }
  end

  def tag_names
    tags.pluck(:name)
  end

  # Class methods
  class_methods do
    def most_tagged(limit = 10)
      select('taggable_id, COUNT(*) as tags_count')
        .group('taggable_id')
        .order('tags_count DESC')
        .limit(limit)
    end
  end
end

Controller Concerns

# app/controllers/concerns/error_handling.rb
module ErrorHandling
  extend ActiveSupport::Concern

  included do
    rescue_from ActiveRecord::RecordNotFound, with: :not_found
    rescue_from ActiveRecord::RecordInvalid, with: :unprocessable_entity
    rescue_from Pundit::NotAuthorizedError, with: :unauthorized
  end

  private

  def not_found
    respond_to do |format|
      format.html { render 'errors/404', status: :not_found }
      format.json { render json: { error: 'Not found' }, status: :not_found }
    end
  end

  def unprocessable_entity(exception)
    respond_to do |format|
      format.html { render 'errors/422', status: :unprocessable_entity }
      format.json { render json: { errors: exception.record.errors }, status: :unprocessable_entity }
    end
  end

  def unauthorized
    respond_to do |format|
      format.html { redirect_to root_path, alert: 'Not authorized' }
      format.json { render json: { error: 'Not authorized' }, status: :forbidden }
    end
  end
end

# Usage
class ApplicationController < ActionController::Base
  include ErrorHandling
end

Method Visibility Rules

Public

# Callable from anywhere, defines the API
# Controller actions must be public
# Methods called from views must be public
# Service interface methods

# Rails context:
# - Controller: only public methods are routable
# - Model: public methods accessible from controllers/views
# - Component: only public methods callable from templates

Private

# Can only be called within the class, without explicit receiver
# Implementation details
# Helper methods not part of public API
# Methods that should never be called externally

# Rails context:
# - Controller: helper methods, before_action callbacks
# - Service: internal computation methods
# - Model: internal validation helpers

# CRITICAL: Private methods CANNOT be called from outside the class.
# If a view needs data, the component MUST have a public method.

Protected

# Callable from same class or subclasses
# Methods meant for inheritance
# Rare in typical Rails apps

# Rails context:
# - Occasionally in base controllers/models for shared behavior

Delegation Patterns

Using delegate

# Creates public forwarding methods
# LIMITATION: Cannot delegate to private methods on target

delegate :method1, :method2, to: :target

class Component < ViewComponent::Base
  delegate :total, :count, to: :@service
  
  def initialize(service:)
    @service = service
  end
end
# Now view can call component.total

Wrapper Methods

# Use when:
# - Need to transform data
# - Need to add caching
# - Need different method names
# - Need to handle errors

class Component < ViewComponent::Base
  def total
    @service.calculate_total
  rescue ServiceError
    0
  end
end

attr_reader Exposure

# Expose the underlying object directly
# Use sparingly - breaks encapsulation

class Component < ViewComponent::Base
  attr_reader :service
  
  def initialize(service:)
    @service = service
  end
end
# View calls: component.service.calculate_total

Rails Request Cycle

Request → Route → Controller#action
       → Controller → Service/Model (business logic)
       → Controller → sets @instance_variables
       → Controller → renders View
       → View → calls methods on @variables
       → View → renders Components
       → Component → accesses only its own methods

Key Insight: Each layer can only access what the previous layer explicitly provides. Views can't magically access service internals.

Implementation Order

Always implement in dependency order (bottom-up):

1. Database migrations (if needed)
2. Models (foundation)
3. Services (business logic)
4. Components (presentation wrappers)
5. Controllers (orchestration)
6. Views (final layer)
7. Tests (verify everything works)

Rationale: Each layer depends on the ones below it. Implementing bottom-up ensures dependencies exist before they're used.

Code Quality Standards

Method Size

  • Maximum 15 lines per method
  • Single responsibility per method
  • Extract complex logic to private helper methods

Class Size

  • Models: max 200 lines
  • Controllers: max 100 lines
  • Services: max 150 lines

Parameter Count

  • Maximum 4 parameters
  • Use keyword arguments for 2+ parameters
  • Use Parameter Objects for complex cases

Form Objects (Expanded)

Basic Form Object

# app/forms/user_registration_form.rb
class UserRegistrationForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :email, :string
  attribute :password, :string
  attribute :password_confirmation, :string
  attribute :first_name, :string
  attribute :last_name, :string
  attribute :accept_terms, :boolean

  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :password, presence: true, length: { minimum: 8 }
  validates :password_confirmation, presence: true
  validates :first_name, :last_name, presence: true
  validates :accept_terms, acceptance: true
  validate :passwords_match

  def save
    return false unless valid?

    ActiveRecord::Base.transaction do
      @user = User.create!(
        email: email,
        password: password,
        first_name: first_name,
        last_name: last_name
      )

      @profile = @user.create_profile!(
        full_name: "#{first_name} #{last_name}"
      )

      SendWelcomeEmailJob.perform_later(@user)
    end

    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.message)
    false
  end

  attr_reader :user, :profile

  private

  def passwords_match
    return if password == password_confirmation

    errors.add(:password_confirmation, "doesn't match password")
  end
end

# Controller usage
def create
  @form = UserRegistrationForm.new(registration_params)

  if @form.save
    redirect_to @form.user, notice: 'Registration successful'
  else
    render :new, status: :unprocessable_entity
  end
end

Multi-Step Wizard Form

# app/forms/checkout_wizard.rb
class CheckoutWizard
  include ActiveModel::Model

  STEPS = [:shipping, :payment, :confirmation].freeze

  attr_accessor :current_step
  attr_reader :order

  delegate :shipping_address, :billing_address, :payment_method,
           :shipping_address=, :billing_address=, :payment_method=,
           to: :order

  validates :shipping_address, presence: true, if: :shipping_step?
  validates :payment_method, presence: true, if: :payment_step?

  def initialize(order, current_step: :shipping)
    @order = order
    @current_step = current_step.to_sym
  end

  def next_step
    return if last_step?

    self.current_step = STEPS[STEPS.index(current_step) + 1]
  end

  def previous_step
    return if first_step?

    self.current_step = STEPS[STEPS.index(current_step) - 1]
  end

  def save
    return false unless valid?

    order.save
  end

  def first_step?
    current_step == STEPS.first
  end

  def last_step?
    current_step == STEPS.last
  end

  private

  def shipping_step?
    current_step == :shipping
  end

  def payment_step?
    current_step == :payment
  end
end

Decorators (Expanded)

Draper Decorator Pattern

# Gemfile
gem 'draper'

# app/decorators/application_decorator.rb
class ApplicationDecorator < Draper::Decorator
  delegate_all

  def created_at
    h.content_tag(:time, object.created_at.strftime("%B %d, %Y"),
                  datetime: object.created_at.iso8601)
  end
end

# app/decorators/user_decorator.rb
class UserDecorator < ApplicationDecorator
  def full_name
    "#{object.first_name} #{object.last_name}"
  end

  def profile_link
    h.link_to full_name, h.user_path(object), class: 'user-link'
  end

  def avatar
    if object.avatar.attached?
      h.image_tag object.avatar.variant(resize_to_limit: [100, 100])
    else
      h.image_tag 'default-avatar.png', alt: full_name
    end
  end

  def status_badge
    css_class = object.active? ? 'badge-success' : 'badge-secondary'
    status_text = object.active? ? 'Active' : 'Inactive'

    h.content_tag(:span, status_text, class: "badge #{css_class}")
  end

  def member_since
    "Member since #{object.created_at.strftime('%B %Y')}"
  end
end

# Controller usage
def show
  @user = User.find(params[:id]).decorate
end

# View usage
<%= @user.profile_link %>
<%= @user.avatar %>
<%= @user.status_badge %>

SimpleDelegator Pattern (Without Gems)

# app/decorators/user_decorator.rb
class UserDecorator < SimpleDelegator
  def initialize(user, view_context)
    super(user)
    @view_context = view_context
  end

  def full_name
    "#{first_name} #{last_name}"
  end

  def profile_link
    h.link_to full_name, h.user_path(self)
  end

  def formatted_created_at
    created_at.strftime("%B %d, %Y")
  end

  private

  def h
    @view_context
  end
end

# Controller
def show
  user = User.find(params[:id])
  @user = UserDecorator.new(user, view_context)
end

Presenters (Expanded)

View-Specific Presenter

# app/presenters/dashboard_presenter.rb
class DashboardPresenter
  def initialize(user, view_context)
    @user = user
    @view_context = view_context
  end

  def welcome_message
    time_of_day = Time.current.hour < 12 ? 'Morning' : 'Afternoon'
    "Good #{time_of_day}, #{@user.first_name}!"
  end

  def recent_orders
    @recent_orders ||= @user.orders.recent.limit(5).map do |order|
      OrderPresenter.new(order, @view_context)
    end
  end

  def total_spent
    h.number_to_currency(@user.orders.sum(:total))
  end

  def activity_feed
    @user.activities.recent.limit(10).map do |activity|
      {
        icon: activity_icon(activity),
        text: activity_text(activity),
        time: h.time_ago_in_words(activity.created_at)
      }
    end
  end

  def stats
    {
      total_orders: @user.orders.count,
      total_spent: total_spent,
      favorite_category: @user.favorite_category&.name || 'N/A',
      member_since: @user.created_at.year
    }
  end

  private

  def h
    @view_context
  end

  def activity_icon(activity)
    case activity.action
    when 'order_placed' then 'shopping-cart'
    when 'review_posted' then 'star'
    when 'profile_updated' then 'user'
    else 'activity'
    end
  end

  def activity_text(activity)
    case activity.action
    when 'order_placed'
      "You placed order ##{activity.target_id}"
    when 'review_posted'
      "You reviewed #{activity.target.product.name}"
    when 'profile_updated'
      "You updated your profile"
    end
  end
end

# Controller
def dashboard
  @presenter = DashboardPresenter.new(current_user, view_context)
end

# View
<h1><%= @presenter.welcome_message %></h1>

<div class="stats">
  <% @presenter.stats.each do |key, value| %>
    <div class="stat">
      <span class="label"><%= key.to_s.humanize %></span>
      <span class="value"><%= value %></span>
    </div>
  <% end %>
</div>

Collection Presenter

# app/presenters/users_index_presenter.rb
class UsersIndexPresenter
  def initialize(users, view_context, filters: {})
    @users = users
    @view_context = view_context
    @filters = filters
  end

  def users
    @decorated_users ||= @users.map { |u| UserDecorator.new(u, h) }
  end

  def total_count
    @users.total_count
  end

  def pagination
    h.paginate(@users)
  end

  def active_filters
    @filters.select { |_, v| v.present? }
  end

  def filter_summary
    return "All users" if active_filters.empty?

    parts = []
    parts << "Role: #{@filters[:role]}" if @filters[:role]
    parts << "Status: #{@filters[:status]}" if @filters[:status]
    parts.join(', ')
  end

  def export_link
    h.link_to 'Export CSV', h.users_path(format: :csv, **@filters),
              class: 'btn btn-secondary'
  end

  private

  def h
    @view_context
  end
end

Repository Pattern

Basic Repository

# app/repositories/user_repository.rb
class UserRepository
  class << self
    def find(id)
      User.find(id)
    end

    def find_by_email(email)
      User.find_by(email: email)
    end

    def active_users
      User.where(active: true).order(created_at: :desc)
    end

    def search(query)
      User.where('name ILIKE ? OR email ILIKE ?', "%#{query}%", "%#{query}%")
    end

    def with_recent_orders(days: 30)
      User.joins(:orders)
          .where('orders.created_at > ?', days.days.ago)
          .distinct
    end

    def create(attributes)
      User.create(attributes)
    end

    def update(user, attributes)
      user.update(attributes)
    end

    def destroy(user)
      user.destroy
    end
  end
end

# Service using repository
class UserRegistrationService
  def initialize(repository: UserRepository)
    @repository = repository
  end

  def call(attributes)
    user = @repository.create(attributes)

    if user.persisted?
      SendWelcomeEmailJob.perform_later(user)
      Result.success(user)
    else
      Result.failure(user.errors)
    end
  end
end

Repository with Complex Queries

# app/repositories/order_repository.rb
class OrderRepository
  class << self
    def pending_orders
      Order.where(status: 'pending').order(created_at: :asc)
    end

    def overdue_orders(threshold: 3.days)
      Order.where(status: 'pending')
           .where('created_at < ?', threshold.ago)
    end

    def user_orders(user, status: nil)
      scope = user.orders

      scope = scope.where(status: status) if status.present?
      scope.order(created_at: :desc)
    end

    def revenue_by_month(year: Time.current.year)
      Order.where(status: 'completed')
           .where('EXTRACT(YEAR FROM created_at) = ?', year)
           .group("DATE_TRUNC('month', created_at)")
           .sum(:total)
    end

    def top_customers(limit: 10)
      User.joins(:orders)
          .where(orders: { status: 'completed' })
          .group('users.id')
          .select('users.*, SUM(orders.total) as total_spent')
          .order('total_spent DESC')
          .limit(limit)
    end
  end
end

PORO (Plain Old Ruby Object) Conventions

Value Objects

# app/models/money.rb
class Money
  include Comparable

  attr_reader :amount, :currency

  def initialize(amount, currency: 'USD')
    @amount = BigDecimal(amount.to_s)
    @currency = currency
  end

  def +(other)
    validate_currency!(other)
    Money.new(amount + other.amount, currency: currency)
  end

  def -(other)
    validate_currency!(other)
    Money.new(amount - other.amount, currency: currency)
  end

  def *(multiplier)
    Money.new(amount * multiplier, currency: currency)
  end

  def <=>(other)
    validate_currency!(other)
    amount <=> other.amount
  end

  def to_s
    format('%s%.2f', currency_symbol, amount)
  end

  def ==(other)
    amount == other.amount && currency == other.currency
  end

  private

  def validate_currency!(other)
    return if currency == other.currency

    raise ArgumentError, "Cannot operate on different currencies"
  end

  def currency_symbol
    case currency
    when 'USD' then '$'
    when 'EUR' then '€'
    when 'GBP' then '£'
    else currency
    end
  end
end

# Usage
price = Money.new(19.99)
tax = price * 0.08
total = price + tax # => $21.59

Data Transfer Objects (DTOs)

# app/models/user_dto.rb
class UserDTO
  attr_reader :id, :email, :full_name, :role

  def initialize(id:, email:, full_name:, role:)
    @id = id
    @email = email
    @full_name = full_name
    @role = role
  end

  def self.from_model(user)
    new(
      id: user.id,
      email: user.email,
      full_name: "#{user.first_name} #{user.last_name}",
      role: user.role
    )
  end

  def to_h
    {
      id: id,
      email: email,
      full_name: full_name,
      role: role
    }
  end
end

# Or using Ruby 3.2+ Data class
UserDTO = Data.define(:id, :email, :full_name, :role) do
  def self.from_model(user)
    new(
      id: user.id,
      email: user.email,
      full_name: "#{user.first_name} #{user.last_name}",
      role: user.role
    )
  end
end

Result Objects

# app/models/result.rb
class Result
  attr_reader :value, :error

  def initialize(success:, value: nil, error: nil)
    @success = success
    @value = value
    @error = error
  end

  def self.success(value = nil)
    new(success: true, value: value)
  end

  def self.failure(error)
    new(success: false, error: error)
  end

  def success?
    @success
  end

  def failure?
    !@success
  end

  def on_success
    yield value if success?
    self
  end

  def on_failure
    yield error if failure?
    self
  end
end

# Service using Result object
class CreateUserService
  def call(params)
    user = User.new(params)

    if user.save
      Result.success(user)
    else
      Result.failure(user.errors)
    end
  end
end

# Usage
result = CreateUserService.new.call(user_params)

result
  .on_success { |user| redirect_to user }
  .on_failure { |errors| render :new }

Policy Objects

# app/policies/post_visibility_policy.rb
class PostVisibilityPolicy
  def initialize(user, post)
    @user = user
    @post = post
  end

  def visible?
    return true if @post.published?
    return true if @user&.admin?
    return true if @post.user_id == @user&.id

    false
  end

  def editable?
    return true if @user&.admin?
    return true if @post.user_id == @user&.id

    false
  end
end

# Usage in controller
def show
  @post = Post.find(params[:id])
  policy = PostVisibilityPolicy.new(current_user, @post)

  unless policy.visible?
    redirect_to root_path, alert: 'Not authorized'
  end
end

Quick Reference

Before Writing Any Code

# Check existing patterns
ls app/services/
ls app/models/
grep -r 'class.*Service' app/ --include='*.rb' -l | head -10

# Check naming conventions
head -30 $(find app/services -name '*.rb' | head -1)

# Check dependencies
cat Gemfile | grep -v '^#' | grep -v '^$'

Common File Locations

app/models/           - ActiveRecord models
app/controllers/      - Controllers
app/services/         - Service objects
app/components/       - ViewComponents
app/queries/          - Query objects
app/forms/            - Form objects
app/presenters/       - Presenters
app/decorators/       - Decorators
app/serializers/      - API serializers
app/jobs/             - Background jobs
app/mailers/          - Action Mailers
app/channels/         - Action Cable channels
app/repositories/     - Repository pattern objects
app/policies/         - Policy objects (business rules)