Rails Service Objects

Plan

  • Introduction / Context
  • Rails Patterns
    • ➡️ SOLID
  • Object Services
    • ➡️ interactor gem
  • Conclusion

Context

  • Rails default patterns
    • Model
      Deals with a CRUD datasource
      Expose data, transform and validate data before update
    • View
      Display data - from templates variables, return a representation of data (HTML, JSON, ...)
    • Controller
      Handle request level logic - example: authentication

Context

  • Rails other patterns
    • Decorator
      Augment model's data
      ( decorator(record) = decorated_record )
    • Presenter
      Prepare data for the view - example with data comes from many model or sources

      (decorator vs presenter)
       
    • Singleton
      Shared instance across the app - example for configuration or redis client

Context

Decorator and Presenter usage example

S.O.L.I.D principles

Single responsibility principle
a class should have only a single responsibility (i.e. changes to only one part of the software's specification should be able to affect the specification of the class).

 

Open/closed principle
"software entities … should be open for extension, but closed for modification."

 

Liskov substitution principle
"objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program." See also design by contract.

 

Interface segregation principle
"many client-specific interfaces are better than one general-purpose interface."

 

Dependency inversion principle
"high level modules should not depend upon low level modules. Both should depend upon abstractions"
 

Service Objects

We have all been there when you see your controller action getting way too long and hold too much business logic.
You know you need to email the user, adjust an account, maybe submit to Stripe and finally ping a Slack Webhook.
Well, where should it go? This code needs to exist and doesn’t seem to fit in the model.
This my friend, is where Service Objects come in!

Service Objects

Service Objects: How to

class Analytics

    def self.get_data(algolia_application)
        usage = {}
        acl = %w[
          total_write_operations
          total_read_operations
          records
        ]
        uri = URI("https://status.algolia.com/1/usage/#{acl.join(',')}/period/month")
        res = perform_request(uri, algolia_application['monitoring_api_key'])
        json = JSON.parse(res.body)
    
        return [:bad_request, error: json['reason']] if json['reason'].present?
        # ...
        [:ok, usage]
    end

    def self.retrieve_analytics_information
        # ...
    end


end

Service Objects: How to

Class based with the following signature:

  • initialize()
  • execute()
  • execute!()

Service Objects: How to

class Analytics

    attr_reader :algolia_application

    def initialize(algolia_application)
        @algolia_application = algolia_application
    end

    def execute(from:)
        usage = {}
        acl = %w[
          total_write_operations
          total_read_operations
          records
        ]
        uri = URI("https://status.algolia.com/1/usage/#{acl.join(',')}/period/month")
        res = perform_request(uri, algolia_application['monitoring_api_key'])
        json = JSON.parse(res.body)
    
        return [:bad_request, error: json['reason']] if json['reason'].present?
        # ...
        [:ok, usage]
    end

    private

    def retrieve_analytics_information
        # ...
    end


end

Service Objects: How to


analytics = Analytics.new(config.algolia_application)
analytics.execute(from: 1.month)
analytics.execute(from: 2.month)

Service Objects: interactor

class AuthenticateUser
  include Interactor

  def call
    if user = User.authenticate(context.email, context.password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

Service Objects: interactor

class SessionsController < ApplicationController
  def create
    result = AuthenticateUser.call(session_params)

    if result.success?
      session[:user_token] = result.token
      redirect_to result.user
    else
      flash.now[:message] = t(result.message)
      render :new
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password)
  end
end

Service Objects: interactor

Interactors (building blocks)

  • context
  • flow control:  fail!/success methods
  • hooks support: around, before, after
  • rollback support


Organizers (orchestrate interactors)

  • flow control: if one interactor fails, others are "rollbacked"

Service Objects: interactor

# our Analytics Organizer
class StoreAnalytics
  include Interactor::Organizer

  organize Analytics::RetrieveAnalytics, Analytics::RetrieveUsage
end


### ... 

## in controller


def analytics
    result = StoreAnalytics.call(algolia_application)
    if result.success?
        result.usage
        result.analytics
        
        render AnalyticsPresenter.new(result).as_json
    else
        render status: result.error.status, json: result.error.json
    end
end

Conclusion

  • Pros
    • Code easier to test, even if services are composed (with an organizer)
    • Responsibilities are clear and self-documented
    • "Less complexity"
  • Cons
    • ​How to build performance oriented Service Objects?
    • How to identify responsibility in an application?

Rails Service Objects

By Charly Poly

Rails Service Objects

  • 1,518