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.