KatPadi's Point

Service Object Adventures

I have read a lot of Rails articles and most of them always tell you to keep your Rails controllers skinny. It sounds so easy but it’s really not. As the cliche goes, “it is easier said than done.” In real life, requirements get complicated. Controllers and models get longer and longer and you need to do something about it. The itch to refactor is for real!

ENTER SERVICE OBJECTS.

What are service objects?

My understanding is that, service objects are actions or logic operations that don’t belong to a single or specific object in your Rails app. If you can’t pinpoint immediately where a certain operation must live, it’s probably a service. Services have their own home and are in a different layer in the app– the service layer.

There are many kinds of service object. Based on what I read, these are the 3 mostly used ones:

  1. Application – used in the application for whatever action / logic needs to be re-usable
  2. Infrastructure – used when action/logic involves external systems/APIs
  3. Domain – simply, “multi-model” stuff. For example, you have to update model B when model A is created and then insert a new entry to model C.

For #3, I used to implement those kinds through hooks like afer_create, but it breaks SRP! It’s not the job of model A to update model B and insert to model C. (Yes. Things can get complicated like this!)

Below is a sample of an application service object that checks if certain limits have been reached. It can be used by the API, model, controller, or whichever needs checking. (For this to be service-object-worthy, I think it should be complicated stuff. Do not do this if you’re only comparing basic things like if count is greater than 1, or something.)


class TransactionLimitChecker
  def initialize(transaction)
    @transaction = transaction
  end

  def limit_is_reached?(user)
    per_user_limit_reached?(user) ||
    per_interval_limit_reached?(user) ||
    overall_max_limit_reached?
  end

  def per_user_limit_reached?(user)
    # Lots of logic here
    # of
    # logic
    # here...
    @transaction.count > some_count
  end

  def per_interval_limit_reached?(user)
    # Logic... 
    # put here your logic
    # Use some private methods...whatever
    check_interval_validity!
    limit = blah
    Time.current.utc <= @transaction.updated_at.time + limit
  end

  def overall_max_limit_reached?
    # More logic!
    # Just put 'em here
    @transaction.over_all_submission_count > total_something
  end

  private

  def check_interval_validity!
    # Check interval unit if valid ActiveSupport::Duration
    valid = 1.respond_to? @transaction.submission_interval_unit.to_sym
    fail Errors::InvalidIntervalDefined unless valid
  end
end

The one below is a domain service object. (I’d like to think so!) It can be re-usable in the controllers that need it. It updates/inserts to several other models not related to the current one.

class SomethingBuilder
  def initialize(something)
    @something = something
  end

  def build
    build_something_after_something_else
  end

  private

  def build_something_after_something_else
    # Update model A
    # Insert to model B
    # Insert to model A and B mapping
  end
end

class AController < ApplicationController
  def create
    # Do the usual stuff here...
    # Then, use the service!
    SomethingBuilder.new(@a).build
  end
  
  # Other controller actions here...
end

class BController < ApplicationController
  def create
    # Do the usual stuff here...
    # Then, use the service!
    SomethingBuilder.new(@b).build
  end
  
  # Other controller actions here...
end

Service objects keep my controllers DRY. My service object adventures are not yet fully “pro” but as I go along and build even more complex stuff, they’ll surely come in handy.

Leave a Reply

Your email address will not be published. Required fields are marked *