23
Decorating Service Objects - FunctionalObject
A common pattern used in Ruby programs are Service Objects. They help enforce the Single-responsibility Principle throughout the app, which in turn makes the program easier to test, debug and reason about.
result = CheckoutService.new(item: item, user: user).call
if result.successful?
...
At my workplace, we use service objects extensively in our codebase. One modification we made to them was to shorten the code it takes to invoke the service. All our service objects are invoked like this:
result = CheckoutService.(item: item, user: user)
if result.successful?
...
It's a minor code shortening, but it also enforces a single interface to invoke the class within the entire program. It's like we are saying "Only invoke services with the #call
method".
Implementing this pattern is pretty straightforward.
First, we define a base class for all our Service Object's.
class ApplicationService
def self.call(...)
new(...).()
end
def call
raise "Not Implemented"
end
end
Here, we do two things.
def self.call(...)
new(...).()
end
One, we define a class method self#call
. It doesn't care what arguments it receives (see this footnote if you're wondering what (...)
does), it just forwards them to new()
and then immediately invokes #call
by chaining .()
behind it.
def call
raise "Not Implemented"
end
Two, we enforce that all subclasses define #call
- we raise an error if it isn't implemented.
Then we make all our service classes inherit from our base class.
class FooService < ApplicationService
def initialize(name: )
@name = name
end
def call
"Hello #{@name}"
end
end
That's it. All classes that inherit from ApplicationService
needs to adhere to the #call
class signature, and can be invoked with the shorter method.
FooService.(name: "Dan")
=> "Hello Dan"
As you can see, this pattern can be applied to any Ruby class. In the future, you might have other classes in your program that you also want to invoke with this shorthand method. In that case, you can implement it as a mixin.
module FunctionalObject
module ClassMethods
def call(...)
new(...).()
end
end
def self.included(klass)
klass.extend(ClassMethods)
end
def call
raise NotImplementedError
end
end
Now any class that wants to behave similarly can just include the mixin, like so.
class ApplicationService
includes FunctionalObject
end
Since Ruby 2.7, the (...)
operator can be used to forward all arguments from a forwarding method to a concrete method.
def forwarding_method(...)
concrete_method(...)
end
See the official docs for more info.
23