What if service objects were just Procs?

by Jared Norman
published August 03, 2023

I have a complicated relationship with service objects. Rails' Convention over Configuration philosophy successfully freed web developers from the swaths of XML configuration that were the norm in the Java ecosystem, but that community's rich domain modelling techniques also fell by the wayside as the framework was adopted by a new generation of programmers.

The default Rails structures are great for simple applications but have limits. As Rails grew in popularity and applications grew, the community tried to navigate that complexity, moving from "fat" controllers to "fat" models.

While some people saw the opportunity to build rich domain objects in the style of the object-oriented programmers that came before us, others reached for a simpler pattern, service objects.

Don't get me wrong; service objects aren't a terrible idea, but neither are they a great one. Like "Fat Model, Skinny Controller", service objects work well up to a certain point but become difficult to work with once a critical level of complexity is reached.

Most service objects you find in the wild follow a basic pattern. They have a single call method that performs whatever operation the object represents. This mirrors the API of other callables, like procs and lambdas. They either take their arguments to their constructor or that call method. Here's an example:

class RobotBuilderService
  def initialize(parts_repository:)
    @parts_repository = parts_repository
  end

  def call(robot_specification)
    Robot.new(
      head: @parts_repository.fetch_part(robot_specification.head),
      body: @parts_repository.fetch_part(robot_specification.body),
      arms: robot_specification.arms.map {
        @parts_repository.fetch_part(_1)
      },
      legs: robot_specification.legs.map {
        @parts_repository.fetch_part(_1)
      }
    )
  end
end

This service builds a robot from a specification, fetching the actual parts from another object, the parts repository, and assembling them. The parts repository is passed into the constructor so that if we need to make a whole bunch of robots, we don't need to instantiate the service repeatedly. We can also instantiate the service and pass it to objects that don't need to know where the parts are coming from and only need to construct robots.

Side note: You might be thinking, "Jared, this is the factory pattern!" Sorry, "factories" are a Java thing, so this couldn't possibly be a factory. You must be mistaken. We Rails programmers hate Java things.

We've got our service whose whole public API is one method, call. Procs also respond to call, so if that's all we care about, we should be able to maintain the exact same API with a Proc. Let's refactor.

module RobotBuilderService
  def self.new(parts_repository:)
    Proc.new { |robot_specification|
      Robot.new(
        head: parts_repository.fetch_part(robot_specification.head),
        body: parts_repository.fetch_part(robot_specification.body),
        arms: robot_specification.arms.map {
          parts_repository.fetch_part(_1)
        },
        legs: robot_specification.legs.map {
          parts_repository.fetch_part(_1)
        }
      )
    }
  end
end

We change our service class into a module and give it a new method which returns a Proc that captures the parts repository rather than storing it in an instance variable. The result is API parity. Consumers of this service wouldn't notice the difference unless they inspected the object or checked its class.

You shouldn't actually do this. It's unusual for the sake of being unusual. There's also the practical downside that you can't refactor any of your service's logic into private methods with this approach.

This example illustrates just how analogous service objects are to first-class functions. In functional parlance, we've created a higher-order function, a function that returns a function that can then be passed to other functions. It's not meaningfully different from this JavaScript snippet:

const robotBuilder = (fetchPart) => (robotSpecification) => new Robot({
  head: fetchPart(robotSpecification.head),
  body: fetchPart(robotSpecification.body),
  arms: robotSpecification.arms.map((partSpec) => fetchPart(partSpec)),
  legs: robotSpecification.legs.map((partSpec) => fetchPart(partSpec))
});

This is neither a good nor bad thing. If you like writing your Ruby code in a more functional style, you'll appreciate this about service objects. If you prefer more traditional object-oriented domain modelling, then you probably don't like service objects that much anyway.

Either way, it illustrates the pattern's relationship with functional programming and how we can borrow ideas from other programming paradigms and communities.