ruby-guide

SKILL.md

Ruby Guide

Applies to: Ruby 3.2+, Gems, APIs, CLIs, Web Applications

Core Principles

  1. Least Surprise: Code should behave as readers expect; prefer clarity over cleverness
  2. Everything is an Object: Leverage Ruby's object model; primitives are objects with methods
  3. Convention Over Configuration: Follow established naming and structure conventions
  4. Duck Typing with Confidence: Rely on behavior, not class checks; validate at boundaries
  5. Blocks Everywhere: Use blocks for resource management, iteration, and DSLs

Guardrails

Version & Dependencies

  • Use Ruby 3.2+ with # frozen_string_literal: true in every .rb file
  • Manage dependencies with Bundler (Gemfile + Gemfile.lock)
  • Pin gem versions with pessimistic operator: gem "rails", "~> 7.1"
  • Run bundle audit before merging to check for vulnerable gems
  • Commit Gemfile.lock for applications; omit for gems
  • Specify required_ruby_version in .gemspec files

Code Style

  • Run rubocop before every commit (no exceptions)
  • snake_case for methods/variables/files, PascalCase for classes/modules, SCREAMING_SNAKE_CASE for constants
  • Predicate methods end with ?, dangerous methods end with !
  • Two-space indentation, no tabs
  • Prefer guard clauses over nested conditionals
# frozen_string_literal: true

# Bad: deeply nested
def process(user)
  if user
    if user.active?
      do_something(user) if user.verified?
    end
  end
end

# Good: guard clauses
def process(user)
  return unless user
  return unless user.active?
  return unless user.verified?

  do_something(user)
end

Blocks & Procs

  • Use {} for single-line blocks, do...end for multi-line
  • Prefer block_given? + yield over explicit &block parameter
  • Use lambdas for strict arity checking; procs for flexible arity
# Block for resource management
File.open("data.txt", "r") do |file|
  file.each_line { |line| process(line) }
end

# Lambda vs Proc
validator = ->(x) { x.positive? }  # strict arity, returns from lambda
transformer = proc { |x| x.to_s }  # flexible arity, returns from enclosing

# Point-free style
names = users.map(&:name)

Error Handling

  • Rescue specific exceptions, never bare rescue
  • Define custom errors inheriting from StandardError
  • Use ensure for cleanup (not rescue for flow control)
  • Provide #message with actionable information in custom errors
class PaymentError < StandardError; end
class InsufficientFundsError < PaymentError; end

def charge(account, amount)
  raise InsufficientFundsError, "account #{account.id} needs #{amount}" if account.balance < amount

  account.debit(amount)
rescue Stripe::CardError => e
  Rails.logger.error("Payment failed for account=#{account.id}: #{e.message}")
  raise PaymentError, "card declined: #{e.message}"
end

Metaprogramming

  • Use define_method sparingly; prefer explicit method definitions
  • Always pair method_missing with respond_to_missing?
  • Prefer Module#prepend over alias_method chains
  • Avoid eval with string arguments (use block form of class_eval)
  • Never use metaprogramming in hot paths
class DynamicFinder
  def method_missing(method_name, *args)
    if method_name.to_s.start_with?("find_by_")
      attribute = method_name.to_s.delete_prefix("find_by_")
      find_by_attribute(attribute, args.first)
    else
      super
    end
  end

  def respond_to_missing?(method_name, include_private = false)
    method_name.to_s.start_with?("find_by_") || super
  end
end

Project Structure

Gem Layout

mygem/
├── lib/
│   ├── mygem.rb              # Entry point, require dependencies
│   └── mygem/
│       ├── version.rb        # VERSION constant
│       ├── client.rb         # Core logic
│       └── errors.rb         # Custom error classes
├── spec/
│   ├── spec_helper.rb
│   └── mygem/
│       └── client_spec.rb
├── Gemfile
├── mygem.gemspec
└── Rakefile

Application Layout

