Notes on writing Service Objects

Paweł Świątkowski - Jul 4 '18 - - Dev Community

Service Objects are probably a single most popular technique for refactoring Ruby applications. However, there is one little with them: there are (too) many ways to write them. And because different people use service objects different way, they tend to get messy too.

I wrote dozens of SOs since I joined Boostcom 1.5 year ago. This is mostly because we don't really use Rails, so they are even more natural. We never established strict rules to follow while writing services, however, they all tend to look similar. It would seem that the way we write them works for us, so I'd like to share a few tips about what I think service objects should look like.

Here is a list as bullet points, with longer explanation coming later in the post:

  • Only one public method
  • Use .new explicitly
  • Only use instance variables in a constructor
  • Don't overuse private methods (stay flat)
  • Don't be afraid of local variables
  • Reuse service instances in heavy loops
  • Bonus: Return value objects
  • Bonus level hard: Use monads
  • Break the rules

Only one public method

Since classes generally should follow Single Responsible Principle, we should apply it to service objects too. One responsible means only one public method – and one way to use the service.

I usually call it simply call. This has a few benefits:

  • You can use Ruby magical shortcut syntax service.(arg). This might not be a killer feature, but sometimes is nice to have
  • If method's name is call, you need to give a proper name to a service, which is good
  • "Calling" services seems natural

I know other people who have completely different opinion on this subject and say you should never call your method call. This is, however, not really important, as long as there is only one method.

Use .new explicitly

I know many people like writing a shortcut like this:

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

  def call(args)
    # do something
  end
end

I admit: there is a certain appeal to writing MyService.call(1,2,3) instead of longer and seemingly redundant MyService.new.call(1,2,3). It's certainly shorter and you usually don't reuse service objects instances anyway. I actually don't think it's the best way.

On the other side lies this counterproposal: MyService.new(my_secret_data).call. I must say I use it sometimes, but I also think this is not perfect. Why? By passing the data to initializer and assigning to an instance variable, you are later tempted to reuse the service with a different method (and same data). This breaks the previous rule about only one public method. For example:

service = MyService.new(data)
service.remove_dulicates!
filtered = service.filter_invalid
service.send_valid_to_api(filtered)

I wouldn't dare say that this is bad per se but it allows your service objects to grow uncontrollably. If you have self-discipline to avoid that, this might be your way. But I think there are other possibility worth exploring.

At some point in your service-objects-writing quest, you will probably discover dependency injection. And you will want to rewrite the services like that:

# before
class MyService
  def call(data)
    filtered = FilteringService.new.call(data)
    ApiSender.new.call(filtered) # where are my Elixir pipes? :(
  end
end

# after
class MyService
  def initialize(filterer: FilteringService.new, api_sender: ApiSender.new)
    @filterer = filtering
    @api_sender = api_sender
  end

  def call(data)
    filtered = filterer.call(data)
    api_sender.call(filtered) # where are my Elixir pipes? :(
  end

  private
  attr_reader :filterer, :api_sender
end

Why? For example for testing MyService without needing to stub both services it depends on. But that's a whole different story... Anyway, by leaving constructor unused at first, it is really easy to put dependency injecting code there later. That's why I think that MyService.new.call is a way to go if you don't have any other strong opinion.

Only use instance variables in a constructor

As seen in the example above, instance variables are only used in the constructor. This is a way it should be, in my opinion. If you use them in any other place, user attr_reader and avoid mutating those variables at any cost.

There might be a question whether those getters should be private or not. I think they should, but it's not really a big deal.

One more hint: use dry-initializer which does the job for you.

require 'dry-initializer'

class MyService
  extend Dry::Initializer

  param :filterer, default: proc { FilteringService.new }
  param :api_sender, default: proc { ApiSender.new }

  def call(data)
    filtered = filterer.call(data)
    api_sender.call(filtered)
  end
end

Don't overuse private methods

If your service is getting complicated (and sometimes it has to), you will add more and more private methods to keep things short and Rubocop happy. In time, the code will become hard to read. Look at this example:

