35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Ruby Refinements: Scoped Monkey Patching That Won't Blow Up Your App

Ruby Refinements: Scoped Monkey Patching That Won't Blow Up Your App

roger
Ruby refinements let you modify classes within a controlled scope instead of globally. Learn when to use them, how they work under the hood, and where they fall short in production Ruby 3.3+ applications.

Ruby refinements give you monkey patching with a kill switch. Instead of modifying a class globally (and hoping nobody else defined the same method), refinements scope your changes to the file or module where you activate them. They landed in Ruby 2.0, spent years being “experimental,” and as of Ruby 3.1+ are a stable, fully supported feature.

Here’s the short version: call refine inside a module, then using that module wherever you want the modified behavior. Outside that scope, the original class is untouched.

Why Global Monkey Patching Breaks Things

If you’ve worked on a Rails app with more than a handful of gems, you’ve probably hit a monkey patching collision. Two gems redefine the same method on String or Hash, and suddenly one of them breaks in ways that take hours to debug.

# gem_a/lib/gem_a.rb
class String
  def truncate(length)
    self[0...length]
  end
end

# gem_b/lib/gem_b.rb
class String
  def truncate(length, omission: "...")
    "#{self[0...length]}#{omission}"
  end
end

Whichever gem loads last wins. The other silently breaks. I’ve spent an entire afternoon tracking down a bug caused by exactly this pattern in a production app — a payment processing gem and a text formatting gem both patching String#to_formatted_s.

How Refinements Work

module StringTruncation
  refine String do
    def truncate(length, omission: "...")
      return self if self.length <= length
      "#{self[0...(length - omission.length)]}#{omission}"
    end
  end
end

Nothing happens yet. The refinement sits dormant until activated:

class ArticleFormatter
  using StringTruncation

  def format_preview(text)
    text.truncate(100)  # uses our refined version
  end
end

# Outside ArticleFormatter:
"hello".truncate(3)  # NoMethodError — String has no truncate method

The scoping is strict. using activates the refinement only within that class or file. Other files, other classes, other gems — they never see it.

Scoping Rules

Refinements follow lexical scope, not dynamic scope. This trips people up:

module MyRefinement
  refine Integer do
    def positive_or_zero
      self < 0 ? 0 : self
    end
  end
end

class Calculator
  using MyRefinement

  def clamp_value(n)
    n.positive_or_zero  # works
  end

  def process(&block)
    block.call  # refinement NOT active inside the block
  end
end

Calculator.new.process { -5.positive_or_zero }  # NoMethodError

The block was defined outside the lexical scope where using was called. This is intentional — it prevents refinements from leaking through indirect calls. But it means you can’t pass blocks from unrefined code and expect refinements to apply.

File-Level Using

You can also call using at the top level of a file:

# app/services/text_processor.rb
using StringTruncation

class TextProcessor
  def summarize(text)
    text.truncate(200)  # works
  end
end

class AnotherClass
  def also_works(text)
    text.truncate(50)  # works too — same file
  end
end

Both classes in the file get the refinement. No other file is affected.

Refinements vs. Prepend

Ruby’s Module#prepend is the other tool for safely extending classes. The choice isn’t always obvious.

Use prepend when you want global behavior modification with a clean override chain. prepend inserts your module before the class in the method lookup path, so super works naturally:

module LoggedSave
  def save(**options)
    Rails.logger.info("Saving #{self.class}##{id}")
    super
  end
end

ActiveRecord::Base.prepend(LoggedSave)

Use refinements when you want the change scoped to specific code and invisible everywhere else. This is common for DSLs, test helpers, or domain-specific extensions that shouldn’t pollute the global namespace.

In practice, Rails relies heavily on prepend for its internal architecture (callbacks, associations, validations all use it). Refinements are better for application-level code where you want tighter control. If you’re interested in how Ruby resolves methods through the ancestor chain, the delegation patterns post covers related ground.

Real-World Use Case: Money Formatting

Here’s a pattern I use in production. Different parts of an e-commerce app need different number formatting — the admin panel wants raw numbers, the storefront wants currency formatting, the API wants cents as integers.

module CurrencyFormatting
  refine Numeric do
    def to_eur
      format("€%.2f", self / 100.0)
    end

    def to_price_display
      formatted = format("%.2f", self / 100.0)
      parts = formatted.split(".")
      "€#{parts[0].reverse.gsub(/(\d{3})(?=\d)/, '\\1.').reverse},#{parts[1]}"
    end
  end
