DEV Community

Cover image for Improving callable service objects with private constructors in Ruby
Max for bitcrowd

Posted on • Originally published at bitcrowd.dev

Improving callable service objects with private constructors in Ruby

We recently found a nice practical use case for private class methods in Ruby: the constructor methods of what we will refer to here as "callable services" in Ruby on Rails projects.

Callable Services ☎️

First off: what do we mean with "callable services" in Ruby on Rails applications?

Drawers 🗄

In the real world, hardly any Ruby on Rails web application only consists of simple, atomic CRUD (create, read, update, delete) operations on plain resources. Even the famous blog engine built in 15 minutes may at some point incorporate logic beyond the complexity of creating or deleting posts. Take for instance translations, previews, comment moderation, etc. Real world applications model a specific domain and therefore include control flows, procedures and "business logic" tied to that domain. These bits are in fact what makes an app unique and interesting.

When it comes to organizing code, Rails people traditionally aim for "skinny controllers and fat models". But as models packed with responsibilities beyond modeling the domain soon tend to get overweight and hard to maintain, the community started to reach for additional concepts and patterns to organize their domain specific code. One "drawer" one may come across in a lot of Rails applications are "services", also referred to as "service objects" or "procedures". They are usually organized under the app/services/ directory and encapsulate functionality to handle domain-specific logic, such as checking our a cart, registering for the site or starting a subscription. ¹

Services are usually implemented as POROs ("plain old Ruby objects") which, upon a given input, perform a set of operations and return a predictable response. They are easy to unit test and help developers to maintain a sense for the bigger picture by hiding away the internal details in what appears to the outside as a large black-box-function. That is why people started to implement them as classes exposing only a single method, call or run. Depending on its internals and their complexity a service may create a new instance for each call or just utilize a single class method for its work:

class Authenticator
  def self.call(user)
    # complex logic
  end
end

# invoking the service:
Authenticator.call(user)
Enter fullscreen mode Exit fullscreen mode

The same service implemented to use a new instance for each call:

class Authenticator
  def initialize(user)
    @user = user
  end

  def call
    # complex logic, operate on @user
  end
end

# invoking the service:
Authenticator.new(user).call
Enter fullscreen mode Exit fullscreen mode

Conventions 🧘

At bitcrowd we - as you know - love conventions and therefore usually strive for a common API for our classes in app/services in Rails projects. So even if we don't know what's in the box, we at least know we're dealing with a box… Picking up the previous example, this could look like this:

class Authenticator
  def initialize(user)
    @user == user
  end

  def call
    # complex logic
  end

  private

  attr_reader :user

  def private_helper_method
    # some bits of logic, can operate on user
  end
end
Enter fullscreen mode Exit fullscreen mode

So within the project all services follow the same general structure:

  • a constructor takes all the core input data the service needs to do its work
  • a single exposed call method invokes the service to perform the actual work

Simplifications 🧹

The functionality encapsulated within a service is seen as one operational unit on the outside. So we probably won't interfere between initializing the service and calling it. If the data needed to be manipulated between new and call, we should probably rather think about drawing the boundaries between our objects differently instead. But having things clearly encapsulated, we could also simplify the service' API to Service.call(<input-data>) and hide the implementation details of it using a new instance for each call inside of it. Since Ruby 2.7, we also make use of the 3 dots argument forwarding syntax ² - also referred to as "forward everything" - for the .call class method:

class Authenticator
  def initialize(user)
    @user = user
  end

  def self.call(...)
    new(...).call
  end

  def call
    # complex logic
  end
end
Enter fullscreen mode Exit fullscreen mode

This API makes our service more streamlined and predictable. The service is easier to read and less verbose on the outside, hiding implementation details inside the class itself. We're also less likely to accidentally sneak code between new and call when invoking the service. And while it's aesthetically pleasing on the eye, it also allows us to write less verbose expectations in our unit tests for code which interacts with the service:

# Before
let(:service_instance) { instance_double(Authenticator) }

it 'calls the service' do
  expect(Authenticator).to receive(:new).with(user).and_return(service_instance)
  expect(service_instance).to receive(:call)
  # ...
end

# After
it 'calls the service'
  expect(Authenticator).to receive(:call).with(user)
  # ...
end
Enter fullscreen mode Exit fullscreen mode

While we previously needed two expectations to ensure both, the service being initialized with the right data and then being invoked, we can now do both in one step.

Communication 📢

Even with the new simpler API, our services can of course still be called in "the old" way, initializing and calling the service in two steps. The API for this approach is public and aside from examples in the code or documentation, we don't have anything at hand to ensure the service is used as intended. People may still happily do Authenticator.new(user).call and use instance doubles in their tests… The new API only gives a hint on how to use services in the project, it does not actually encourage or enforce one unified style.

Private Constructors to the Rescue 🚒

Turns out we can make use of Ruby's private_class_method method on Module to hide the constructor and make our intentions on how to use the services more obvious:

Makes existing class methods private. Often used to hide the default constructor new.

Adapting our example:

class Authenticator
  def initialize(user)
    @user = user
  end

  private_class_method :new

  def self.call(...)
    new(...).call
  end

  def call
    # actual logic
  end
end
Enter fullscreen mode Exit fullscreen mode

With this we introduce a new problem though: the visual overhead of the additional boilerplate code makes the actual service implementation harder to read and understand. One way to circumvent this and allow the readers to stay focussed on the actual business logic, would be extracting the boilerplate into a concern:

module Callable
  extend ActiveSupport::Concern

  included do
    private_class_method :new
  end

  class_methods do
    def call(...)
      new(...).call
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We can then shorten the service to:

class Authenticator
  include Callable

  def initialize(user)
    @user = user
  end

  def call
    # actual logic
  end
end
Enter fullscreen mode Exit fullscreen mode

This ensures our service is now used as intended. Trying to call its parts separately as Authenticator.new(user).call results in an error:

NoMethodError: private method `new' called for Authenticator:Class
Enter fullscreen mode Exit fullscreen mode

Doing so, we can a clean and concise outer API for our services while not sacrificing readability on its internals. Invoking a service with its single exposed call class method ensure we pass the initial data to set up the state it needs to perform its work and then immediately trigger the actual "work" part.

TL;DR

Use private_class_method to hide the initializer of your service objects for a clearer API and less boilerplate in tests 💅.

References

Top comments (2)

Collapse
 
thorstenhirsch profile image
Thorsten Hirsch • Edited

That's a very interesting pattern. Do you also have a CallableSingleton module? Please share more of your Ruby conventions.

Collapse
 
klappradla profile image
Max

Glad it's helpful for you!

I don't remember seeing a CallableSingleton module... 🤔

On the topic of more Ruby stories: I just published another small post about prepending modules 👇
bitcrowd.dev/prepending-modules-to...