Mastering the Ruby Method Object: Decoupling Logic with Method#call
The Hidden Power of Method Objects
Most Rubyists are comfortable calling methods on objects. We do it thousands of times a day. However, there is a distinct difference between calling a method and capturing a method. In Ruby, you can turn any method into a first-class object using the method method. This allows you to pass behavior around your application as a variable, effectively treating a method like a Proc or a Lambda without the overhead of redefining it.
I first stumbled upon this while building a complex data validation engine. I had a dozen different validation rules, each living in a separate service class. Initially, I used a giant case statement to decide which validation method to run based on a configuration string. It was brittle, hard to test, and a nightmare to extend. By switching to method objects, I turned that rigid logic into a flexible pipeline.
The Real-World Scenario: Dynamic Strategy Execution
Imagine you're building a payment processing system. Depending on the payment provider (Stripe, PayPal, Adyen), you need to execute different normalization steps on the incoming payload. Instead of writing a complex adapter pattern with multiple classes that do almost the same thing, you can map provider keys directly to the methods that handle them.
class PaymentNormalizer
def normalize_stripe(data)
# Stripe uses 'amount' in cents
{ amount: data[:amount] / 100.0, currency: data[:currency].upcase }
end
def normalize_paypal(data)
# PayPal uses a nested 'transaction' object
{ amount: data.dig(:transaction, :amount), currency: 'USD' }
end
def normalize_adyen(data)
# Adyen requires specific field mapping
{ amount: data[:value], currency: data[:currency_code] }
end
def process(provider, payload)
# Instead of a case statement, we dynamically fetch the method object
method_name = "normalize_#{provider}"
if respond_to?(method_name)
# This is the magic: capturing the method as an object
handler = self.method(method_name)
handler.call(payload)
else
raise ArgumentError, "Unsupported provider: #{provider}"
end
end
end
# Usage
normalizer = PaymentNormalizer.new
puts normalizer.process(:stripe, { amount: 5000, currency: 'usd' }) # {:amount=>50.0, :currency=>"USD"}
puts normalizer.process(:paypal, { transaction: { amount: 25.0 } }) # {:amount=>25.0, :currency=>"USD"}
Why This Beats the Alternatives
You might be thinking, "Why not just use send("normalize_#{provider}", payload)?" While send works, it's a blunt instrument. Capturing the method via method() provides several strategic advantages:
- Decoupling Execution from Definition: You can pass the method object to another service or a background job. The receiver doesn't need to know the name of the method or the class it belongs to; it only needs to know it has a
#callmethod. - Better Introspection: A
Methodobject carries metadata. You can check itsowner, itsparameters, and itssource_location. This is invaluable for debugging complex meta-programming layers. - Consistency: By treating methods as objects, your API becomes consistent with Ruby's functional leanings. Whether you're passing a
Proc, aLambda, or aMethodobject, the interface is always.call.
Pro Tip: When usingmethod(), always pair it withrespond_to?. Attempting to capture a method that doesn't exist will raise aNameError, which can crash your request cycle if not handled.
Integrating with Higher-Order Functions
The true elegance of this technique appears when you combine it with Ruby's enumerable methods. Suppose you have a list of transformation methods that must be applied to a record in a specific order. Instead of manually calling each one, you can create an array of method objects.
class UserProfileCleaner
def strip_whitespace(val); val&.strip; end
def downcase_email(val); val&.downcase; end
def remove_special_chars(val); val&.gsub(/[^a-zA-Z0-9@ ]/, ''); end
def clean(attribute, value)
# Define the pipeline as a list of method objects
pipeline = [
method(:strip_whitespace),
method(:remove_special_chars),
method(:downcase_email)
]
# Reduce the value by calling each method object in sequence
pipeline.reduce(value) { |acc, step| step.call(acc) }
end
end
cleaner = UserProfileCleaner.new
puts cleaner.clean(:email, " User@Example.Com! ") # "user@example.com"
Final Thoughts
Using Method#call transforms how you think about logic flow. It moves you away from rigid conditional structures and toward a more compositional style of programming. It's a lightweight way to implement the Strategy pattern without the boilerplate of creating five different classes for five different behaviors. Next time you find yourself writing a long case statement to delegate work, try capturing the methods instead.