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:
One more thing re monads - I totally recommend using dry-monads in places where it’s useful, like composing operations with nice result handling etc. Something that would be “too much”, is using monads consistently everywhere, for everything.
— Piotr Solnica (@\_solnic\_) June 26, 2018
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.
Top comments (1)
I'm aware of its existence (I even used it once in production) but then kind of forgot about it. Thanks for reminding me. Also, I need to get back to reading Exploding Rails, it seems ;)