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 💅.