myapp/
├── app/
│   ├── models/               # Domain objects
│   ├── services/             # Business logic (POROs)
│   └── validators/           # Input validation
├── config/
├── db/migrate/               # Database migrations
├── lib/tasks/                # Rake tasks
├── spec/
│   ├── spec_helper.rb
│   ├── models/
│   └── services/
├── Gemfile
├── Gemfile.lock
└── Rakefile
  • Service objects for business logic (single public method: #call)
  • One class per file, file name matches class name in snake_case
  • No global mutable state; use dependency injection or configuration objects

Key Patterns

Enumerable & Lazy Evaluation

# Chain Enumerable methods over manual loops
users.select(&:active?).map(&:email).sort

# Lazy evaluation for large collections
File.open("huge.log").each_line.lazy
  .select { |line| line.include?("ERROR") }
  .map { |line| parse_error(line) }
  .first(10)

# each_with_object for building hashes
totals = orders.each_with_object(Hash.new(0)) do |order, sums|
  sums[order.category] += order.amount
end

Pattern Matching (Ruby 3.x)

case response
in { status: 200, body: { data: Array => items } }
  process_items(items)
in { status: 404 }
  handle_not_found
in { status: (500..) }
  handle_server_error
end

# Pin operator for variable binding
expected_id = 42
case record
in { id: ^expected_id, name: String => name }
  puts "Found: #{name}"
end

Frozen String Literals & Immutability

# frozen_string_literal: true

name = "hello"
name << " world"  # => FrozenError

# Use +"" or .dup when mutation is needed
mutable = +"hello"
mutable << " world"  # => "hello world"

VALID_STATUSES = %w[pending active suspended].freeze
CONFIG_DEFAULTS = { timeout: 30, retries: 3 }.freeze

Ractor (Ruby 3.x Parallelism)

workers = 4.times.map do
  Ractor.new do
    loop do
      task = Ractor.receive
      Ractor.yield expensive_computation(task)
    end
  end
end

tasks.each_with_index { |task, i| workers[i % workers.size].send(task) }
results = workers.map(&:take)

Testing

RSpec

RSpec.describe PaymentService do
  subject(:service) { described_class.new(gateway: gateway) }

  let(:gateway) { instance_double(PaymentGateway) }
  let(:account) { build(:account, balance: 100.0) }

  describe "#charge" do
    context "when account has sufficient funds" do
      before { allow(gateway).to receive(:process).and_return(true) }

      it "debits the account" do
        expect { service.charge(account, 50.0) }
          .to change { account.balance }.from(100.0).to(50.0)
      end
    end

    context "when account has insufficient funds" do
      it "raises InsufficientFundsError" do
        expect { service.charge(account, 200.0) }
          .to raise_error(InsufficientFundsError, /needs 200/)
      end
    end
  end
end

RSpec Conventions

  • describe for class/method, context for scenario (prefix with "when" or "with")
  • let for lazy data, let! only when eager evaluation is needed
  • subject for the object under test, described_class over hardcoded class names
  • Prefer instance_double over generic double for type safety
  • Use shared examples for behavior shared across classes

FactoryBot

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    name { "Test User" }
    active { true }
    trait(:admin) { role { "admin" } }
    trait(:inactive) { active { false } }
  end
end

Testing Standards

  • Test files mirror source: lib/foo/bar.rb -> spec/foo/bar_spec.rb
  • Coverage target: >80% for business logic, >60% overall (use simplecov)
  • All bug fixes include a regression test
  • Test edge cases: nil, empty string, empty array, boundary values
  • Use webmock or vcr for HTTP stubbing (no real network in unit tests)

Tooling

Essential Commands

bundle install              # Install dependencies
bundle exec rspec           # Run tests through Bundler
bundle exec rubocop         # Lint
bundle exec rubocop -a      # Auto-fix safe cops
bundle exec rake            # Default task (usually tests)
bundle audit                # Check for vulnerable gems
bundle outdated             # Show outdated gems

RuboCop Configuration

# .rubocop.yml
require:
  - rubocop-rspec
  - rubocop-performance
AllCops:
  NewCops: enable
  TargetRubyVersion: 3.2
Style/FrozenStringLiteralComment:
  Enabled: true
  EnforcedStyle: always
Metrics/MethodLength:
  Max: 20
Metrics/CyclomaticComplexity:
  Max: 10

Bundler Best Practices

# Gemfile
source "https://rubygems.org"
ruby "~> 3.2"

gem "dry-struct", "~> 1.6"
gem "zeitwerk", "~> 2.6"

group :development, :test do
  gem "rspec", "~> 3.13"
  gem "rubocop", "~> 1.60", require: false
  gem "factory_bot", "~> 6.4"
end

group :test do
  gem "simplecov", require: false
  gem "webmock", "~> 3.19"
end

Advanced Topics

For detailed patterns and examples, see:

External References

Weekly Installs
5
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
Mar 1, 2026
Installed on
gemini-cli5
opencode5
codebuddy5
github-copilot5
codex5
kimi-cli5