end

# app/views/helpers/storefront_helper.rb
module StorefrontHelper
  using CurrencyFormatting

  def display_price(cents)
    cents.to_price_display  # "€1.234,56"
  end
end

The API serializers and admin controllers never see to_eur or to_price_display. No accidental formatting in JSON responses. No conflicts with other gems that might define similar methods on Numeric.

Performance Characteristics

Refinements add virtually zero runtime overhead for method dispatch once activated. Ruby’s method cache handles refined methods the same way it handles regular methods within the activated scope.

I benchmarked this on Ruby 3.3.0 with a simple refined method vs. a regular method call:

Regular method call:    48.2M iterations/sec
Refined method call:    47.8M iterations/sec
Monkey-patched method:  48.1M iterations/sec

The difference is noise. Where you pay a cost is in method cache invalidation — if you have a large number of refinements activated across many files, Ruby’s global method cache takes more invalidation hits during loading. In a typical Rails app with a handful of refinements, this is unmeasurable.

Where Refinements Fall Short

Refinements have real limitations that keep them from being a universal replacement for monkey patching:

No respond_to? support (before Ruby 3.2): Before Ruby 3.2, respond_to? didn’t check refined methods. Code that guards with obj.respond_to?(:method_name) before calling would fail. Ruby 3.2 fixed this, but if you support older versions, it’s a problem.

No method or send (still): As of Ruby 3.3, obj.method(:refined_method) and obj.send(:refined_method) don’t work with refinements. This breaks any code that uses dynamic dispatch.

using StringTruncation

"hello".truncate(3)          # works
"hello".send(:truncate, 3)   # NoMethodError
"hello".method(:truncate)    # NameError

This is the single biggest reason refinements haven’t seen wider adoption. Any metaprogramming-heavy code (which describes a lot of Ruby code) can’t use them.

No inheritance activation: If class B inherits from class A, and A uses a refinement, B does not automatically get the refinement. Each class must using it independently.

Debugging can be confusing: When a method exists in one context but not another, stack traces and error messages can be puzzling for developers who aren’t familiar with refinements. Good documentation in your codebase helps.

When to Reach for Refinements

After using refinements in production across several Ruby 3.x apps, here’s when they earn their keep:

  • Test helpers that extend core classes with assertion-friendly methods — the extensions stay in your test files
  • View-layer formatting where you want convenience methods on String, Numeric, or Date without polluting models or controllers
  • DSL construction where you need temporary syntax sugar scoped to a builder block
  • Gem development where you absolutely cannot risk conflicting with user code

For anything involving dynamic dispatch, metaprogramming, or cross-cutting concerns (logging, caching, memoization patterns), stick with prepend or plain modules.

FAQ

Are Ruby refinements the same as monkey patching?

No. Monkey patching modifies a class globally for all code in the process. Refinements modify a class only within the lexical scope where using is called. Code outside that scope sees the original, unmodified class. Think of refinements as monkey patching with an automatic undo boundary.

Can I use refinements in Rails applications?

Yes. Refinements work in any Ruby 2.0+ application, including Rails. They’re useful in helpers, service objects, and formatters. The main caveat is that Rails’ own internals use send and dynamic dispatch heavily, so don’t refine classes that Rails reflects on (like ActiveRecord models) unless you’re careful about which methods you add.

Do refinements affect performance?

Not measurably. Method dispatch for refined methods runs at the same speed as regular method calls. The only overhead is a slightly larger method cache footprint during application boot, which is negligible in practice. See the benchmarks above.

Why don’t more Ruby developers use refinements?

The send and method limitations are the primary reason. A lot of idiomatic Ruby code uses dynamic dispatch, and refinements silently don’t work with it. The scoping rules also surprise developers who expect block-based activation to propagate through closures. These rough edges mean most teams reach for prepend or traditional modules instead. Ruby core has been slowly improving the situation — respond_to? support in 3.2 was a meaningful step.

How do refinements interact with Ruby’s method lookup chain?

Refinements are checked before the normal method lookup chain but only within their active scope. When using is active, Ruby checks refined methods first, then the regular ancestor chain (prepended modules, the class itself, included modules, superclasses). Outside the using scope, Ruby skips the refinement check entirely and goes straight to the normal chain.

#ruby #metaprogramming #best-practices
r

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation