Ruby Service Objects with Sorbet
I really enjoy working with Sorbet.
Actually I really like working with T::Struct
, everything else that Sorbet provides is sort of just a bunch of nice bonus content.
Today I'm writing about a small technique that I think illustrates the value of T::Struct
and friends.
Recently I was working with a series of service classes that were all structured like this:
class SomeService
class << self
def call(arg1, arg2, arg3, arg4, arg5 = nil, ..., arg13 = nil)
...
end
private
def some_private_method(arg1, arg3, arg5 = nil, ..., arg12 = nil)
SomeOtherService.call(arg3, arg1, arg5, arg12, some_calculated_value)
end
end
end
That is to say, they all had a consistent callable interface with a very large number of nilable positional arguments. This isn't a bad pattern, per se, but it starts bordering on unreadable when you need to pass those arguments around to other methods within the service.
Being how I like to (ab)use Sorbet in fun ways, and that I really wanted all of those arguments to be typed, and that I wanted to convert this to a service object rather than a service class, here's what I ended up with:
class SomeService < BaseService
private
const arg1, String
const arg2, T::Hash[Symbol, String]
...
const arg13, T.nilable(Integer)
def call
...
end
def some_private_method
...
end
end
Notice, right at the top, the private
keyword. Everything in this class is private except the things that are exposed by BaseService
. After that we define some const
things, then no-argument call
and some_private_method
instance methods.
And what does BaseService
look like, you ask?
class ApplicationService < T::InexactStruct
def self.call(**kwargs)
new(**kwargs).send(:call)
end
private
def call
raise NotImplementedError
end
end
There's no real magic here. The interesting stuff happens in T::InexactStruct
where it creates a nice constructor for you and handles all of the const
and prop
initialization. T::InexactStruct
is exactly like a T::Struct
except you can subclass from it which T::Struct
prevents subclassing for, really, no good reason other than performance.
The only other weird thing happening is that .send(:call)
, and the only reason we're doing that is so that we can have a private instance-level call
method. It's not absolutely required, but it considerably narrows the public interface of ApplicationService
-derived classes.
I think this is a nice pattern that lets you use Sorbet's props to make a clean and minimal interface for your service objects.
What do you think? Is this something you'd use? Is it super gross and you hate it? Either way, lemme know by emailing pete@petekeen.net.