Let me tell you about the single most transformative idea I've ever applied to Ruby on Rails code. It's not a gem. It's not a design pattern with a Greek name. It's not even a new idea. It's been hiding in plain sight since 2008, patiently waiting for you to take it seriously.
It's called the Single Level of Abstraction principle, and once you internalize it, you will physically recoil at the sight of a 60-line method. You'll split it into small, private, beautifully named methods and your code will read like a recipe instead of an autopsy report.
Each method should contain statements at
one level of abstraction — and that level
should be one level below the method's name.
— Clean Code, Chapter 3: Functions
The Problem: Code That Makes You Squint #
We've all seen it. We've all written it. (Yes, you too.) A Rails service object or model method that starts innocently enough, then grows and grows until it's 80 lines of nested conditionals, database queries, string manipulation, and API calls all tangled together like earbuds in a pocket. You open the file, your eyes glaze over, and you mutter something about "refactoring it later."
Later never comes. Here's what that code typically looks like:
Rubydef process_customer_order(order_params)
customer = Customer.find(order_params[:customer_id])
raise InsufficientFundsError if customer.balance < order_params[:total]
items = order_params[:items].map do |item|
product = Product.find(item[:product_id])
raise OutOfStockError if product.stock < item[:quantity]
product.update!(stock: product.stock - item[:quantity])
{ product: product, quantity: item[:quantity] }
end
discount = if customer.vip?
order_params[:total] * 0.15
elsif customer.orders.count > 10
order_params[:total] * 0.05
else
0
end
order = Order.create!(
customer: customer,
total: order_params[:total] - discount,
status: :pending
)
shipment = Shipment.create!(order: order, address: customer.shipping_address)
ShipmentMailer.confirmation(shipment).deliver_later
customer.update!(balance: customer.balance - order.total)
order
end
This method does everything. It validates the balance. It checks stock. It calculates discounts. It creates records. It sends emails. It updates balances. To understand what it does, you have to read every single line and mentally group them. You're doing the computer's job. Congratulations, you are now a human parser.
The Solution: Your Method Is a Recipe #
Here's the exact same logic, rewritten with the Single Level of Abstraction principle. Every step is extracted into a private method with a name that tells you what it does:
Rubydef process_customer_order
check_balance
check_availability
apply_special_discount
create_order
create_shipment
notify_customer
deduct_balance
end
Look at that. Seven lines. You can read this method in three seconds and know exactly what the order processing flow does. You don't need to understand how the discount is calculated to understand the overall flow. You don't need to see the SQL query to know that availability is being checked. The method reads like a recipe:
- Check the customer's balance
- Check product availability
- Apply the special discount
- Create the order
- Create the shipment
- Notify the customer
- Deduct the balance
Each private method below handles one step, at one level of abstraction. The high-level method orchestrates. The low-level methods execute. They never mix. You should be able to read the program as a set of TO paragraphs — "to process a customer order, we check the balance, check availability, apply the discount..." — and that's exactly what this reads like.
Why This Works: Cognitive Load Is the Enemy #
Here's the thing nobody tells you about writing software: the hard part isn't writing code — it's reading it. Studies consistently show that developers spend far more time reading and understanding existing code than writing new code. Every minute you invest in readability pays for itself tenfold.
When you look at a 60-line method, your brain has to perform what the Principles Wiki calls mental grouping — you read through lines of code trying to find which statements belong together, mentally drawing invisible brackets around blocks that form a logical unit. This is exhausting. This is where bugs hide. This is where onboarding new developers becomes a month-long ordeal.
Single-use private methods eliminate mental grouping entirely. The
grouping is already done — it's the method itself. The
name is the comment. The boundary is the def and
end. There's nothing left to interpret. Your brain gets
to coast downhill instead of rock-climbing through nested conditionals.
The "But It's Only Used Once" Objection #
Ah, the classic. Every code review, someone raises this objection like they've discovered a logical flaw in the matrix. Let me be blunt: the number of times a method is called is irrelevant to whether it should exist.
You don't extract a method to avoid duplication (that's a bonus). You
extract a method to
name a concept. A private method called
apply_special_discount is not just code organization
— it's a unit of meaning. It tells the next developer "this block
has a purpose, and here's what it is." Without the extraction, that
purpose is buried inside a wall of implementation details.
"Functions should be small" and "use descriptive names" are two of the
oldest rules in the
clean code playbook.
A single-use private method achieves both simultaneously. Two birds,
one def.
Ruby Was Born for This #
There's a reason this principle feels so natural in Ruby. Matz designed the language for developer happiness, and Ruby's syntax makes small methods almost free. No type annotations. No boilerplate. No ceremony:
Rubyprivate
def check_balance
raise InsufficientFundsError if @customer.balance < @total
end
def check_availability
@items.each do |item|
raise OutOfStockError if item.product.stock < item.quantity
end
end
def apply_special_discount
@discount = DiscountCalculator.new(@customer, @total).calculate
end
def create_shipment
@shipment = Shipment.create!(order: @order, address: @customer.shipping_address)
end
def notify_customer
ShipmentMailer.confirmation(@shipment).deliver_later
end
Each method is 1–5 lines. Each one does exactly one thing. Each
one has a name that makes its purpose immediately obvious. In Java,
this would involve three files and an interface. In Ruby, it's just
def, a name, the logic, and end. You have
no excuse.
RuboCop Agrees (and RuboCop Is the Law) #
This isn't just theory from a book. The Ruby community has codified it into its official tooling. RuboCop, the Ruby linter that "serves and protects" your codebase, enforces several cops that directly encourage the Single Level of Abstraction:
| RuboCop Cop | What It Enforces |
|---|---|
Metrics/MethodLength |
Methods should not exceed 10 lines (default). Forces you to extract. |
Metrics/AbcSize |
Limits assignments, branches, and conditions. Complex methods get flagged. |
Metrics/CyclomaticComplexity |
Limits the number of linearly independent paths. Nested logic gets caught. |
Metrics/PerceivedComplexity |
Measures how complex a method feels to read. Directly targets cognitive load. |
These aren't arbitrary rules. They're mechanical enforcement of the Single Level of Abstraction. When RuboCop tells you your method is too long or too complex, it's telling you that you're mixing abstraction levels. The fix is almost always the same: extract a private method.
"Dead or alive, you're coming with me."
It's no coincidence that the Ruby community's most beloved linter is named after the movie character whose three prime directives are: Serve the public trust. Protect the innocent. Uphold the law. Translated to code:
- Serve the public trust — Write code your team can read and maintain
- Protect the innocent — Protect the next developer who opens your file at 2 AM during an incident
- Uphold the law — Follow the style guide, not because it's bureaucracy, but because consistency is kindness
Rails Core Does This — and You Should Too #
If you think extracting single-use private methods is overkill for "real" code, let me point you to someone who applies this principle in one of the most scrutinized codebases on the planet: Rails itself.
Genadi Samokovarov is a Rails core contributor whose pull requests are masterclasses in clean abstraction. His contributions to Rails demonstrate exactly how the Single Level of Abstraction makes large, complex frameworks comprehensible. Let's look at a few:
ActionableError module lets classes declare actions
declaratively — the what is separated from the
how, with the dispatch mechanism hidden behind a clean
interface. Pure SLA.
HostAuthorization middleware reads like a recipe: check
if the request is authorized, build the response if it isn't, mark the
request. Each step is its own small method. The top-level
call method tells you the entire story in a few lines.
default: option to mattr_accessor. But look
at how it's done: the implementation doesn't bolt default-handling
logic into an existing method. It extends the existing abstraction
cleanly, keeping each method focused on its single responsibility. The
change is small because the architecture already followed SLA.
Notice the pattern? Every one of these contributions touches complex, multi-step logic — security middleware, error handling frameworks, server boot sequences — and every one of them is structured so the high-level flow reads in seconds. That's not accidental. That's SLA applied with discipline.
When you contribute to Rails, your PR is reviewed by some of the sharpest Ruby developers alive. Code that mixes abstraction levels doesn't survive review. Genadi's merged PRs are proof that disciplined method extraction isn't academic theory — it's what passes code review on the most popular Ruby framework in the world.
A Complete Before & After #
Let's take a realistic Rails controller concern and apply SLA. This is the kind of code you'll find in production applications everywhere:
Before: Mixed Abstraction Levels
Rubyclass SubscriptionService
def renew(user)
subscription = user.subscription
return unless subscription&.active?
plan = subscription.plan
price = plan.annual? ? plan.price * 0.8 : plan.price
if user.payment_methods.any?
payment_method = user.payment_methods.default.first
charge = Stripe::Charge.create(
amount: (price * 100).to_i,
currency: "usd",
customer: user.stripe_customer_id,
source: payment_method.stripe_id
)
if charge.paid?
subscription.update!(
renewed_at: Time.current,
expires_at: 1.year.from_now,
status: :active
)
SubscriptionMailer.renewed(user).deliver_later
Analytics.track(user, "subscription_renewed", plan: plan.name)
else
subscription.update!(status: :payment_failed)
SubscriptionMailer.payment_failed(user).deliver_later
end
else
subscription.update!(status: :payment_method_missing)
SubscriptionMailer.missing_payment(user).deliver_later
end
end
end
This method has at least four levels of abstraction crammed together: subscription validation, pricing logic, payment processing, and notification handling. To understand any one of those concerns, you have to wade through all the others.
After: Single Level of Abstraction
Rubyclass SubscriptionService
def renew(user)
@user = user
@subscription = user.subscription
return unless eligible_for_renewal?
charge_customer
extend_subscription
send_confirmation
track_renewal
rescue PaymentFailedError
handle_payment_failure
rescue MissingPaymentMethodError
handle_missing_payment_method
end
private
def eligible_for_renewal?
@subscription&.active?
end
def charge_customer
PaymentGateway.charge!(@user, renewal_price)
end
def renewal_price
PricingCalculator.new(@subscription.plan).discounted_price
end
def extend_subscription
@subscription.update!(renewed_at: Time.current, expires_at: 1.year.from_now)
end
def send_confirmation
SubscriptionMailer.renewed(@user).deliver_later
end
def track_renewal
Analytics.track(@user, "subscription_renewed")
end
def handle_payment_failure
@subscription.update!(status: :payment_failed)
SubscriptionMailer.payment_failed(@user).deliver_later
end
def handle_missing_payment_method
@subscription.update!(status: :payment_method_missing)
SubscriptionMailer.missing_payment(@user).deliver_later
end
end
The renew method now reads like a business process
document. A product manager could read it. A new hire on their first day
could read it. You could read it at 2 AM during a
production incident and immediately know which step to investigate.
The
Principles Wiki
wisely notes that SLA has a counterweight: you don't want to force the
reader into excessive mental inlining — jumping
into tiny methods just to understand a trivial operation. A one-line
guard clause in a high-level method is fine. The goal isn't to extract
everything — it's to extract concepts. If the
code reads clearly at its current level, leave it alone. Nobody wants
a def add_one(x) method. We're not monsters.
Why This Matters for Rails Specifically #
Rails applications have a unique relationship with this principle. Unlike compiled languages where the cost of a method call might give you pause, Ruby's dynamic nature and Rails' conventions make private method extraction almost zero-cost:
- No type signatures to maintain. Extracting a method in Ruby takes 10 seconds, not 2 minutes of type juggling.
- Convention over configuration. Rails already teaches you to think in named abstractions —
before_action,scope,concern. SLA is the same philosophy applied to your own code. - Long-lived codebases. Rails apps tend to live for years, sometimes decades. The readability investment compounds over time.
- Team-scale development. Most Rails teams are 3–15 developers. Code that reads like a recipe means faster code reviews, easier onboarding, and fewer "what does this method do?" Slack messages.
As Genadi Samokovarov's Rails contributions demonstrate, even at the framework level — where performance and backwards compatibility are critical — clean abstraction boundaries win. His HostAuthorization middleware protects millions of Rails applications from DNS rebinding attacks, and it does so through a series of small, clearly named methods that any contributor can understand and extend.
"Serve the public trust. Protect the innocent. Uphold the law."
Also the unofficial mission statement of every well-configured
.rubocop.yml
Conclusion: Write Recipes, Not Novels #
The Single Level of Abstraction is not about making your classes have more methods. It's about making your code tell a story at every level. The high-level method is the table of contents. The private methods are the chapters. Each one is self-contained, named, and focused.
The principle has been around since 2008. RuboCop enforces it mechanically in 2026. Genadi Samokovarov practices it in Rails core. The Ruby community has embraced it as idiomatic style. At this point, if you're still writing 50-line methods, you're not being pragmatic — you're being nostalgic.
The next time you write a method longer than 10 lines, stop. Read it back to yourself. Ask: "Can I describe what this method does as a series of steps?" If yes, those steps are your private methods. Name them. Extract them. Let the public method read like a recipe.
Your future self, your teammates, and that junior developer joining next month will all thank you. And RuboCop will finally stop yelling at you like you owe it money.
This is an April 1st post. Please don't actually do any of this. No production apps were harmed in the making of this post. Probably.
More Posts
- Why useEffect Is the Only React Hook You'll Ever Need
- Tabs vs Spaces vs U+2800: The Indentation Debate Is Finally Over
- I Switched from Git CLI to a GUI and My Productivity Doubled
- How I Work for 10 Companies Simultaneously Using AI
- Why Your Ruby Variables Should Be Full English Sentences
- Always Write Comments: The "Self-Documenting Code" Myth Is Killing Your Codebase
Comments
Ruby was designed so that
defandendwould feel natural. This article understands that. When I see a seven-line method calling seven beautifully named private methods, I feel the happiness I intended. When I see the 47-line version, I feel the pain I was trying to prevent. 10/10 would refactor again.I've been saying methods should tell stories since 2004. But I envisioned short stories, not a table of contents pointing to 23 private methods that each do one thing. You took the Rails Way and drove it off the Rails. Respect.
Applied SLA to a section of the Rails codebase. Opened a PR with 200 new private methods. The CI pipeline is still running. The review comments are longer than the diff. My colleague left a single emoji: 🫠. I consider this progress.
I read this article at 2 AM and immediately refactored my entire Twitch bot. It now has a main method with seven lines and 43 private methods. Chat thinks I've lost it. Engagement has never been higher. Coincidence? I think not. Bookmarked.
I showed this article to my mentees and one of them extracted 47 private methods from a single controller. The controller now reads like a haiku. Each method is two lines. My test suite, however, has filed for emotional support. I'm somehow proud.