Skip to content

Elegant service objects

Posted on:February 27, 2021

Service objects are one of my favourite design patterns. They aren’t glamorous, but can bring a lot of organizational value to a code base. They’re easy to test, and can take a lot of complexity out of your models and controllers.

The basic idea behind a service object is to use the power of objects to encapsulate our business logic. While we could just as easily pull our business logic into a series of class methods, we wouldn’t be able to achieve the same level of encapsulation that can be achieved with an object.

Here’s an example of a basic service object that updates the balance of an order.

class UpdateOrderBalance
  def initialize(order)
    @order = order
  end

  def call
    @order.balance = @order.total - payment_total
  end

  private

  def payment_total
    @payment_total ||= @order.payments.sum(&:amount)
  end
end

And here it is in action.

UpdateOrderBalance.new(order).call

Cleaning up the interface

Great! We’ve made our first service object. Though calling new preceded immediately by call isn’t exactly a pattern that exudes elegance. Ultimately we still want to instantiate our service object and then invoke it, but we want to look good doing it.

One way we can accomplish this is by building a new base class, which abstracts away the logic of calling new and call.

class Service
  def self.call(*args)
    new(*args).call
  end
end

We can then update our UpdateOrderBalance service object to make use of our new base service class.

require_relative 'service'

class UpdateOrderBalance < Service
  def initialize(order)
    @order = order
  end

  def call
    @order.balance = @order.total - payment_total
  end

  private

  def payment_total
    @payment_total ||= @order.payments.sum(&:amount)
  end
end

The end result is a much cleaner interface for our service objects, as we need only invoke a single method.

UpdateOrderBalance.call(order)

This is admittedly a minor improvement, but if I’m going to be writing potentially hundreds of service objects, I don’t want to work with a verbose interface. Ruby is a joy to work with, and I want my service objects to incite that same joy.

Taking it a step further

Because I’m a lazy software developer, I don’t want to have to write the name of each argument three times when defining the initialize method for new service objects. One option for getting around this repetition is to make use of the dry-initializer gem from the dry-rb collection.

It’s a good idea to think twice about adding new dependencies to any project, so consider this an optional improvement, depending on your appetite for additional dependencies.

require 'dry-initializer'

class Service
  extend Dry::Initializer

  def self.call(*args)
    new(*args).call
  end
end
require_relative 'service'

class UpdateOrderBalance < Service
  param :order

  def call
    order.balance = order.total - payment_total
  end

  private

  def payment_total
    @payment_total ||= order.payments.sum(&:amount)
  end
end

The external interface of our service object remains unchanged, but we’ve cut down on some of the boilerplate required. Beyond cleaning things up, dry-initializer offers some additional benefits. If you’re interested, I recommend checking out the dry-initializer docs.

Wrapping up

I’m a big fan of service objects. I write a lot of them, and want the process to be as enjoyable as possible. Ease of use is important with any tool; if it’s painful to use, people won’t reach for it.