class BadService
  def call(data)
    send_filtered_data_to_api(data)
    notify_subscribers(data)
  end

  private

  def notify_subscribers(data)
    SubscribersNotifier.new.call(data)
  end

  def send_filtered_data_to_api(data)
    api_sender.call(filtered(data))
  end

  def filtered(data)
    filterer.call(data)
  end

  def api_sender
    @api_sender ||= ApiSender.new
  end

  def filterer
    @filtering_service ||= FilteringService.new
  end

How many jumps you had to do in order to read what this code does? The answer is: a lot call -> send_filtered_data_to_api -> api_sender -> send_filtered_data_to_api -> filtered -> filterer -> filtered -> send_filtered_data_to_api -> call -> notify_subscribers -> call. Or using a different notation:

call
  send_filtered_data_to_api
    api_sender
    filtered
      filterer
  notify_subscribers

As you see, we have four levels of calls here. I think it only should be two of them. That's why I also sometimes call this rule "stay flat".

In my opinion, a perfect way is to only use private methods in call. It means both: nothing else than private methods in call and only call can use private methods. This way you keep the entry method as a kind of high-level description of what service does. Of course, private methods should be defined in the same order that they are called, so you don't need to refer to what call does when you read them.

This way even services that do a lot can stay simple to read:

class RequestHandler
  def call(params)
    valid_params = filter_valid_params(params)
    save_to_database(valid_params)
    notify_subscribers(valid_params)
    log_params(params)
    send_measures(params)
  end

  private

  # [...]
end

Sidenote: if your private methods start to do too much and be too long, you probably need to introduce another service.

Don't be afraid of local variables

Earlier this year I've read about a thing called The Local Variable Aversion Antipattern and I started to notice it a lot in Ruby code.

Basically, you don't need to initialize everything in a separate method. Also, there's no need to memoize everything – usually you use it only once anyway. So there is nothing better in this:

def send_to_api(data)
  api_sender.call(data)
end

def api_sender
  @api_sender ||= ApiSender.new
end

Than this:

def send_to_api(data)
  sender = ApiSender.new
  sender.call(data)
end

The latter is actually easier to follow and does not break the rule about setting instance variable only in initializers.

Reuse service instances in heavy loops

This is probably pretty obvious, but we talk very little about performance when it comes to high-level Ruby code, so I dare to say it aloud.


# DON'T

large_array.each do |item|
  ItemProcessor.new.call(item)
end

# DO

processor = ItemProcessor.new
large_array.each do |item|
  processor.call(item)
end

Ruby objects are relatively cheap, but they are not free. If you can avoid creating unnecessary objects, you'll benefit from it at some point.

Bonus: Return value objects

It is a good habit to return service objects when you do something more complicated than just streamlining data. At some point you will like to know more about what happened in high-level service objects and you will start to create little monsters like this:

class MyHighLevelService
  def call(data)
    pg_result = save_to_postgresql(data)
    kafka_result = send_to_kafka(data)
    worker_ids = schedule_workers(data)
    [pg_result, kafka_result, worker_ids]
  end
end

# ...

pg_result, kafka_result, worker_ids = MyHighLevelService.new.call(data)

Value objects are much better for it.

Bonus level hard: Use monads

I'm not going to say much more, as this is more of a point on my checklist to start doing. But sometimes what dry-monads offer will very easily fit your flow of data. But don't try to squeeze them everywhere just because "monads are cool". As Piotr Solnica once said:

Last but not least: Break the rules if necessary

This is just a set of suggestions. I think they are good and cohesive, but they are suggestions anyway. Even I don't always follow them. Sometimes there are good reasons not to. For example, when all other things (for example coming from external gems you have no control over) are using Blah.call(1), maybe your services should use that notation too? Otherwise you will find yourself wondering everytime what to write.

Don't start changing everything in your code just because some dude on the Internet says he does it otherwise. Make conscious decisions, try to adhere to them, but give yourself permission to sometimes stray away. Context is everything, after all, and yours can be very different than mine.




This has been also posted on my blog.


. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .