Benefits of Having a Call Method for Your Ruby Object

Exploiting Ruby’s Object Model to Ease Collaboration

One of the common patterns I use in my day-to-day Ruby 📖 coding is to define a module or level call method. I like to establish these call methods as crisp interfaces between different concerns.

Dependency Injection with Objects That Respond to Call

In the following example I’ve constructed a “Package” class that has a deliver! method. Let’s take a look:

module Handler
  def self.call(package:)
    # Complicated logic
  end
end

module Notifier
  def self.call(package:)
    # Complicated logic
  end
end

class Package
  def initialize(origin:, destination:, notifier: Notifier, handler: Handler)
    @origin = origin
    @destination = destination
    @handler = handler
    @notifier = notifier
  end
  attr_reader :origin, :destination, :handler, :notifier

  def deliver!(with_notification: true)
    notifier.call(package: self) if with_notification
    handler.call(package: self)
  end
end

If I want to test the deliver! method I have a few scenarios to consider:

  • I’m not sending a notification
  • I’m sending a notification and it raises an exception
  • I’m sending a notification and it succeeds

And let’s assume that both Notifier.call and Handler.call are very expensive to run in test. Without stubbing or mocking, I could write the following test:

def test_assert_not_sending_notification
  # By having the notifier `raise`, we'll know if it was called.
  notifier = ->(package:) { raise }
  handler = ->(package:) { :handled }
  package = Package.new(
    origin: :here,
    destination: :there,
    notifier: notifier,
    handler: handle
  )

  assert(package.deliver!(with_notification: false) == :handled)
end

Dependency Injection Using a Collaborating Object’s Method as a Lambda

There are some interesting pathways we can now go down. First, what if we really don’t like the .call method naming convention?

module PlanetExpress
  def self.deliver(package:)
    # Navigates multiple hurdles to renew delivery licenses
  end
end

We could create an alias of PlanetExpress.deliver but we could also do a little bit of Ruby magic:

Package.new(
  origin: :here,
  destination: :there,
  handler: PlanetExpress.method(:deliver)
)

The Object.method method returns a Method object, which responds to call. This allows us to avoid modifying the PlanetExpress module, while still enjoying the flexibility of a call based interface.

This is perhaps even more relevant when I think about interfacing with ActiveRecord. Are there cases where I want to have found a record and process it? Maybe the creation of that record is expensive. Let’s short-circuit that.

class User < ActiveRecord::Base
end

# An async worker that must receive an ID, not the full object.
class CongratulatorWorker
  def initialize(user_id:, user_finder: User.method(:find))
    @user = user_finder.call(user_id)
  end

  def send_congratulations!
    # All kinds of weird things with lots of conditionals
  end
end

With the above, I can now setup the following in test:

def test_send_congratulations!
  user = User.new(email: "hello@domain.com")
  finder = ->(user_id) { user }
  worker = CongratulatorWorker.new(user_id: "1", user_finder: finder)

  worker.send_congratulations!
end

In the above scenario, I’d be scratching my head if I saw a User.call method declared in the User class. But in the CongratulatorWorker I would have a bit more of a chance of reasoning what was going on.

Using a Method Object as a Block

This example steers in a different direction, but highlights the utility of the convention of having a call method.

module ShoutOut
  def self.say_it(name)
    puts "#{name} is here"
  end
end

["huey", "duey", "louie"].each(&ShoutOut.method(:say_it))

I was hoping that I could define call on ShoutOut and use that as the block (e.g. .each(&ShoutOut)). But that did not work.

Conclusion

This degree of dynamism is one aspect I love about Ruby. And it’s not unique to Ruby; I do this in Emacs-Lisp.

Early in learning Ruby, I stumbled upon a few statements that inform Ruby:

  • Everything’s an Object
  • A Class is an Object and an Object is a Class

And even the methods on an Object are themselves Objects. What’s nice about that is these objects have shape and form; you can see that in “detaching” the method and passing it around.