Service objects are great when you need to abstract/move some business logic away from a model or controller. Ideally you keep your models and controllers small and simple.
By moving logic to a service object it is also much easier to test what you're trying to achive, instead of needing to put multiple methods within a model/controller which may only exist for that one purpose.
Here is an example service object which simply lets us process data from csv text.
class ImportStuffService
def initialize(csv_body:)
@csv_body = csv_body
@errors = []
end
attr_reader :errors
def process
validate
return false if errors.any?
csv_rows.each do |row|
do_the_thing(row)
end
true
end
private
def validate
# Verify the data in the CSV is of the correct format
# => Check for valid/invalid headers
# => Verify data is of the correct format
# => etc...
csv_rows.each_with_index do |row, index|
if some_failure
errors << "Failure on row #{index}: The problem"
end
end
end
def csv_rows
@csv_rows ||= CSV.parse(csv_body, { headers: true })
end
def do_the_thing(row)
# Do stuff w/the data
# MyObject.new...
end
end
Using the service object:
service = ImportStuffService.new(csv_body: '...')
if service.process
# Success!
else
# Failure, we can examine service.errors now
end
The above achieves a few things:
- It validates that the csv data we receive is correct/useable before it attempts to do anything.
- It stores errors on an errors object so we can understand why the service failed.
The first example just showed us how to handle failures by just returning true/false. But if we had quite a few service objects that needed to consume one another? Checking the return values and passing the errors array up/down could be quite cumbersome.
We could instead have a process!
method raise exceptions when problems are found. Like the below:
class ImportStuffService
class ImportStuffError < StandardError; end
def initialize(csv_body:)
@csv_body = csv_body
@errors = []
end
attr_reader :errors
def process!
validate!
csv_rows.each do |row|
do_the_thing(row)
end
end
private
def validate!
# Verify the data in the CSV is of the correct format
# => Check for valid/invalid headers
# => Verify data is of the correct format
# => etc...
csv_rows.each_with_index do |row, index|
if some_failure
raise ImportStuffError.new("Failure on row #{index}: The problem")
end
end
end
def csv_rows
@csv_rows ||= CSV.parse(csv_body, { headers: true })
end
def do_the_thing(row)
# Do stuff w/the data
# MyObject.new...
end
end
Then to use the service object:
service = ImportStuffService.new(csv_body: '...')
service.process!
# We now need to rescue for this particular exception or allow it to bubble further up the chain if other services are involved.
For some use cases this makes a lot of sense, for the case of importing data from a CSV, likely not so much. Mainly because we'd love to validate all rows at once instead of just stopping when there is one error.
And as a rule of thumb I generally add !
to the end of a method that I would expect to raise an exception.