Skip to main content

Improving callable service objects with private constructors in Ruby

Privatizing the constructor methods of callable services in Ruby on Rails for cleaner APIs and tests.

· 6 min read
Max Mulatz

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)

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

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

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

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

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

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

We can then shorten the service to:

class Authenticator
include Callable

def initialize(user)
@user = user
end

def call
# actual logic
end
end

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

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

Max Mulatz

Max Mulatz

Whitespace wrestler on a functional fieldtrip

We're hiring

Work with our great team, apply for one of the open positions at bitcrowd