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.

Posted in: Programming