
Ruby Refactor
- 237 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ruby-refactor: A skill for development. This provides functionality for development workflows.
Key points
- ruby-refactor
Ruby Refactor by the numbers
- 237 all-time installs (skills.sh)
- +7 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,595 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ruby-refactorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 237 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ruby-refactor for development tasks?
Use ruby-refactor for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ruby-refactor.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ruby-refactor for development tasks, or when ruby-refactor: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ruby-refactor: ruby-refactor.
Files
Community Ruby Refactoring Best Practices
Comprehensive refactoring guide for Ruby applications, maintained by the community. Contains 45 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
When to Apply
Reference these guidelines when:
- Refactoring Ruby code to reduce complexity and improve design
- Extracting methods, classes, or value objects from large units
- Simplifying complex conditionals and deep nesting
- Reducing coupling between classes and modules
- Adopting idiomatic Ruby patterns and modern Ruby 3.x features
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Structure & Decomposition | CRITICAL | struct- |
| 2 | Conditional Simplification | CRITICAL | cond- |
| 3 | Coupling & Dependencies | HIGH | couple- |
| 4 | Ruby Idioms | HIGH | idiom- |
| 5 | Data & Value Objects | MEDIUM-HIGH | data- |
| 6 | Design Patterns | MEDIUM | pattern- |
| 7 | Modern Ruby 3.x | MEDIUM | modern- |
| 8 | Naming & Readability | LOW-MEDIUM | name- |
Quick Reference
1. Structure & Decomposition (CRITICAL)
- `struct-extract-method` - Extract Long Methods into Focused Units
- `struct-extract-class` - Extract Class for Single Responsibility
- `struct-parameter-object` - Introduce Parameter Object for Long Signatures
- `struct-compose-method` - Compose Methods at Single Abstraction Level
- `struct-replace-method-with-object` - Replace Complex Method with Method Object
- `struct-single-responsibility` - One Reason to Change per Class
- `struct-flatten-deep-nesting` - Flatten Deep Nesting with Early Extraction
2. Conditional Simplification (CRITICAL)
- `cond-guard-clauses` - Replace Nested Conditionals with Guard Clauses
- `cond-decompose-conditional` - Extract Complex Booleans into Named Predicates
- `cond-replace-with-polymorphism` - Replace case/when with Polymorphism
- `cond-null-object` - Replace nil Checks with Null Object
- `cond-pattern-matching` - Use Pattern Matching for Structural Conditions
- `cond-consolidate-duplicates` - Consolidate Duplicate Conditional Fragments
3. Coupling & Dependencies (HIGH)
- `couple-law-of-demeter` - Enforce Law of Demeter with Delegation
- `couple-feature-envy` - Move Method to Resolve Feature Envy
- `couple-dependency-injection` - Inject Dependencies via Constructor Defaults
- `couple-composition-over-inheritance` - Replace Mixin with Composed Object
- `couple-tell-dont-ask` - Tell Objects What to Do, Don't Query Their State
- `couple-avoid-class-methods-domain` - Avoid Class Methods in Domain Logic
4. Ruby Idioms (HIGH)
- `idiom-prefer-enumerable` - Use map/select/reject Over each with Accumulator
- `idiom-keyword-arguments` - Use Keyword Arguments for Clarity
- `idiom-duck-typing` - Use respond_to? Over is_a? for Type Checking
- `idiom-predicate-methods` - Name Boolean Methods with ? Suffix
- `idiom-respond-to-missing` - Always Pair method_missing with respond_to_missing?
- `idiom-block-yield` - Use yield Over block.call for Simple Blocks
- `idiom-implicit-return` - Omit Explicit return for Last Expression
5. Data & Value Objects (MEDIUM-HIGH)
- `data-value-object` - Replace Primitive Obsession with Value Objects
- `data-define-immutable` - Use Data.define for Immutable Value Objects
- `data-encapsulate-collection` - Encapsulate Collections Behind Domain Methods
- `data-replace-data-clump` - Replace Data Clumps with Grouped Objects
- `data-separate-query-command` - Separate Query Methods from Command Methods
6. Design Patterns (MEDIUM)
- `pattern-strategy` - Extract Algorithm Variations into Strategy Objects
- `pattern-factory` - Use Factory Method to Abstract Object Creation
- `pattern-template-method` - Define Algorithm Skeleton with Template Method
- `pattern-decorator` - Wrap Objects with Decorator for Added Behavior
- `pattern-null-object-protocol` - Implement Null Object with Full Protocol
7. Modern Ruby 3.x (MEDIUM)
- `modern-pattern-matching` - Use case/in for Structural Pattern Matching
- `modern-deconstruct-keys` - Implement deconstruct_keys for Custom Pattern Matching
- `modern-endless-methods` - Use Endless Method Definition for Simple Methods
- `modern-hash-pattern-guard` - Use Pattern Matching with Guard Clauses
- `modern-rightward-assignment` - Use Rightward Assignment for Pipeline Expressions
8. Naming & Readability (LOW-MEDIUM)
- `name-intention-revealing` - Use Intention-Revealing Names
- `name-consistent-vocabulary` - Use One Word per Concept Across Codebase
- `name-avoid-abbreviations` - Spell Out Names Except Universal Abbreviations
- `name-rename-to-remove-comments` - Rename to Eliminate Need for Comments
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Ruby Refactoring
Version 0.1.0 Community February 2026
Note:
This document is for agents and LLMs to follow when refactoring Ruby codebases.
Humans may also find it useful, but guidance here is optimized for automation
and consistency by AI-assisted workflows.
---
Abstract
Comprehensive refactoring guide for Ruby applications, designed for AI agents and LLMs. Contains 45 rules across 8 categories, prioritized by impact from critical (structure decomposition, conditional simplification) to incremental (naming and readability). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.
---
Table of Contents
1. Structure & Decomposition — CRITICAL
- 1.1 Compose Methods at Single Abstraction Level — CRITICAL (reduces mixed abstraction levels from N to 1 per method)
- 1.2 Extract Class for Single Responsibility — CRITICAL (reduces class coupling by 50-80%)
- 1.3 Extract Long Methods into Focused Units — CRITICAL (reduces cognitive load by 3-5x)
- 1.4 Flatten Deep Nesting with Early Extraction — HIGH (reduces cyclomatic complexity by 40-60%)
- 1.5 Introduce Parameter Object for Long Signatures — CRITICAL (eliminates parameter coupling across call chain)
- 1.6 One Reason to Change per Class — HIGH (reduces change cascade across codebase)
- 1.7 Replace Complex Method with Method Object — HIGH (enables decomposition of tangled logic)
2. Conditional Simplification — CRITICAL
- 2.1 Consolidate Duplicate Conditional Fragments — HIGH (reduces duplication by 30-50%)
- 2.2 Extract Complex Booleans into Named Predicates — CRITICAL (reduces boolean complexity from N clauses to 1 named predicate)
- 2.3 Replace case/when with Polymorphism — CRITICAL (eliminates shotgun surgery across N branches)
- 2.4 Replace Nested Conditionals with Guard Clauses — CRITICAL (reduces nesting depth by 2-4 levels)
- 2.5 Replace nil Checks with Null Object — HIGH (eliminates N nil-guard conditionals per call site)
- 2.6 Use Pattern Matching for Structural Conditions — HIGH (reduces 3-5 nested nil checks to 1 expression)
3. Coupling & Dependencies — HIGH
- 3.1 Avoid Class Methods in Domain Logic — MEDIUM-HIGH (reduces test setup from global stubs to 1 constructor injection)
- 3.2 Enforce Law of Demeter with Delegation — HIGH (reduces coupling to 1 dependency per call)
- 3.3 Inject Dependencies via Constructor Defaults — HIGH (enables test isolation without monkey-patching)
- 3.4 Move Method to Resolve Feature Envy — HIGH (reduces cross-class coupling from N accessors to 1 method call)
- 3.5 Replace Mixin with Composed Object — HIGH (eliminates hidden method conflicts and unclear precedence)
- 3.6 Tell Objects What to Do, Don't Query Their State — MEDIUM-HIGH (reduces caller coupling from N state queries to 1 command)
4. Ruby Idioms — HIGH
- 4.1 Always Pair method_missing with respond_to_missing? — MEDIUM-HIGH (prevents broken respond_to? and method introspection)
- 4.2 Name Boolean Methods with ? Suffix — MEDIUM-HIGH (eliminates N return-type lookups per code review)
- 4.3 Omit Explicit return for Last Expression — MEDIUM-HIGH (follows Ruby convention, reduces noise)
- 4.4 Use Keyword Arguments for Clarity — HIGH (self-documents call sites, prevents argument order bugs)
- 4.5 Use map/select/reject Over each with Accumulator — HIGH (eliminates mutable accumulator pattern)
- 4.6 Use respond_to? Over is_a? for Type Checking — HIGH (enables polymorphism without inheritance hierarchy)
- 4.7 Use yield Over block.call for Simple Blocks — MEDIUM-HIGH (avoids Proc allocation, 2-5x faster)
5. Data & Value Objects — MEDIUM-HIGH
- 5.1 Encapsulate Collections Behind Domain Methods — MEDIUM-HIGH (prevents external mutation and scatters)
- 5.2 Replace Data Clumps with Grouped Objects — MEDIUM (eliminates parameter coupling across 3+ methods)
- 5.3 Replace Primitive Obsession with Value Objects — MEDIUM-HIGH (reduces scattered validation from N call sites to 1 constructor)
- 5.4 Separate Query Methods from Command Methods — MEDIUM (enables safe caching and idempotent reads)
- 5.5 Use Data.define for Immutable Value Objects — MEDIUM-HIGH (immutable by default, 10-50x faster construction than OpenStruct)
6. Design Patterns — MEDIUM
- 6.1 Define Algorithm Skeleton with Template Method — MEDIUM (eliminates 60-80% duplicated algorithm code across N subclasses)
- 6.2 Extract Algorithm Variations into Strategy Objects — MEDIUM (reduces case/when branches from N to 0 in caller)
- 6.3 Implement Null Object with Full Protocol — MEDIUM (eliminates conditional nil checking across entire call chain)
- 6.4 Use Factory Method to Abstract Object Creation — MEDIUM (decouples creation from usage, enables extension)
- 6.5 Wrap Objects with Decorator for Added Behavior — MEDIUM (reduces subclass explosion from 2^N combinations to N decorators)
7. Modern Ruby 3.x — MEDIUM
- 7.1 Implement deconstruct_keys for Custom Pattern Matching — MEDIUM (enables pattern matching on domain objects)
- 7.2 Use case/in for Structural Pattern Matching — MEDIUM (reduces 3-5 nested hash checks to 1 destructuring expression)
- 7.3 Use Endless Method Definition for Simple Methods — MEDIUM (reduces noise for one-liner methods)
- 7.4 Use Pattern Matching with Guard Clauses — MEDIUM (reduces nested if/case from 3-4 levels to 1 flat match)
- 7.5 Use Rightward Assignment for Pipeline Expressions — LOW-MEDIUM (reduces left-side noise in multi-step pipelines by 30-50%)
8. Naming & Readability — LOW-MEDIUM
- 8.1 Rename to Eliminate Need for Comments — LOW-MEDIUM (eliminates 1 comment per renamed method or variable)
- 8.2 Spell Out Names Except Universal Abbreviations — LOW-MEDIUM (prevents ambiguity and miscommunication)
- 8.3 Use Intention-Revealing Names — LOW-MEDIUM (eliminates need for explanatory comments)
- 8.4 Use One Word per Concept Across Codebase — LOW-MEDIUM (prevents confusion between synonyms)
---
References
Rule Title Here
Brief explanation (1-3 sentences) of WHY this matters. Focus on design implications and cascade effects.
Incorrect (description of the problem/cost):
# Comment on problematic line explaining consequence
bad_example_code_hereCorrect (description of the benefit/solution):
good_example_code_hereReference: Reference Title
{
"version": "1.0.6",
"organization": "Community",
"technology": "Ruby",
"date": "February 2026",
"abstract": "Comprehensive refactoring guide for Ruby applications, designed for AI agents and LLMs. Contains 45 rules across 8 categories, prioritized by impact from critical (structure decomposition, conditional simplification) to incremental (naming and readability). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.",
"references": [
"https://github.com/rubocop/ruby-style-guide",
"https://ruby-style-guide.shopify.dev/",
"https://www.poodr.com/",
"https://thoughtbot.com/blog/sandi-metz-rules-for-developers",
"https://thoughtbot.com/ruby-science/",
"https://martinfowler.com/books/refactoringRubyEd.html",
"https://refactoring.guru/refactoring/catalog",
"https://docs.ruby-lang.org/en/3.3/syntax/pattern_matching_rdoc.html",
"https://github.com/github/rubocop-github",
"https://github.com/airbnb/ruby"
]
}
Ruby Refactor Best Practices
Refactoring guidelines for Ruby applications. Contains 45 rules across 8 categories for improving code structure, design, and maintainability.
Overview/Structure
ruby-refactor/
├── SKILL.md # Entry point with quick reference
├── AGENTS.md # Compiled comprehensive guide
├── metadata.json # Version, organization, references
├── README.md # This file
├── references/
│ ├── _sections.md # Category definitions
│ ├── struct-*.md # Structure & decomposition rules
│ ├── cond-*.md # Conditional simplification rules
│ ├── couple-*.md # Coupling & dependencies rules
│ ├── idiom-*.md # Ruby idioms rules
│ ├── data-*.md # Data & value objects rules
│ ├── pattern-*.md # Design patterns rules
│ ├── modern-*.md # Modern Ruby 3.x rules
│ └── name-*.md # Naming & readability rules
└── assets/
└── templates/
└── _template.md # Rule template for extensionsGetting Started
Installation
# Clone or copy this skill to your project
cp -r ruby-refactor/ .claude/skills/ruby-refactor/
# Install dependencies (if using validation scripts)
pnpm installBuild
# Build AGENTS.md from individual rules
pnpm build
# Or directly:
node scripts/build-agents-md.js .claude/skills/ruby-refactorValidate
# Validate skill structure and content
pnpm validate
# Or directly:
node scripts/validate-skill.js .claude/skills/ruby-refactorCreating a New Rule
1. Choose the appropriate category based on refactoring impact 2. Create a new file in references/ following the naming convention 3. Use the template structure for consistency 4. Run validation to ensure compliance
Prefix Reference
| Category | Prefix | Impact |
|---|---|---|
| Structure & Decomposition | struct- | CRITICAL |
| Conditional Simplification | cond- | CRITICAL |
| Coupling & Dependencies | couple- | HIGH |
| Ruby Idioms | idiom- | HIGH |
| Data & Value Objects | data- | MEDIUM-HIGH |
| Design Patterns | pattern- | MEDIUM |
| Modern Ruby 3.x | modern- | MEDIUM |
| Naming & Readability | name- | LOW-MEDIUM |
Rule File Structure
---
title: Rule Title Here
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW
impactDescription: Quantified impact (e.g., "reduces coupling by 50%")
tags: prefix, technique, related-concepts
---
## Rule Title Here
Brief explanation (1-3 sentences) of why this matters.
**Incorrect (description of what's wrong):**
\`\`\`ruby
# Bad example with comments explaining cost
\`\`\`
**Correct (description of what's right):**
\`\`\`ruby
# Good example with comments explaining benefit
\`\`\`
Reference: [Source](url)File Naming Convention
Files follow the pattern: {prefix}-{description}.md
prefix: Category identifier (3-8 chars)description: Kebab-case description of the rule
Examples:
struct-extract-method.mdcond-guard-clauses.md
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Root-cause smells that block all downstream refactoring |
| HIGH | Significant design improvement, reduces coupling or complexity |
| MEDIUM-HIGH | Notable improvement in specific design areas |
| MEDIUM | Measurable improvement in readability or extensibility |
| LOW-MEDIUM | Incremental improvement in code clarity |
| LOW | Polish-level improvements |
Scripts
| Script | Description |
|---|---|
build-agents-md.js | Compiles all rules into AGENTS.md |
validate-skill.js | Validates structure and content |
Contributing
1. Read existing rules to understand the style 2. Create your rule using the template 3. Run validation before submitting 4. Ensure all code examples are syntactically correct 5. Include authoritative references
Acknowledgments
This skill synthesizes best practices from:
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Structure & Decomposition (struct)
Impact: CRITICAL Description: Long methods and large classes are the root cause of most code smells. Decomposing structure into small, focused units unlocks all downstream refactorings and improves testability.
2. Conditional Simplification (cond)
Impact: CRITICAL Description: Complex conditionals are the #2 source of bugs and coupling. Guard clauses, polymorphism, and pattern matching eliminate deep nesting and make intent explicit.
3. Coupling & Dependencies (couple)
Impact: HIGH Description: Tight coupling blocks all refactoring. Law of Demeter violations, feature envy, and rigid inheritance hierarchies make changes cascade unpredictably across the codebase.
4. Ruby Idioms (idiom)
Impact: HIGH Description: Non-idiomatic Ruby resists automated refactoring tools and confuses developers. Duck typing, Enumerable methods, and keyword arguments make code intent-revealing and tool-friendly.
5. Data & Value Objects (data)
Impact: MEDIUM-HIGH Description: Primitive obsession and data clumps scatter implicit dependencies across the codebase. Value objects and Data.define centralize domain concepts and enforce invariants.
6. Design Patterns (pattern)
Impact: MEDIUM Description: Strategy, Factory, Template Method, and Decorator resolve recurring structural problems by replacing ad-hoc conditionals with composable, extensible objects.
7. Modern Ruby 3.x (modern)
Impact: MEDIUM Description: Pattern matching, Data.define, and endless methods provide cleaner, more expressive alternatives to traditional conditional and value object patterns.
8. Naming & Readability (name)
Impact: LOW-MEDIUM Description: Clear naming is the cheapest refactoring with the highest ROI for code comprehension. Intention-revealing names eliminate the need for explanatory comments.
Consolidate Duplicate Conditional Fragments
When every branch of a conditional repeats the same setup or teardown code, the duplication obscures what actually differs between the branches. Changes to the shared logic must be applied to every branch, and missing one creates subtle inconsistencies. Extracting common fragments before and after the conditional makes the unique behavior in each branch visually obvious and reduces the total line count.
Incorrect (duplicated setup and teardown in each branch):
class ReportExporter
def export(records, format:)
if format == :csv
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
filename = "report_#{timestamp}"
log_export_started(filename, format)
content = generate_csv_content(records)
write_to_storage(filename, content, extension: "csv")
log_export_completed(filename, format) # duplicated in both branches
notify_requester(filename) # duplicated in both branches
else
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
filename = "report_#{timestamp}"
log_export_started(filename, format)
content = generate_json_content(records)
write_to_storage(filename, content, extension: "json")
log_export_completed(filename, format) # same as above
notify_requester(filename) # same as above
end
end
endCorrect (shared code extracted, only the difference remains in the conditional):
class ReportExporter
def export(records, format:)
timestamp = Time.current.strftime("%Y%m%d_%H%M%S")
filename = "report_#{timestamp}"
log_export_started(filename, format)
content, extension = case format # only the format-specific logic varies
when :csv then [generate_csv_content(records), "csv"]
when :json then [generate_json_content(records), "json"]
else raise ArgumentError, "unsupported format: #{format}"
end
write_to_storage(filename, content, extension: extension)
log_export_completed(filename, format)
notify_requester(filename)
end
endExtract Complex Booleans into Named Predicates
Compound boolean expressions encode business rules that are invisible to future readers. When the same multi-clause condition appears in two places, it will inevitably diverge. Extracting predicates names the business concept once, makes the condition testable in isolation, and turns the calling code into a readable sentence.
Incorrect (inline compound boolean):
class PurchaseService
def attempt_purchase(user, item)
if user.age >= 18 && user.verified? && !user.suspended? && user.subscription.active? # what business rule is this?
charge_user(user, item)
else
deny_purchase(user, item)
end
end
def show_premium_catalog(user)
if user.age >= 18 && user.verified? && !user.suspended? && user.subscription.active? # duplicated, will diverge
render_catalog(user)
end
end
endCorrect (named predicate methods):
class PurchaseService
def attempt_purchase(user, item)
if eligible_for_purchase?(user) # reads as a business rule
charge_user(user, item)
else
deny_purchase(user, item)
end
end
def show_premium_catalog(user)
render_catalog(user) if eligible_for_purchase?(user)
end
private
def eligible_for_purchase?(user)
of_legal_age?(user) && verified_and_active?(user)
end
def of_legal_age?(user)
user.age >= 18
end
def verified_and_active?(user)
user.verified? && !user.suspended? && user.subscription.active?
end
endReplace Nested Conditionals with Guard Clauses
Deeply nested conditionals force readers to hold multiple branch contexts in working memory simultaneously. Each nesting level doubles the number of mental paths through the method. Guard clauses flatten the structure by handling exceptional cases first, leaving the happy path at the bottom with zero indentation.
Incorrect (deeply nested validation):
class PaymentAuthorizer
def authorize(payment)
if payment.amount > 0
if payment.card.present?
if payment.card.balance >= payment.amount
if !payment.flagged_for_fraud?
payment.charge! # happy path buried under 4 levels of nesting
{ success: true, transaction_id: payment.transaction_id }
else
{ success: false, error: "payment flagged for fraud review" }
end
else
{ success: false, error: "insufficient balance" }
end
else
{ success: false, error: "no card on file" }
end
else
{ success: false, error: "invalid payment amount" }
end
end
endCorrect (flat guard clauses with early returns):
class PaymentAuthorizer
def authorize(payment)
return { success: false, error: "invalid payment amount" } unless payment.amount > 0
return { success: false, error: "no card on file" } unless payment.card.present?
return { success: false, error: "insufficient balance" } unless payment.card.balance >= payment.amount
return { success: false, error: "payment flagged for fraud review" } if payment.flagged_for_fraud?
payment.charge! # happy path at natural reading level
{ success: true, transaction_id: payment.transaction_id }
end
endReplace nil Checks with Null Object
Scattered nil checks for optional associations create a shotgun of defensive conditionals throughout the codebase. Every caller must remember to guard against nil, and forgetting one produces a NoMethodError in production. A Null Object that responds to the same interface as the real object removes all guards at once, leveraging Ruby's duck typing to make the absence of a value behave like a sensible default.
Incorrect (nil guards scattered across call sites):
class AccountDashboard
def display_plan(user)
if user.subscription
plan_name = user.subscription.plan
else
plan_name = "Free"
end
if user.subscription&.premium?
show_premium_badge(user)
end
remaining = if user.subscription
user.subscription.days_remaining # every call site repeats the nil guard
else
0
end
render_dashboard(plan_name: plan_name, days_remaining: remaining)
end
endCorrect (Null Object with matching interface):
class NullSubscription
def plan
"Free"
end
def premium?
false
end
def days_remaining
0
end
def active?
false
end
end
class User
def subscription
super || NullSubscription.new # single nil guard replaces all downstream checks
end
end
class AccountDashboard
def display_plan(user)
plan_name = user.subscription.plan
show_premium_badge(user) if user.subscription.premium?
remaining = user.subscription.days_remaining # no nil checks, same interface
render_dashboard(plan_name: plan_name, days_remaining: remaining)
end
endSee also: `pattern-null-object-protocol` for implementing the full protocol with multiple methods.
Use Pattern Matching for Structural Conditions
Deeply nested hash access with manual nil checks is fragile and hard to read. Each level of response[:key] && response[:key][:nested] adds a potential failure point and obscures the structure being validated. Ruby 3.x pattern matching (case/in) declaratively describes the expected shape and destructures values in a single expression, making structural expectations explicit and self-documenting.
Incorrect (nested hash checks with manual nil guards):
class PaymentResponseParser
def extract_transaction(response)
if response[:data]
if response[:data][:transaction]
txn = response[:data][:transaction]
if txn[:id] && txn[:status] == "completed" && txn[:amount]
if txn[:amount][:value] && txn[:amount][:currency] # 4 levels deep, easy to miss a nil check
build_record(
id: txn[:id],
value: txn[:amount][:value],
currency: txn[:amount][:currency]
)
else
handle_malformed_response(response)
end
else
handle_incomplete_transaction(response)
end
else
handle_missing_transaction(response)
end
else
handle_empty_response(response)
end
end
endCorrect (pattern matching with destructuring):
class PaymentResponseParser
def extract_transaction(response)
case response
in { data: { transaction: { id:, status: "completed",
amount: { value:, currency: } } } } # declares expected shape in one expression
build_record(id: id, value: value, currency: currency)
in { data: { transaction: { id:, status: } } }
handle_incomplete_transaction(response)
in { data: { transaction: nil } }
handle_missing_transaction(response)
else
handle_empty_response(response)
end
end
endSee also: `modern-pattern-matching` for additional pattern matching syntax and features.
Replace case/when with Polymorphism
Every case/when on a type field is a magnet for shotgun surgery: adding a new type requires editing every switch site in the codebase. Polymorphism moves each branch into its own class, so adding a new type means adding a new file, not modifying existing code. This satisfies the Open/Closed Principle and eliminates an entire category of missed-branch bugs.
Incorrect (case/when on type):
class NotificationService
def deliver(notification)
case notification.type
when :email
validate_email(notification.recipient)
EmailClient.send(
to: notification.recipient,
subject: notification.subject,
body: notification.body
)
when :sms
validate_phone(notification.recipient)
SmsGateway.send(
phone: notification.recipient,
message: notification.body
)
when :push
validate_device_token(notification.recipient)
PushService.send(
token: notification.recipient,
title: notification.subject,
payload: notification.body
)
else
raise ArgumentError, "unknown notification type: #{notification.type}" # adding a type means editing this method
end
end
endCorrect (polymorphic notifier classes with registry):
class BaseNotifier
def deliver(notification)
validate(notification.recipient)
send_message(notification)
end
private
def validate(recipient)
raise NotImplementedError
end
def send_message(notification)
raise NotImplementedError
end
end
class EmailNotifier < BaseNotifier
private
def validate(recipient)
validate_email(recipient)
end
def send_message(notification)
EmailClient.send(
to: notification.recipient,
subject: notification.subject,
body: notification.body
)
end
end
class SmsNotifier < BaseNotifier
private
def validate(recipient) = validate_phone(recipient)
def send_message(notification)
SmsGateway.send(phone: notification.recipient, message: notification.body)
end
end
# PushNotifier follows the same pattern...
class NotificationService
NOTIFIERS = {
email: EmailNotifier.new,
sms: SmsNotifier.new
}.freeze # adding a type means adding a class and one registry entry
def deliver(notification)
notifier = NOTIFIERS.fetch(notification.type) do
raise ArgumentError, "unknown notification type: #{notification.type}"
end
notifier.deliver(notification)
end
endAvoid Class Methods in Domain Logic
Class methods are global entry points that cannot be injected, subclassed cleanly, or mocked without stubbing the class itself. This makes tests brittle and prevents polymorphic dispatch. Converting to an instance method behind a conventional #call interface lets callers inject, decorate, and substitute the object freely.
Incorrect (class method locks callers to a single global implementation):
class UserImporter
def self.import(csv_data)
# Cannot inject a different parser or notifier — stubbing requires global patch
rows = CSV.parse(csv_data, headers: true)
rows.each do |row|
user = User.create!(name: row["name"], email: row["email"])
AdminMailer.notify_new_user(user)
end
end
end
# Caller
UserImporter.import(csv_data)Correct (instance-based with injectable collaborators):
class UserImporter
def initialize(csv_data, parser: CSV, notifier: AdminMailer)
@csv_data = csv_data
@parser = parser
@notifier = notifier
end
# Instance method — injectable, decoratable, polymorphic
def call
rows = @parser.parse(@csv_data, headers: true)
rows.each do |row|
user = User.create!(name: row["name"], email: row["email"])
@notifier.notify_new_user(user)
end
end
end
# Caller — same brevity, full flexibility
UserImporter.new(csv_data).call
# Test — no global stubs
fake_notifier = instance_double(AdminMailer)
allow(fake_notifier).to receive(:notify_new_user)
UserImporter.new(csv_data, notifier: fake_notifier).callReplace Mixin with Composed Object
Including multiple modules flattens their methods into a single namespace where conflicts are silent and resolution depends on ancestors order. As the mixin count grows, the precedence chain becomes unpredictable and debugging requires tracing through ancestors. Composition makes each collaborator explicit with its own interface and no name collisions.
Incorrect (multiple includes with hidden conflict):
module Searchable
def search(query)
# Full-text search across all fields
records.select { |r| r.values.any? { |v| v.to_s.include?(query) } }
end
end
module Filterable
def search(query)
# Filters by exact match — silently overrides Searchable#search
records.select { |r| r[:name] == query }
end
end
class ProductCatalog
include Searchable
include Filterable # ancestors: Filterable wins — Searchable#search is dead code
attr_reader :records
def initialize(records)
@records = records
end
endCorrect (composed objects with explicit interfaces):
class SearchEngine
def initialize(records)
@records = records
end
def search(query)
@records.select { |r| r.values.any? { |v| v.to_s.include?(query) } }
end
end
class Filter
def initialize(records)
@records = records
end
def search(query)
@records.select { |r| r[:name] == query }
end
end
class ProductCatalog
attr_reader :records
def initialize(records, search_engine: SearchEngine.new(records), filter: Filter.new(records))
@records = records
@search_engine = search_engine
@filter = filter
end
# No conflict — each collaborator has its own object and name
def full_text_search(query) = @search_engine.search(query)
def exact_filter(query) = @filter.search(query)
endInject Dependencies via Constructor Defaults
Hard-coded class references inside methods make it impossible to substitute collaborators in tests without monkey-patching or stubbing globals. Injecting dependencies through the constructor with sensible defaults preserves the production path while giving tests a clean seam.
Incorrect (hard-coded dependency buried inside the method):
class WeatherService
def forecast(city)
# Locked to HTTPClient — tests must hit the network or stub a global
client = HTTPClient.new
response = client.get("https://api.weather.example.com/v1/forecast?city=#{city}")
JSON.parse(response.body)
end
endCorrect (inject via constructor with production default):
class WeatherService
def initialize(client: HTTPClient.new)
# Default preserves production behavior; tests inject a fake
@client = client
end
def forecast(city)
response = @client.get("https://api.weather.example.com/v1/forecast?city=#{city}")
JSON.parse(response.body)
end
end
# Test usage — no monkey-patching, no network
fake_client = instance_double(HTTPClient)
allow(fake_client).to receive(:get).and_return(
OpenStruct.new(body: '{"celsius": 22, "condition": "sunny"}')
)
service = WeatherService.new(client: fake_client)
result = service.forecast("London")
expect(result["celsius"]).to eq(22)Move Method to Resolve Feature Envy
When a method reaches into another object for most of its data, the logic belongs on that object. Feature envy scatters related calculations across classes, so a change to the data structure forces edits in every envious caller. Moving the method next to the data it uses eliminates this coupling.
Incorrect (OrderPrinter reaches into Order for every value):
class OrderPrinter
def format_total(order)
# Every line pulls data from order — this method envies Order
subtotal = order.items.sum { |item| item.price * item.quantity }
discount = subtotal * order.discount_rate
tax = (subtotal - discount) * order.tax_rate
total = subtotal - discount + tax
"Subtotal: #{subtotal}, Discount: #{discount}, Tax: #{tax}, Total: #{total}"
end
endCorrect (calculation moves to Order, printer only formats):
class Order
def subtotal
items.sum { |item| item.price * item.quantity }
end
def discount
subtotal * discount_rate
end
def tax
(subtotal - discount) * tax_rate
end
# Data and logic live together — one place to change
def total
subtotal - discount + tax
end
end
class OrderPrinter
def format_total(order)
"Subtotal: #{order.subtotal}, Discount: #{order.discount}, " \
"Tax: #{order.tax}, Total: #{order.total}"
end
endEnforce Law of Demeter with Delegation
Chained method calls like order.customer.address.city couple the caller to the entire object graph, so renaming or restructuring any intermediate object breaks every call site. Delegation exposes only what the caller needs, keeping each object's contract to a single dot.
Incorrect (chained calls couple caller to 3 levels of structure):
class OrderMailer
def send_confirmation(order)
# Each dot is a dependency — 3 objects must stay stable
city = order.customer.address.city
email = order.customer.email
postal_code = order.customer.address.postal_code
deliver(
to: email,
subject: "Order confirmed",
body: "Shipping to #{city}, #{postal_code}"
)
end
endCorrect (delegate through the immediate collaborator):
class Order
# Expose only what callers need — internal structure stays private
delegate :email, to: :customer
delegate :city, :postal_code, to: :customer, prefix: true
end
class OrderMailer
def send_confirmation(order)
city = order.customer_city
email = order.email
postal_code = order.customer_postal_code
deliver(
to: email,
subject: "Order confirmed",
body: "Shipping to #{city}, #{postal_code}"
)
end
endAlternative (stdlib Forwardable for non-Rails projects):
require "forwardable"
class Customer
extend Forwardable
def_delegators :address, :city, :postal_code
endTell Objects What to Do, Don't Query Their State
Querying an object's internals to make a decision on its behalf scatters the object's business rules across every caller. When the rules change, every call site must be updated. Telling the object what to do keeps the decision and the data together, so changes happen in one place.
Incorrect (caller queries state then acts on behalf of the object):
class PaymentProcessor
def process(order)
# Caller interrogates order internals — rules duplicated everywhere this pattern appears
if order.status == :pending && order.total > 0
order.payment_method.charge(order.total)
order.status = :paid
order.paid_at = Time.current
elsif order.status == :pending && order.total.zero?
order.status = :paid
order.paid_at = Time.current
end
end
endCorrect (tell the object to handle its own transition):
class Order
def process_payment
return unless status == :pending
# Decision and data live together — one place to change
payment_method.charge(total) unless total.zero?
self.status = :paid
self.paid_at = Time.current
end
end
class PaymentProcessor
def process(order)
order.process_payment
end
endUse Data.define for Immutable Value Objects
Hand-rolled value objects require boilerplate for initialization, freezing, equality, and pattern matching -- and every line is a chance to forget freeze or misimplement ==. Data.define provides all of this out of the box with zero ceremony, and benchmarks at 85x faster than OpenStruct for construction. Ruby 3.2+ only.
Incorrect (manual boilerplate for immutability and equality):
class Coordinate
attr_reader :latitude, :longitude
def initialize(latitude:, longitude:)
raise ArgumentError, "invalid latitude" unless (-90..90).cover?(latitude)
raise ArgumentError, "invalid longitude" unless (-180..180).cover?(longitude)
@latitude = latitude
@longitude = longitude
freeze # easy to forget, breaks immutability guarantee
end
def ==(other)
other.is_a?(self.class) &&
latitude == other.latitude &&
longitude == other.longitude
end
alias_method :eql?, :==
def hash = [latitude, longitude].hash
def deconstruct_keys(keys)
{ latitude: latitude, longitude: longitude }
end
endCorrect (Data.define — immutable, equatable, pattern-matchable):
Coordinate = Data.define(:latitude, :longitude) do
def initialize(latitude:, longitude:)
raise ArgumentError, "invalid latitude" unless (-90..90).cover?(latitude)
raise ArgumentError, "invalid longitude" unless (-180..180).cover?(longitude)
super # frozen, ==, eql?, hash, and deconstruct_keys provided automatically
end
def to_s = "#{latitude}, #{longitude}"
end
# Pattern matching works out of the box
coordinate = Coordinate.new(latitude: 51.5074, longitude: -0.1278)
case coordinate
in Coordinate[latitude: (50..55) => lat, longitude:]
puts "UK region: #{lat}, #{longitude}"
endNote: Data.define requires Ruby 3.2+. For earlier versions, use Struct with keyword_init: true and manual freeze.
Encapsulate Collections Behind Domain Methods
Exposing a raw collection via attr_accessor lets any caller add, remove, or replace items without validation. Business rules like quantity limits or duplicate checks get scattered across every call site, and a single items.clear can silently violate invariants. Encapsulating the collection behind domain methods keeps mutation controlled and auditable.
Incorrect (exposed collection allows uncontrolled mutation):
class ShoppingCart
attr_accessor :items
def initialize
@items = []
end
def total
items.sum { |item| item.price * item.quantity }
end
end
cart = ShoppingCart.new
cart.items << CartItem.new(sku: "SHOE-42", price: 89.99, quantity: 1)
cart.items << CartItem.new(sku: "SHOE-42", price: 89.99, quantity: 1) # duplicate — no guard
cart.items.clear # caller can silently empty the cartCorrect (frozen collection with domain methods enforcing rules):
class ShoppingCart
def initialize
@items = []
end
def items
@items.dup.freeze # external callers get a frozen snapshot
end
def add_item(item)
existing = @items.find { |i| i.sku == item.sku }
if existing
existing.increment_quantity(item.quantity)
else
@items << item
end
self
end
def remove_item(sku)
@items.reject! { |item| item.sku == sku }
self
end
def total
@items.sum { |item| item.price * item.quantity }
end
end
cart = ShoppingCart.new
cart.add_item(CartItem.new(sku: "SHOE-42", price: 89.99, quantity: 1))
cart.add_item(CartItem.new(sku: "SHOE-42", price: 89.99, quantity: 1)) # merges quantity
cart.items << CartItem.new(sku: "HAT-01", price: 24.99, quantity: 1) # raises FrozenErrorReplace Data Clumps with Grouped Objects
When the same group of parameters travels together through three or more methods, it signals a missing concept. Adding a sixth field means updating every method signature, every caller, and every test. Extracting the clump into a named object makes the concept explicit and gives formatting, validation, and comparison a natural home.
Incorrect (address fields repeated across multiple methods):
class ShippingService
def estimate_cost(street, city, state, zip, weight)
zone = ZoneCalculator.zone_for(city, state, zip) # same 3 fields again
rate = RateTable.lookup(zone, weight)
rate
end
def validate_address(street, city, state, zip)
# 4 parameters that always appear together
ZipLookup.valid?(zip, city, state)
end
def format_label(street, city, state, zip, name)
"#{name}\n#{street}\n#{city}, #{state} #{zip}"
end
end
cost = service.estimate_cost("123 Main St", "Portland", "OR", "97201", 2.5)
valid = service.validate_address("123 Main St", "Portland", "OR", "97201")
label = service.format_label("123 Main St", "Portland", "OR", "97201", "Jane Doe")Correct (grouped into an Address object that owns its behavior):
class Address
attr_reader :street, :city, :state, :zip
def initialize(street:, city:, state:, zip:)
@street = street
@city = city
@state = state
@zip = zip
end
def valid?
ZipLookup.valid?(zip, city, state)
end
# Formatting lives with the data it formats
def to_label(name)
"#{name}\n#{street}\n#{city}, #{state} #{zip}"
end
end
class ShippingService
def estimate_cost(address, weight)
zone = ZoneCalculator.zone_for(address.city, address.state, address.zip)
rate = RateTable.lookup(zone, weight)
rate
end
def validate_address(address)
address.valid?
end
def format_label(address, name)
address.to_label(name)
end
end
address = Address.new(street: "123 Main St", city: "Portland", state: "OR", zip: "97201")
cost = service.estimate_cost(address, 2.5)
valid = service.validate_address(address)
label = service.format_label(address, "Jane Doe")Separate Query Methods from Command Methods
A method that both returns a value and mutates state is impossible to call safely -- callers cannot check a balance without accidentally triggering a withdrawal, and the result cannot be cached or retried. Separating queries (return data, no side effects) from commands (mutate state, return nothing) makes each independently testable, cacheable, and composable.
Incorrect (query and command tangled in one method):
class Account
attr_reader :balance, :transactions
def initialize(balance:)
@balance = balance
@transactions = []
end
def withdraw(amount)
# Returns remaining balance AND mutates state — caller can't query without side effects
raise InsufficientFundsError, "balance too low" if amount > @balance
@balance -= amount
@transactions << { type: :withdrawal, amount: amount, at: Time.current }
@balance
end
end
account = Account.new(balance: 500.00)
remaining = account.withdraw(100.00) # wanted to check balance, got a mutation insteadCorrect (query returns data, command mutates state):
class Account
attr_reader :balance, :transactions
def initialize(balance:)
@balance = balance
@transactions = []
end
# Query — safe to call any number of times, cacheable
def sufficient_funds?(amount)
amount <= @balance
end
# Command — mutates state, returns nothing
def withdraw(amount)
raise InsufficientFundsError, "balance too low" unless sufficient_funds?(amount)
@balance -= amount
@transactions << { type: :withdrawal, amount: amount, at: Time.current }
nil # explicit nil signals no return value by design
end
end
account = Account.new(balance: 500.00)
if account.sufficient_funds?(100.00) # query — no side effects
account.withdraw(100.00) # command — explicit mutation
endReplace Primitive Obsession with Value Objects
Passing raw strings or numbers to represent domain concepts scatters validation and formatting logic across every call site. When the same primitive is validated in three places, a fourth will inevitably be missed. A value object gives the concept a name, validates once at construction, and provides a natural home for derived behavior.
Incorrect (raw string with validation scattered across call sites):
class UserRegistration
def register(email, name)
# Validation duplicated wherever email is used
raise ArgumentError, "invalid email" unless email.match?(/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i)
user = User.create!(email: email, name: name)
Mailer.send_welcome(user.email)
Analytics.track_signup(email.split("@").last) # domain extraction repeated elsewhere
user
end
end
class PasswordReset
def request_reset(email)
raise ArgumentError, "invalid email" unless email.match?(/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i)
token = SecureRandom.hex(20)
ResetToken.create!(email: email, token: token)
Mailer.send_reset(email)
end
endCorrect (value object centralizes validation and behavior):
class EmailAddress
PATTERN = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
attr_reader :address
def initialize(address)
raise ArgumentError, "invalid email" unless address.match?(PATTERN)
@address = address.downcase.freeze
end
# Domain behavior lives with the data that owns it
def domain = address.split("@").last
def to_s = address
def ==(other) = other.is_a?(self.class) && address == other.address
def hash = address.hash
end
class UserRegistration
def register(email, name)
email = EmailAddress.new(email)
user = User.create!(email: email.to_s, name: name)
Mailer.send_welcome(email.to_s)
Analytics.track_signup(email.domain)
user
end
end
class PasswordReset
def request_reset(email)
email = EmailAddress.new(email) # validates once — no duplication
token = SecureRandom.hex(20)
ResetToken.create!(email: email.to_s, token: token)
Mailer.send_reset(email.to_s)
end
endUse yield Over block.call for Simple Blocks
Capturing a block with &block forces Ruby to allocate a Proc object on every call, even when you only need to invoke it once. yield passes control directly without allocation, making it 2-5x faster. Reserve &block for when you need to store, forward, or inspect the block.
Incorrect (unnecessary Proc allocation via &block):
class EventProcessor
def process(events, &block)
events.each do |event|
result = block.call(event) # allocates Proc on every call to process
log_result(event, result)
end
end
def with_retry(max_attempts:, &block)
attempts = 0
begin
attempts += 1
block.call # Proc allocated unnecessarily
rescue TransientError => e
retry if attempts < max_attempts
raise
end
end
endCorrect (yield avoids Proc allocation):
class EventProcessor
def process(events)
events.each do |event|
result = yield event # no Proc allocated, 2-5x faster
log_result(event, result)
end
end
def with_retry(max_attempts:)
attempts = 0
begin
attempts += 1
yield # direct dispatch, no allocation
rescue TransientError => e
retry if attempts < max_attempts
raise
end
end
def on_complete(&block) # &block is correct here — storing for later
@on_complete = block
end
endUse respond_to? Over is_a? for Type Checking
Checking is_a? couples code to a specific class hierarchy, breaking when you introduce adapters, decorators, or any object that quacks like the expected type but doesn't inherit from it. Duck typing is the Ruby way -- check behavior, not ancestry. This lets any object participate as long as it implements the expected protocol.
Incorrect (type checking couples to class hierarchy):
class NotificationDispatcher
def dispatch(destination, message)
if destination.is_a?(String) # breaks for StringIO, Pathname, or any string-like object
send_to_email(destination, message)
elsif destination.is_a?(Array)
destination.each { |dest| dispatch(dest, message) }
elsif destination.is_a?(User) # breaks for AdminUser, GuestUser, or decorated users
send_to_user(destination, message)
else
raise ArgumentError, "unsupported destination type: #{destination.class}"
end
end
endCorrect (check behavior, not ancestry):
class NotificationDispatcher
def dispatch(destination, message)
if destination.respond_to?(:to_str) # any string-like object works
send_to_email(destination.to_str, message)
elsif destination.respond_to?(:each) # any enumerable works
destination.each { |dest| dispatch(dest, message) }
elsif destination.respond_to?(:email) # any object with an email works
send_to_user(destination, message)
else
raise ArgumentError, "destination must respond to :to_str, :each, or :email"
end
end
endOmit Explicit return for Last Expression
Ruby methods implicitly return the value of their last expression. Adding an explicit return at the end of a method is redundant noise that signals the author may not be fluent in Ruby conventions. Keep explicit return only for early exits (guard clauses) where it communicates intent to short-circuit.
Incorrect (redundant return on last expression):
class PricingCalculator
def total_price(order)
subtotal = order.line_items.sum(&:total)
discount = calculate_discount(order.customer, subtotal)
tax = (subtotal - discount) * tax_rate(order.shipping_address)
return subtotal - discount + tax # redundant — already the last expression
end
def tax_rate(address)
return address.state == "OR" ? 0.0 : 0.08 # redundant return
end
def formatted_total(order)
total = total_price(order)
return "$#{'%.2f' % total}" # redundant return
end
endCorrect (implicit return, explicit only for guard clauses):
class PricingCalculator
def total_price(order)
subtotal = order.line_items.sum(&:total)
discount = calculate_discount(order.customer, subtotal)
tax = (subtotal - discount) * tax_rate(order.shipping_address)
subtotal - discount + tax # implicit return — idiomatic Ruby
end
def tax_rate(address)
address.state == "OR" ? 0.0 : 0.08
end
def formatted_total(order)
total = total_price(order)
"$#{'%.2f' % total}"
end
def apply_coupon(order, code)
return 0 unless code.present? # explicit return is correct here — guard clause
return 0 if coupon_expired?(code)
Coupon.find_by(code: code).discount_amount
end
endUse Keyword Arguments for Clarity
Positional arguments with more than two parameters become unreadable at the call site. Callers must remember exact ordering, and boolean flags are especially cryptic. Keyword arguments make every call self-documenting and immune to transposition bugs.
Incorrect (positional arguments obscure meaning):
class UserService
def create_user(first_name, last_name, admin, verified, age)
User.new(
first_name: first_name,
last_name: last_name,
admin: admin,
verified: verified,
age: age
)
end
end
# caller has no idea what true, false, 25 mean
service.create_user("John", "Doe", true, false, 25)Correct (keyword arguments self-document every call):
class UserService
def create_user(first_name:, last_name:, admin:, verified:, age:) # required keywords, no defaults
User.new(
first_name: first_name,
last_name: last_name,
admin: admin,
verified: verified,
age: age
)
end
end
# call site reads like documentation
service.create_user(first_name: "John", last_name: "Doe", admin: true, verified: false, age: 25)Name Boolean Methods with ? Suffix
Ruby convention uses the ? suffix to signal that a method returns a boolean. Prefixes like is_ or has_ are Java/Python idioms that add noise in Ruby. The ? suffix is understood by every Ruby developer and reads naturally in conditionals: if user.active? reads as a question.
Incorrect (Java-style boolean prefixes):
class Subscription
def is_active
expires_at > Time.current
end
def has_payment_method
payment_methods.any?
end
def is_eligible_for_renewal
is_active && has_payment_method && !is_cancelled
end
def is_cancelled
cancelled_at.present?
end
end
# reads awkwardly in conditionals
send_reminder(subscription) if subscription.is_eligible_for_renewalCorrect (Ruby ? suffix convention):
class Subscription
def active?
expires_at > Time.current
end
def payment_method? # no has_ prefix needed
payment_methods.any?
end
def eligible_for_renewal?
active? && payment_method? && !cancelled?
end
def cancelled?
cancelled_at.present?
end
end
# reads as natural English question
send_reminder(subscription) if subscription.eligible_for_renewal?Use map/select/reject Over each with Accumulator
The each-with-accumulator pattern introduces unnecessary mutability and obscures intent. Ruby's Enumerable methods (map, select, reject) declare what you want in a single expression, eliminating temporary variables and off-by-one mutation bugs.
Incorrect (mutable accumulator with each):
class OrderReport
def line_item_totals(order)
totals = []
order.line_items.each do |item|
totals << item.price * item.quantity # mutable accumulator hides intent
end
totals
end
def active_users(users)
results = []
users.each do |user|
results << user if user.active? && user.confirmed? # manual filtering
end
results
end
def deactivated_emails(users)
emails = []
users.each do |user|
emails << user.email unless user.active?
end
emails
end
endCorrect (declarative Enumerable methods):
class OrderReport
def line_item_totals(order)
order.line_items.map { |item| item.price * item.quantity } # map declares transformation
end
def active_users(users)
users.select { |user| user.active? && user.confirmed? } # select declares filter criteria
end
def deactivated_emails(users)
users.reject(&:active?).map(&:email) # reject + map chains read as a pipeline
end
endAlways Pair method_missing with respond_to_missing?
Defining method_missing without respond_to_missing? creates objects that handle messages they claim not to understand. This breaks respond_to?, method(:name), and any library that checks capabilities before calling. The pair ensures Ruby's introspection protocol stays consistent.
Incorrect (method_missing without respond_to_missing?):
class DynamicConfig
def initialize(settings)
@settings = settings
end
def method_missing(name, *args)
if @settings.key?(name)
@settings[name]
else
super
end
end
end
config = DynamicConfig.new(database_url: "postgres://localhost/app")
config.database_url # => "postgres://localhost/app"
config.respond_to?(:database_url) # => false — introspection is broken
config.method(:database_url) # => raises NameErrorCorrect (method_missing paired with respond_to_missing?):
class DynamicConfig
def initialize(settings)
@settings = settings
end
def method_missing(name, *args)
if @settings.key?(name)
@settings[name]
else
super
end
end
def respond_to_missing?(name, include_private = false) # must mirror method_missing logic
@settings.key?(name) || super
end
end
config = DynamicConfig.new(database_url: "postgres://localhost/app")
config.database_url # => "postgres://localhost/app"
config.respond_to?(:database_url) # => true — introspection works correctly
config.method(:database_url) # => #<Method: DynamicConfig#database_url>Implement deconstruct_keys for Custom Pattern Matching
Without deconstruct_keys, custom objects cannot participate in Ruby 3.0+ case/in pattern matching, forcing callers back to manual accessor checks and conditionals. Implementing this protocol method lets domain objects expose their structure declaratively, enabling the same concise matching syntax that works with hashes and arrays.
Incorrect (manual attribute checks on domain objects):
class Coordinate
attr_reader :latitude, :longitude, :altitude
def initialize(latitude:, longitude:, altitude: nil)
@latitude = latitude
@longitude = longitude
@altitude = altitude
end
end
class FlightTracker
def classify_position(coordinate)
if coordinate.latitude.between?(-90, 90) && coordinate.longitude.between?(-180, 180)
if coordinate.altitude && coordinate.altitude > 10_000 # manual accessor checks, no structural matching
:high_altitude
elsif coordinate.altitude
:low_altitude
else
:ground_level
end
else
:invalid
end
end
endCorrect (deconstruct_keys enables case/in on domain objects):
class Coordinate
attr_reader :latitude, :longitude, :altitude
def initialize(latitude:, longitude:, altitude: nil)
@latitude = latitude
@longitude = longitude
@altitude = altitude
end
def deconstruct_keys(keys) # enables pattern matching protocol for this class
h = {}
h[:latitude] = latitude if keys.nil? || keys.include?(:latitude)
h[:longitude] = longitude if keys.nil? || keys.include?(:longitude)
h[:altitude] = altitude if keys.nil? || keys.include?(:altitude)
h
end
end
class FlightTracker
def classify_position(coordinate)
case coordinate
in { latitude: (-90..90), longitude: (-180..180), altitude: (10_001..) }
:high_altitude
in { latitude: (-90..90), longitude: (-180..180), altitude: (1..10_000) }
:low_altitude
in { latitude: (-90..90), longitude: (-180..180) }
:ground_level
else
:invalid
end
end
endUse Endless Method Definition for Simple Methods
Three-line method definitions that simply return a single expression add visual noise without adding clarity. Ruby 3.0+ endless methods (def name = expr) eliminate the end keyword and make the intent immediately scannable, similar to how attr_reader signals simple accessors. Reserve this for truly simple expressions -- anything requiring multiple statements or complex logic should keep the traditional form.
Incorrect (verbose definitions for single-expression methods):
class Invoice
attr_reader :line_items, :tax_rate, :customer
def subtotal
line_items.sum(&:total) # 3 lines for a one-expression method
end
def tax_amount
subtotal * tax_rate
end
def total
subtotal + tax_amount
end
def display_name
"#{customer.company} - Invoice ##{id}"
end
def overdue?
due_date < Date.today
end
endCorrect (endless methods for single expressions):
class Invoice
attr_reader :line_items, :tax_rate, :customer
def subtotal = line_items.sum(&:total) # reads like a declaration, not a procedure
def tax_amount = subtotal * tax_rate
def total = subtotal + tax_amount
def display_name = "#{customer.company} - Invoice ##{id}"
def overdue? = due_date < Date.today
# Keep traditional form for methods with side effects or multiple statements
def finalize!
validate_line_items!
self.status = :finalized
save!
end
endUse Pattern Matching with Guard Clauses
Nested if/case combinations that check both response structure and conditional values scatter related logic across multiple indentation levels. Ruby 3.0+ pattern matching supports if guards directly on in branches, combining structural matching and conditional logic into a single readable construct.
Incorrect (nested if/case for status and body handling):
class HttpResponseHandler
def process(response)
if response[:status]
status = response[:status]
body = response[:body]
if status >= 200 && status < 300
if body && body[:items] && body[:items].size > 0 # structure check tangled with status logic
{ result: :success, items: body[:items] }
else
{ result: :empty }
end
elsif status >= 400 && status < 500
if body && body[:error]
{ result: :client_error, message: body[:error][:message] }
else
{ result: :client_error, message: "unknown client error" }
end
elsif status >= 500
{ result: :server_error, retry: true }
else
{ result: :unexpected, status: status }
end
else
{ result: :invalid_response }
end
end
endCorrect (pattern matching with guard clauses):
class HttpResponseHandler
def process(response)
case response
in { status: (200..299), body: { items: [_, *] => items } } # structural match with array pattern
{ result: :success, items: items }
in { status: (200..299) }
{ result: :empty }
in { status: (400..499), body: { error: { message: } } }
{ result: :client_error, message: message }
in { status: (400..499) }
{ result: :client_error, message: "unknown client error" }
in { status: Integer => status } if status >= 500 # guard clause for open-ended range
{ result: :server_error, retry: true }
else
{ result: :invalid_response }
end
end
endUse case/in for Structural Pattern Matching
Manually traversing nested hashes with chained && guards is brittle and obscures the structure you actually expect. Each access adds a nil-check obligation and a potential NoMethodError. Ruby 3.0+ case/in pattern matching declaratively describes the expected shape, destructures values inline, and makes missing-key handling exhaustive.
Incorrect (chained nil guards for nested hash access):
class ApiResponseParser
def extract_user_email(response)
if response[:data] && response[:data][:user] && response[:data][:user][:email]
email = response[:data][:user][:email] # 3 redundant traversals of the same path
if response[:data][:user][:verified]
{ email: email, verified: true }
else
{ email: email, verified: false }
end
elsif response[:error]
{ error: response[:error][:message] || "unknown error" }
else
{ error: "malformed response" }
end
end
endCorrect (pattern matching with destructuring):
class ApiResponseParser
def extract_user_email(response)
case response
in { data: { user: { email:, verified: true } } } # declares shape and destructures in one expression
{ email: email, verified: true }
in { data: { user: { email: } } }
{ email: email, verified: false }
in { error: { message: } }
{ error: message }
else
{ error: "malformed response" }
end
end
endSee also: `cond-pattern-matching` for replacing nested hash access conditionals.
Use Rightward Assignment for Pipeline Expressions
When a multi-step method chain produces a result that needs a name, traditional leftward assignment forces readers to see the variable name before understanding the transformation. Ruby 3.0+ rightward assignment (=> variable) lets the code read top-to-bottom like a pipeline, matching the natural data flow from input to named output.
Incorrect (leftward assignment breaks reading flow):
class SalesReport
def quarterly_summary(orders)
regional_totals = orders
.select { |o| o.status == :completed }
.group_by(&:region)
.transform_values { |group| group.sum(&:total) } # reader must scroll up to find what this becomes
top_regions = regional_totals
.sort_by { |_region, total| -total }
.first(5)
.to_h
{ totals: regional_totals, top_regions: top_regions }
end
endCorrect (rightward assignment follows data flow):
class SalesReport
def quarterly_summary(orders)
orders
.select { |o| o.status == :completed }
.group_by(&:region)
.transform_values { |group| group.sum(&:total) } => regional_totals # name appears at the end of the pipeline
regional_totals
.sort_by { |_region, total| -total }
.first(5)
.to_h => top_regions
{ totals: regional_totals, top_regions: top_regions }
end
endWhen NOT to use: Avoid rightward assignment for simple, single-step expressions where leftward assignment is already clear. It adds unnecessary novelty without improving readability.
# Do NOT use rightward assignment here
"hello world" => greeting # confusing for a trivial assignment
user.name => name # no pipeline, leftward is clearer
# Traditional assignment is better for simple cases
greeting = "hello world"
name = user.nameSpell Out Names Except Universal Abbreviations
Abbreviated names save keystrokes but cost minutes in comprehension. desc could mean description, descending, or descriptor. mgr saves three characters but forces every reader to mentally expand it. Spell out names fully unless the abbreviation is universally understood in software (id, url, html, json, http, db, io, api).
Incorrect (abbreviations create ambiguity):
class TxnProcessor
def calc_avg_txn_amt(acct)
txns = acct.txns.where(stat: :completed)
return 0 if txns.empty?
tot_amt = txns.sum(&:amt)
avg = tot_amt / txns.cnt
avg
end
def gen_rpt(dept_mgr, dt_range)
txns = dept_mgr.dept.txns.where(created_at: dt_range)
qty = txns.count
desc = txns.group(:cat).sum(:amt) # desc — description or descending?
{ qty: qty, desc: desc }
end
endCorrect (spelled-out names eliminate guesswork):
class TransactionProcessor
def calculate_average_transaction_amount(account)
transactions = account.transactions.where(status: :completed)
return 0 if transactions.empty?
total_amount = transactions.sum(&:amount)
average = total_amount / transactions.count
average
end
def generate_report(department_manager, date_range)
transactions = department_manager.department.transactions.where(created_at: date_range)
quantity = transactions.count
breakdown = transactions.group(:category).sum(:amount) # no ambiguity
{ quantity: quantity, breakdown: breakdown }
end
endCommonly misused abbreviations to always spell out:
| Abbreviation | Spell out as |
|---|---|
mgr | manager |
amt | amount |
qty | quantity |
desc | description (or descending — the ambiguity proves the point) |
txn | transaction |
util | utility |
calc | calculate |
rpt | report |
dept | department |
cnt | count |
stat | status |
cat | category |
Use One Word per Concept Across Codebase
When different parts of a codebase use fetch, get, retrieve, and load for the same conceptual operation, developers waste time wondering whether the synonyms imply different behavior. Pick one word per concept and enforce it everywhere. Consistency lets developers predict method names without searching.
Incorrect (synonyms for the same operation across services):
class UserService
def fetch_user(id)
User.find(id)
end
end
class OrderService
def get_order(id) # get vs fetch — different word, same concept
Order.find(id)
end
end
class PaymentService
def retrieve_payment(id) # retrieve vs fetch — yet another synonym
Payment.find(id)
end
end
class InvoiceService
def load_invoice(id) # load vs fetch — readers wonder if this is lazy-loading
Invoice.find(id)
end
end
# Same problem with class-level naming
class UserController; end
class OrderManager; end # manager vs controller
class PaymentHandler; end # handler vs controller
class InvoiceProcessor; end # processor vs controllerCorrect (one word per concept, applied consistently):
class UserService
def fetch_user(id)
User.find(id)
end
end
class OrderService
def fetch_order(id) # same verb for same concept
Order.find(id)
end
end
class PaymentService
def fetch_payment(id)
Payment.find(id)
end
end
class InvoiceService
def fetch_invoice(id)
Invoice.find(id)
end
end
# Consistent class-level naming — one suffix per role
class UserController; end
class OrderController; end
class PaymentController; end
class InvoiceController; endUse Intention-Revealing Names
Names should answer why something exists, what it does, and how it is used. When a variable or method name requires a comment to explain its purpose, the name has failed. Intention-revealing names make code read like prose and let reviewers focus on logic instead of deciphering abbreviations.
Incorrect (cryptic names require mental translation):
class SubscriptionService
def process(d)
# d is the cutoff date for expiring trials
u_list = User.where("cd < ?", d)
u_list.each do |u|
d2 = Date.today - u.cd # days since creation
if d2 > 14
u.s.update!(status: :expired)
n = Notification.new(u, :trial_ended)
n.send
end
end
end
def calc(o)
t = o.items.sum { |i| i.p * i.q }
t - (t * o.dr)
end
endCorrect (names reveal intent without comments):
class SubscriptionService
def expire_trial_subscriptions(cutoff_date)
users = User.where("created_at < ?", cutoff_date)
users.each do |user|
days_since_creation = Date.today - user.created_at
if days_since_creation > 14
user.subscription.update!(status: :expired)
notification = Notification.new(user, :trial_ended)
notification.send
end
end
end
def calculate_discounted_total(order)
total = order.items.sum { |item| item.price * item.quantity }
total - (total * order.discount_rate) # name makes formula self-evident
end
endRename to Eliminate Need for Comments
A comment explaining what a method or variable does is a naming failure. Comments drift from reality as code evolves, but names are checked by every caller. When you feel the urge to write a comment, rename instead. A name that renders its comment redundant is always the better choice.
Incorrect (comments compensate for vague names):
class Account
# Check if user can access premium features
def check(u)
u.plan == :premium && u.status == :active
end
# Get the number of days left until the trial expires
def remaining(user)
(user.trial_end_date - Date.today).to_i
end
# Send email if invoice is more than 30 days overdue
def process(invoice)
if (Date.today - invoice.due_date).to_i > 30
InvoiceMailer.overdue_notice(invoice).deliver_later
end
end
endCorrect (names replace every comment):
class Account
def user_has_premium_access?(user)
user.plan == :premium && user.status == :active
end
def trial_days_remaining(user) # name states exactly what is returned
(user.trial_end_date - Date.today).to_i
end
def send_overdue_notice_if_past_threshold(invoice)
if (Date.today - invoice.due_date).to_i > 30
InvoiceMailer.overdue_notice(invoice).deliver_later
end
end
endWrap Objects with Decorator for Added Behavior
When cross-cutting concerns like logging, caching, and retries are added through subclassing, the number of subclasses explodes combinatorially (LoggingClient, CachingClient, LoggingCachingClient, etc.). Decorators using SimpleDelegator let you stack behaviors independently and in any order, keeping each concern in its own class.
Incorrect (subclass explosion for every combination of concerns):
class HttpClient
def fetch(url)
Net::HTTP.get(URI(url))
end
end
class LoggingHttpClient < HttpClient
def fetch(url)
Rails.logger.info("HTTP GET #{url}")
result = super
Rails.logger.info("HTTP 200 #{url} (#{result.bytesize} bytes)")
result
end
end
# CachingLoggingHttpClient, RetryLoggingHttpClient, RetryLoggingCachingHttpClient…
# each combination requires a new subclass
class CachingLoggingHttpClient < LoggingHttpClient
def fetch(url)
@cache ||= {}
@cache[url] ||= super
end
endCorrect (stacked decorators using SimpleDelegator):
class HttpClient
def fetch(url)
Net::HTTP.get(URI(url))
end
end
class LoggingDecorator < SimpleDelegator
def fetch(url)
Rails.logger.info("HTTP GET #{url}")
result = super # delegates to wrapped object
Rails.logger.info("HTTP 200 #{url} (#{result.bytesize} bytes)")
result
end
end
class CachingDecorator < SimpleDelegator
def initialize(client)
super
@cache = {}
end
def fetch(url)
@cache[url] ||= super
end
end
class RetryDecorator < SimpleDelegator
def fetch(url, retries: 3)
attempts = 0
begin
attempts += 1
super(url)
rescue Net::OpenTimeout, Net::ReadTimeout => e
raise e if attempts >= retries
retry # each decorator handles one concern independently
end
end
end
# Stack any combination without new classes
client = HttpClient.new
client = LoggingDecorator.new(client)
client = CachingDecorator.new(client)
client = RetryDecorator.new(client)
client.fetch("https://api.example.com/data")Use Factory Method to Abstract Object Creation
Hardcoded case/when blocks that instantiate different classes based on a type string scatter creation logic and force edits every time a new type is introduced. A registry-based factory lets each parser register itself, keeping creation logic open for extension and closed for modification.
Incorrect (case/when tightly coupling creation to every type):
class DocumentProcessor
def process(file_path)
ext = File.extname(file_path).delete(".")
parser = case ext
when "json"
JSONParser.new(file_path)
when "xml"
XMLParser.new(file_path)
when "csv"
CSVParser.new(file_path) # every new format forces a change here
else
raise ArgumentError, "Unsupported format: #{ext}"
end
parser.parse
end
endCorrect (registry-based factory with `.register` and `.build`):
class ParserFactory
@registry = {}
class << self
def register(format, klass)
@registry[format.to_s] = klass # each parser registers itself once
end
def build(file_path)
ext = File.extname(file_path).delete(".")
klass = @registry.fetch(ext) do
raise ArgumentError, "Unsupported format: #{ext}"
end
klass.new(file_path)
end
end
end
# Self-registration — adding a new format never touches the factory
ParserFactory.register("json", JSONParser)
ParserFactory.register("xml", XMLParser)
ParserFactory.register("csv", CSVParser)
class DocumentProcessor
def process(file_path)
parser = ParserFactory.build(file_path)
parser.parse
end
endImplement Null Object with Full Protocol
Repeated if current_user guards litter controllers, views, and helpers with defensive checks that obscure business logic. A single forgotten guard produces a NoMethodError in production. A GuestUser class that responds to the full User protocol with safe defaults eliminates every conditional at every call site, leveraging Ruby's duck typing to treat logged-out state as a first-class concept.
Incorrect (nil checks scattered across the entire call chain):
class ApplicationController < ActionController::Base
def dashboard
if current_user
@name = current_user.name
@permissions = current_user.permissions
else
@name = "Guest"
@permissions = []
end
@display_name = if current_user
current_user.to_s # repeated nil checks in every action
else
"Anonymous Visitor"
end
@can_edit = current_user&.permissions&.include?("edit") || false
@avatar_url = current_user&.avatar_url || "/images/default_avatar.png"
end
endCorrect (GuestUser responds to full User protocol with safe defaults):
class GuestUser
def name
"Guest"
end
def to_s
"Anonymous Visitor"
end
def permissions
[].freeze # safe default — no access granted
end
def avatar_url
"/images/default_avatar.png"
end
def authenticated?
false
end
def admin?
false
end
end
class ApplicationController < ActionController::Base
def current_user
super || GuestUser.new # single fallback replaces all downstream nil checks
end
def dashboard
@name = current_user.name
@permissions = current_user.permissions
@display_name = current_user.to_s
@can_edit = current_user.permissions.include?("edit")
@avatar_url = current_user.avatar_url # no conditionals, same interface everywhere
end
endSee also: `cond-null-object` for a simpler single-attribute null object introduction.
Extract Algorithm Variations into Strategy Objects
Case/when blocks that select between algorithm variations violate the Open/Closed Principle: every new variation forces a change to the selector method. Extracting each algorithm into a strategy object with a common call interface lets you add new strategies by adding code, never by editing existing code.
Incorrect (case/when coupling all pricing logic into one method):
class PricingCalculator
def compute(order, tier)
case tier
when :standard
subtotal = order.line_items.sum(&:price)
subtotal *= 0.95 if order.line_items.size >= 10
subtotal
when :premium
subtotal = order.line_items.sum(&:price) * 0.85
subtotal -= 20.0 if order.recurring?
subtotal
when :enterprise
# Every new tier forces a change here
subtotal = order.line_items.sum(&:price) * 0.70
subtotal -= 50.0 if order.annual_contract?
[subtotal, order.negotiated_minimum].max
else
raise ArgumentError, "Unknown tier: #{tier}"
end
end
endCorrect (strategy objects with common `call` interface):
class StandardPricing
def call(order)
subtotal = order.line_items.sum(&:price)
subtotal *= 0.95 if order.line_items.size >= 10
subtotal
end
end
class PremiumPricing
def call(order)
subtotal = order.line_items.sum(&:price) * 0.85
subtotal -= 20.0 if order.recurring?
subtotal
end
end
class EnterprisePricing
def call(order)
subtotal = order.line_items.sum(&:price) * 0.70
subtotal -= 50.0 if order.annual_contract?
[subtotal, order.negotiated_minimum].max
end
end
class PricingCalculator
STRATEGIES = {
standard: StandardPricing.new,
premium: PremiumPricing.new,
enterprise: EnterprisePricing.new
}.freeze
def compute(order, tier)
strategy = STRATEGIES.fetch(tier) do
raise ArgumentError, "Unknown tier: #{tier}"
end
strategy.call(order) # new tiers need only a new class and one hash entry
end
endDefine Algorithm Skeleton with Template Method
When multiple classes repeat the same high-level algorithm but differ only in specific steps, the duplicated structure drifts apart over time and bugs fixed in one variant are missed in others. Template Method extracts the shared skeleton into a base class and lets subclasses override only the steps that vary, guaranteeing structural consistency.
Incorrect (duplicated pipeline structure across export classes):
class CSVExport
def run(dataset)
records = dataset.select(&:active?)
records = records.sort_by(&:created_at)
rows = records.map { |r| [r.id, r.name, r.value].join(",") }
output = (["id,name,value"] + rows).join("\n")
File.write("/tmp/export_#{Time.now.to_i}.csv", output)
Notifier.send("CSV export complete") # same structure repeated in every exporter
end
end
class JSONExport
def run(dataset)
records = dataset.select(&:active?)
records = records.sort_by(&:created_at)
output = JSON.pretty_generate(records.map { |r| { id: r.id, name: r.name, value: r.value } })
File.write("/tmp/export_#{Time.now.to_i}.json", output)
Notifier.send("JSON export complete")
end
endCorrect (base class defines skeleton, subclasses override hooks):
class Exporter
def run(dataset)
records = prepare(dataset)
records = transform(records)
output = format(records)
deliver(output) # skeleton is defined once; steps vary by subclass
end
private
def prepare(dataset)
dataset.select(&:active?).sort_by(&:created_at)
end
def transform(records)
records # hook — subclasses override when needed
end
def format(records)
raise NotImplementedError, "#{self.class}#format must be implemented"
end
def deliver(output)
File.write("/tmp/export_#{Time.now.to_i}.#{file_extension}", output)
Notifier.send("#{self.class.name} export complete")
end
def file_extension
raise NotImplementedError, "#{self.class}#file_extension must be implemented"
end
end
class CSVExport < Exporter
private
def format(records)
rows = records.map { |r| [r.id, r.name, r.value].join(",") }
(["id,name,value"] + rows).join("\n")
end
def file_extension
"csv"
end
end
class JSONExport < Exporter
private
def format(records)
JSON.pretty_generate(records.map { |r| { id: r.id, name: r.name, value: r.value } })
end
def file_extension
"json"
end
endCompose Methods at Single Abstraction Level
When a method mixes high-level intent with low-level implementation details, readers must constantly shift between "what" and "how." Composing the method so every line operates at the same abstraction level makes it readable in a single pass, like a table of contents.
Incorrect (mixed abstraction levels in registration):
class RegistrationService
def register(params)
# High-level: validate -- but implemented inline at low level
raise ArgumentError, "Email is required" if params[:email].nil?
raise ArgumentError, "Invalid email format" unless params[:email].match?(/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i)
raise ArgumentError, "Password too short" if params[:password].length < 8
raise ArgumentError, "Name is required" if params[:name].to_s.strip.empty?
# Low-level: hash the password directly in flow
hashed_password = BCrypt::Password.create(params[:password])
user = User.create!(
name: params[:name].strip,
email: params[:email].downcase,
password_digest: hashed_password,
confirmed: false
)
# Low-level email construction mixed in
token = SecureRandom.urlsafe_base64(32)
user.update!(confirmation_token: token)
Mailer.deliver(
to: user.email,
subject: "Confirm your account",
body: "Click here: https://app.example.com/confirm?token=#{token}"
)
user
end
endCorrect (all calls at the same abstraction level):
class RegistrationService
def register(params)
validate(params)
user = create_user(params)
send_confirmation(user) # each step reads like a sentence
user
end
private
def validate(params)
raise ArgumentError, "Email is required" if params[:email].nil?
raise ArgumentError, "Invalid email format" unless params[:email].match?(/\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i)
raise ArgumentError, "Password too short" if params[:password].length < 8
raise ArgumentError, "Name is required" if params[:name].to_s.strip.empty?
end
def create_user(params)
User.create!(
name: params[:name].strip,
email: params[:email].downcase,
password_digest: hash_password(params[:password]),
confirmed: false
)
end
def hash_password(password)
# Use BCrypt or Argon2 in production — simplified here for illustration
BCrypt::Password.create(password)
end
def send_confirmation(user)
token = SecureRandom.urlsafe_base64(32)
user.update!(confirmation_token: token)
Mailer.deliver(
to: user.email,
subject: "Confirm your account",
body: "Click here: https://app.example.com/confirm?token=#{token}"
)
end
endReference: Compose Method -- every line in a method should be at the same level of abstraction.
Extract Class for Single Responsibility
When a class accumulates methods that operate on a subset of its data, it has absorbed a second responsibility. Extracting a value object gives that concept a name, a home for validation, and the ability to be reused independently. Sandi Metz's rule: classes should be 100 lines or fewer.
Incorrect (User class absorbing email logic):
class User
attr_accessor :name, :email, :role
def validate_email
return false if email.nil? || email.strip.empty?
email.match?(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i)
end
def email_domain
email.split("@").last.downcase
end
def send_welcome_email
return unless validate_email
Mailer.deliver(
to: email,
subject: "Welcome, #{name}!",
body: "Your account on #{email_domain} is ready."
)
end
def corporate_email?
!%w[gmail.com yahoo.com hotmail.com].include?(email_domain)
end
endCorrect (extracted EmailAddress value object):
class User
attr_accessor :name, :role
attr_reader :email
def initialize(name:, email:, role:)
@name = name
@email = EmailAddress.new(email) # value object owns all email logic
@role = role
end
def send_welcome_email
return unless email.valid?
Mailer.deliver(
to: email.to_s,
subject: "Welcome, #{name}!",
body: "Your account on #{email.domain} is ready."
)
end
end
class EmailAddress
CORPORATE_FREEMAIL = %w[gmail.com yahoo.com hotmail.com].freeze
FORMAT = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
def initialize(address)
@address = address.to_s.strip
end
def valid?
@address.match?(FORMAT)
end
def domain
@address.split("@").last.downcase
end
def corporate?
!CORPORATE_FREEMAIL.include?(domain)
end
def to_s
@address
end
endReference: Sandi Metz, Practical Object-Oriented Design -- classes should be 100 lines or fewer.
Extract Long Methods into Focused Units
Long methods force readers to hold multiple concerns in working memory simultaneously. Extracting cohesive blocks into named private methods makes each piece independently understandable, testable, and reusable. Sandi Metz's rule: methods should be 5 lines or fewer.
Incorrect (monolithic method with multiple responsibilities):
class OrderProcessor
def process(order)
# Validation, calculation, and payment all tangled together
raise ArgumentError, "Order must have items" if order.items.empty?
raise ArgumentError, "Customer email required" if order.customer.email.nil?
subtotal = order.items.sum { |item| item.price * item.quantity }
discount = 0
if order.customer.loyalty_years > 5
discount = subtotal * 0.15
elsif order.customer.loyalty_years > 2
discount = subtotal * 0.10
end
subtotal_after_discount = subtotal - discount
tax = subtotal_after_discount * 0.08
shipping = subtotal_after_discount > 100 ? 0 : 12.99
total = subtotal_after_discount + tax + shipping
payment_result = order.payment_method.charge(total)
raise PaymentError, "Charge failed: #{payment_result.error}" unless payment_result.success?
order.update!(
subtotal: subtotal,
discount: discount,
tax: tax,
shipping: shipping,
total: total,
status: :completed,
completed_at: Time.current
)
end
endCorrect (decomposed into focused private methods, each <=5 lines):
class OrderProcessor
def process(order)
validate(order)
subtotal = calculate_subtotal(order)
discount = apply_discount(order.customer, subtotal)
tax, shipping, total = calculate_total(subtotal - discount)
charge_payment(order.payment_method, total)
finalize(order, subtotal:, discount:, tax:, shipping:, total:)
end
private
def validate(order)
raise ArgumentError, "Order must have items" if order.items.empty?
raise ArgumentError, "Customer email required" if order.customer.email.nil?
end
def calculate_subtotal(order)
order.items.sum { |item| item.price * item.quantity }
end
def apply_discount(customer, subtotal)
return subtotal * 0.15 if customer.loyalty_years > 5
return subtotal * 0.10 if customer.loyalty_years > 2
0
end
def calculate_total(subtotal_after_discount)
tax = subtotal_after_discount * 0.08
shipping = subtotal_after_discount > 100 ? 0 : 12.99
[tax, shipping, subtotal_after_discount + tax + shipping]
end
def charge_payment(payment_method, total)
payment_result = payment_method.charge(total)
raise PaymentError, "Charge failed: #{payment_result.error}" unless payment_result.success?
end
def finalize(order, subtotal:, discount:, tax:, shipping:, total:)
order.update!(subtotal:, discount:, tax:, shipping:, total:, status: :completed, completed_at: Time.current)
end
endReference: Sandi Metz, Practical Object-Oriented Design -- methods should be 5 lines or fewer.
Flatten Deep Nesting with Early Extraction
Each level of nesting doubles the mental effort required to trace execution paths. Deeply nested code obscures the happy path and makes edge cases invisible. Extract nested blocks into named methods with early returns so each method handles one concern at one level.
Incorrect (4+ levels of nesting in payment processing):
class PaymentProcessor
def process(payment)
if payment.amount > 0
if payment.currency_supported?
account = Account.find_by(id: payment.account_id)
if account
if account.active?
if account.balance >= payment.amount
if !payment.flagged_for_review?
transaction = account.debit(payment.amount)
receipt = Receipt.create!(transaction: transaction, payment: payment)
NotificationService.send_confirmation(account.owner, receipt)
{ success: true, receipt_id: receipt.id }
else
{ success: false, error: "Payment flagged for manual review" }
end
else
{ success: false, error: "Insufficient balance" }
end
else
{ success: false, error: "Account is suspended" }
end
else
{ success: false, error: "Account not found" }
end
else
{ success: false, error: "Currency not supported" }
end
else
{ success: false, error: "Amount must be positive" }
end
end
endCorrect (flat methods with early returns):
class PaymentProcessor
def process(payment)
validate(payment)
account = find_account(payment)
verify_account(account, payment)
execute_payment(account, payment)
end
private
def validate(payment)
raise PaymentError, "Amount must be positive" unless payment.amount > 0
raise PaymentError, "Currency not supported" unless payment.currency_supported?
raise PaymentError, "Payment flagged for manual review" if payment.flagged_for_review?
end
def find_account(payment)
Account.find_by(id: payment.account_id) ||
raise(PaymentError, "Account not found")
end
def verify_account(account, payment)
raise PaymentError, "Account is suspended" unless account.active?
raise PaymentError, "Insufficient balance" unless account.balance >= payment.amount
end
def execute_payment(account, payment)
transaction = account.debit(payment.amount)
receipt = Receipt.create!(transaction: transaction, payment: payment)
NotificationService.send_confirmation(account.owner, receipt)
{ success: true, receipt_id: receipt.id }
end
endIntroduce Parameter Object for Long Signatures
Long parameter lists couple every caller to the exact position and count of arguments. When the same group of parameters appears in multiple methods, it signals a missing concept. Bundling them into objects names the concept and gives validation a natural home. Sandi Metz's rule: 4 parameters max.
Incorrect (5+ parameters repeated across methods):
class PropertySearch
def search(start_date, end_date, min_price, max_price, category, sort_by: :relevance)
properties = Property.where(category: category)
properties = properties.where("available_from <= ? AND available_to >= ?", start_date, end_date)
properties = properties.where("price >= ? AND price <= ?", min_price, max_price)
properties.order(sort_by)
end
def count(start_date, end_date, min_price, max_price, category)
# Same 5 parameters duplicated across the call chain
properties = Property.where(category: category)
properties = properties.where("available_from <= ? AND available_to >= ?", start_date, end_date)
properties.where("price >= ? AND price <= ?", min_price, max_price).count
end
def average_price(start_date, end_date, min_price, max_price, category)
properties = Property.where(category: category)
properties = properties.where("available_from <= ? AND available_to >= ?", start_date, end_date)
properties.where("price >= ? AND price <= ?", min_price, max_price).average(:price)
end
endCorrect (parameter objects encapsulate related data):
class DateRange
attr_reader :start_date, :end_date
def initialize(start_date:, end_date:)
raise ArgumentError, "start_date must precede end_date" if start_date > end_date
@start_date = start_date
@end_date = end_date
end
def to_scope(relation)
relation.where("available_from <= ? AND available_to >= ?", start_date, end_date)
end
end
class PriceRange
attr_reader :min_price, :max_price
def initialize(min_price:, max_price:)
raise ArgumentError, "min_price must not exceed max_price" if min_price > max_price
@min_price = min_price
@max_price = max_price
end
def to_scope(relation)
relation.where("price >= ? AND price <= ?", min_price, max_price)
end
end
class PropertySearch
def search(date_range, price_range, category, sort_by: :relevance)
filter(date_range, price_range, category).order(sort_by)
end
def count(date_range, price_range, category)
filter(date_range, price_range, category).count
end
def average_price(date_range, price_range, category)
filter(date_range, price_range, category).average(:price)
end
private
def filter(date_range, price_range, category)
scope = Property.where(category: category)
scope = date_range.to_scope(scope)
price_range.to_scope(scope)
end
endReference: Sandi Metz, Practical Object-Oriented Design -- pass no more than 4 parameters.
Replace Complex Method with Method Object
When a method has many interdependent local variables, extracting pieces into separate methods is painful because each piece needs access to all the locals. Turning the method into its own class converts locals into instance variables, making decomposition straightforward.
Incorrect (tangled calculation with many interdependent locals):
class InvoiceCalculator
def calculate_tax(invoice)
line_totals = invoice.line_items.map { |item| item.quantity * item.unit_price }
subtotal = line_totals.sum
exempt_total = invoice.line_items
.select { |item| item.tax_exempt? }
.sum { |item| item.quantity * item.unit_price }
taxable_amount = subtotal - exempt_total
state_rate = TaxTable.rate_for(invoice.shipping_state)
state_tax = taxable_amount * state_rate
county_rate = TaxTable.county_rate_for(invoice.shipping_state, invoice.shipping_county)
county_tax = taxable_amount * county_rate
# Threshold discount depends on all prior values
combined_tax = state_tax + county_tax
discount = combined_tax > 500 ? combined_tax * 0.02 : 0
total_tax = combined_tax - discount
surcharge = invoice.expedited? ? total_tax * 0.015 : 0
final_tax = (total_tax + surcharge).round(2)
{ subtotal: subtotal, taxable_amount: taxable_amount, state_tax: state_tax,
county_tax: county_tax, discount: discount, surcharge: surcharge, total_tax: final_tax }
end
endCorrect (extracted to method object with call()):
class InvoiceCalculator
def calculate_tax(invoice)
TaxCalculation.new(invoice).call
end
end
class TaxCalculation
def initialize(invoice)
@invoice = invoice
end
def call
compute_subtotals
compute_tax_rates
apply_discount
apply_surcharge
build_result
end
private
def compute_subtotals
@subtotal = @invoice.line_items.sum { |item| item.quantity * item.unit_price }
exempt_total = @invoice.line_items
.select { |item| item.tax_exempt? }
.sum { |item| item.quantity * item.unit_price }
@taxable_amount = @subtotal - exempt_total
end
def compute_tax_rates
state_rate = TaxTable.rate_for(@invoice.shipping_state)
@state_tax = @taxable_amount * state_rate
county_rate = TaxTable.county_rate_for(@invoice.shipping_state, @invoice.shipping_county)
@county_tax = @taxable_amount * county_rate
end
def apply_discount
combined_tax = @state_tax + @county_tax
@discount = combined_tax > 500 ? combined_tax * 0.02 : 0
@total_tax = combined_tax - @discount
end
def apply_surcharge
@surcharge = @invoice.expedited? ? @total_tax * 0.015 : 0
@total_tax = (@total_tax + @surcharge).round(2)
end
def build_result
{ subtotal: @subtotal, taxable_amount: @taxable_amount, state_tax: @state_tax,
county_tax: @county_tax, discount: @discount, surcharge: @surcharge, total_tax: @total_tax }
end
endReference: Replace Function with Command
One Reason to Change per Class
When a class has two responsibilities, a change to one can break the other. If you describe a class using "and" -- "this class formats reports and sends them" -- it has too many reasons to change. Split it so each class changes for exactly one reason.
Incorrect (Report class formats AND sends):
class Report
def initialize(data)
@data = data
end
def generate
rows = @data.map do |record|
"#{record[:name].ljust(20)} #{format('$%<amount>.2f', amount: record[:amount])}"
end
header = "Sales Report - #{Date.today}"
separator = "-" * 40
body = [header, separator, *rows, separator, total_line].join("\n")
body
end
def send_via_email(recipient)
body = generate
# Report should not know about email transport
smtp = Net::SMTP.start("mail.example.com", 587, "example.com", "user", "pass", :login)
message = <<~EMAIL
From: reports@example.com
To: #{recipient}
Subject: Daily Sales Report
#{body}
EMAIL
smtp.send_message(message, "reports@example.com", recipient)
smtp.finish
end
private
def total_line
total = @data.sum { |record| record[:amount] }
"Total:#{' ' * 14}#{format('$%.2f', total)}"
end
endCorrect (split into ReportFormatter and ReportSender):
class ReportFormatter
def initialize(data)
@data = data
end
def generate
rows = @data.map do |record|
"#{record[:name].ljust(20)} #{format('$%<amount>.2f', amount: record[:amount])}"
end
header = "Sales Report - #{Date.today}"
separator = "-" * 40
[header, separator, *rows, separator, total_line].join("\n")
end
private
def total_line
total = @data.sum { |record| record[:amount] }
"Total:#{' ' * 14}#{format('$%.2f', total)}"
end
end
class ReportSender
def initialize(smtp_config)
@smtp_config = smtp_config
end
def send_via_email(body, recipient)
smtp = Net::SMTP.start(*@smtp_config.values_at(:host, :port, :domain, :user, :password, :auth))
message = <<~EMAIL
From: reports@example.com
To: #{recipient}
Subject: Daily Sales Report
#{body}
EMAIL
smtp.send_message(message, "reports@example.com", recipient)
smtp.finish
end
end
# Usage: each class changes for exactly one reason
formatter = ReportFormatter.new(data)
sender = ReportSender.new(smtp_config)
sender.send_via_email(formatter.generate, "manager@example.com")Smell test: If you describe the class with "and," split it.
Related skills
FAQ
What does ruby-refactor do?
ruby-refactor: A skill for development. This provides functionality for development workflows.
When should I use ruby-refactor?
When you need to use ruby-refactor for development tasks, or when ruby-refactor: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ruby-refactor.