Skip to content

Instantly share code, notes, and snippets.

@Geesu
Last active January 6, 2019 04:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Geesu/742c1360d3b68274d055ec5bf07d2004 to your computer and use it in GitHub Desktop.
Save Geesu/742c1360d3b68274d055ec5bf07d2004 to your computer and use it in GitHub Desktop.
Ruby Service Objects - Why and How

Ruby Service Objects

Why

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.

How - Example 1

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:

  1. It validates that the csv data we receive is correct/useable before it attempts to do anything.
  2. It stores errors on an errors object so we can understand why the service failed.

How - Example 2

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment