Clean Code

Why Single Level of Abstraction Will Transform Your Rails Code

Your methods should read like a recipe, not like a crime scene. How one deceptively simple principle from Clean Code turns unmaintainable Ruby into code that explains itself.

Monika Georgieva
Monika Georgieva April 1, 2026 · 14 min read

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.

The Principle, in One Sentence

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:

  1. Check the customer's balance
  2. Check product availability
  3. Apply the special discount
  4. Create the order
  5. Create the shipment
  6. Notify the customer
  7. 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.

7
Lines in the main method
3s
To understand the full flow
0
Mental gymnastics required

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."
— RoboCop (1987) — and also RuboCop when it encounters your 47-line method

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:

  1. Serve the public trust — Write code your team can read and maintain
  2. Protect the innocent — Protect the next developer who opens your file at 2 AM during an incident
  3. 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:

#34788 — Introduce Actionable Errors actionpack activesupport railties
Merged Apr 19, 2019 · 71 comments
This PR introduced a beautifully clean pattern for errors that can suggest corrective actions to the developer. Instead of cramming error handling, action resolution, and dispatch logic into a single method, the design separates each concern into its own focused abstraction. The 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.
#33145 — Guard Against DNS Rebinding Attacks by Permitting Hosts actionpack railties
Merged Dec 17, 2018 · 34 comments
A security feature that could easily have been a 200-line monolith. Instead, the 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.
#29294 — Introduce mattr_accessor Default Option activesupport
Merged Jun 4, 2017 · 20 comments
A seemingly small API addition — adding a 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.
#32058 — Introduce Explicit Rails Server Handler Option railties
Merged Mar 4, 2018 · 66 comments
Server handler selection logic — determining which Rack server to boot — broken into clearly named, single-purpose methods. Instead of a nested conditional deciding between Puma, Thin, WEBrick, and fallback behavior in one sprawling block, each handler resolution step is its own method. You can understand the boot sequence without reading the implementation details of each strategy.

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.

The Rails Way

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.

A Word of Balance

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:

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."
— RoboCop's Three Prime Directives (1987)
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.


Comments

Matz
Matz (Yukihiro Matsumoto)
Creator of Ruby

Ruby was designed so that def and end would 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.

DHH
DHH (David Heinemeier Hansson)
Creator of Ruby on Rails

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.

tenderlove
Aaron Patterson (tenderlove)
Ruby & Rails Core Team

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.

ThePrimeagen
ThePrimeagen
YouTuber & Netflix Engineer

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.

Sandi Metz
Sandi Metz
Author of “Practical Object-Oriented Design in Ruby”

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.

More Posts