DEV Community

Cover image for Benefits of Having a Call Method for Your Ruby Object
Jeremy Friesen for The DEV Team

Posted on • Originally published at takeonrules.com on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (5)

Collapse
 
kigster profile image
Konstantin Gredeskoul • Edited

While I appreciate the idea of using call method in unexpected places, I can't help but wonder if this will be more confusing for those reviewing or reading the code. As a rubyist when I see something.call I assume that something is a proc, lambda or a method object. I might even use the shorthand version of something[] to invoke it. But if the object in question isn't a proper callable, this might not work.

Second point is that this pattern does not solve the fact that we still have tightly coupled classes Package and Handler ➔ they must know about each other. There is no way to test the deliver! method without invoking both objects.

IMHO this type of problem can be well addressed by using Events as first class citizens, and using the Observer pattern on steroids to decouple the caller from the downstream processing.

Here is a version of your code using the gem ventable which does exactly that — an easy way to create event classes, subscribe various modules or classes to each event either during the application initialization, or dynamically at runtime:

module Handler
  def self.handle_package_delivered(event)
    handle(event.package)
    # Complicated logic
  end
end

module Notifier
  def self.handle_package_delivered(event)
    return unless event.notification
    notify(event.package.notifier)
    # Complicated logic
  end
end

require 'ventable'

class PackageDeliveredEvent
  include Ventable::Event

  attr_reader :package, :notification

  def initialize(package, notification: false)
    @package      = package
    @notification = notification
  end
end

PackageDeliveredEvent.configure do
  notifies Handler, Notifier
  # you can even notify various observers within an ActiveRecord Transaction, 
  # see https://github.com/kigster/ventable#using-configure-and-groups
end

class Package
  attr_reader :origin, :destination, :handler, :notifier

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

  def deliver!(notification: true)
    # do the primary logic that perhaps requires updating
    # the status of the package in the database, and then use
    # event notifications for other "auxiliary" stuff.
    PackageDeliveredEvent.new(
      self,
      notification: notification
    ).fire!
  end
end
Enter fullscreen mode Exit fullscreen mode

I admit that the ventable gem is my creation. It's actually pretty small if you look at the source.

There is a much more expansive and feature-rich event-driven gem called Wisper which can be configured to even execute event handlers in the Sidekiq background jobs with wisper-sidekiq

I wrote a blog post a while ago about detangling Rails logic using events and observers. I believe it is as relevant today as it was back then.

That said, it's always good to think out the box about how various ruby conventions came to be.

Collapse
 
jeremyf profile image
Jeremy Friesen

Memoization is definitely a valid approach; and alas we're working with a very arbitrary object, so

One subtle bonus of DI is that the yard docs will have some guidance on the assumptive collaboration. Which in turn provides some LSP help.

Now, whether that is good guidance (with the expanded API) or not is certainly up for a debate. I had personally considered those two named and defaulted parameters as somewhat of a "private API". But that's also not clear.

Quite a bit to think about.

Collapse
 
jeremyf profile image
Jeremy Friesen

I knew this was possible, and forget the Proc route. Thanks for the reminder!

Collapse
 
djuber profile image
Daniel Uber

There is a .() syntax for sending :call to an object, if you like syntax shortcuts. I never do this personally, but it's there

# obvious way
obj.call(args)
# same as shortcut syntax
obj.(args)
Enter fullscreen mode Exit fullscreen mode

It's almost just right, and just a little bit wrong. I think Trailblazer did this early on, but may be moving away from it.

Collapse
 
kupkovski profile image
Andre Luiz Kupkovski

am I wrong or there's a typo on the first example? On

package = Package.new(
   ...
    handler: handle
  )
Enter fullscreen mode Exit fullscreen mode

shouldn't it be?

package = Package.new(
    ...
    handler: handler
  )
Enter fullscreen mode Exit fullscreen mode