DEV Community

Oinak
Oinak

Posted on

Ruby Service Objects without gems

Sometimes we use gems like Trailblazer or Interactor just to separate business logic from controllers and models, and sometimes, a couple of PORO's and a simple convention is enough:

Here is a general outline of my favourite set of conventions for services object:

  • inherit from a base service that holds shared behaviour
  • have a single class-level command method run accepting keyword arguments, that always returns an instance of the service class
  • have an instance level status query method to check the success of the action (usually :ok and:error)
  • have an instance level result query method that holds any external object needed as an outcome

I am using query and command in the sense Sandi Metz does:

  • query is a method that gets information but does not change the state of the receiver, whereas
  • command may or may not return something but always changes the state of the receiver

I always use run and have the name of the class describe the action, other people, like the very wise Xavier Noria, prefer to make service's main method a meaningful verb, but I find it just another thing to remember.

Let's see it in action with an example, I will use authentication, not because you should program your own (you shouldn't), but because it's a common business logic which shall make the fir of the ServiceObject better:

A base service to inherit from:

class Service
  attr_reader :result, :status

  def self.run(**args)
    new(**args).tap(&:run)
  end

  def initialize(*)
    raise NotImplemented, "must be defined by subclasses"
  end
end
Enter fullscreen mode Exit fullscreen mode

A sample service with a familiar logic:

module Services
  class UserAuthenticate < ::Service
    def initialize(username:, password:, user_model: User)
      @username = username
      @password = pasword
    end

    def run
      if user&.authenticate(@password)
        @result = user
        @status = :ok
      else
        @result = nil
        @status = :error
      end
    end

    private

    def user
      user_model.find_by(username: @username)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And and example of usage in an quite unoriginal rails app:

class SessionsController < ApplicationController
  def create
    if user_authentication.status == :ok
      session[:user_id] = user_authentication.result.id
      redirect_to root_path
    else
      render :new, flash: { error: t('.wrong_email_or_password') }
    end
  end

  def destroy
    session[:user_id] = nil
    redirect_to root_path
  end

  private

  def user_authentication  
    @user_authentication ||= UserAuthentication.run(session_params)
  end

  def session_params
    params.require(:user).permit(:username, :password)
  end
end
Enter fullscreen mode Exit fullscreen mode

What, ah, you were intrigued by the user_model: User part?

Ok lets see how to test this (in minispec):

describe UserAuthentication do
  let(:user_class) { Object.new }
  let(:user) { Minitest::Mock.new }
  let(:good_pass) { 'good_pass' }
  subject do
    user_class.stub(:find_by, user) do
      UserAuthenticate.run('username', user_class: user_class)
    end
  end

  describe "valid password" do
    before do
      user.expect(:authenticate, true, [good_pass])
    end

    it "returns status ok" do
      value(subject.status).must_equal(:ok)
    end

    it "has a result of user object" do
      value(subject.result).must_equal(user)
    end

    it "calls authenticate on the user model" do
      subject
      user.verify
    end
  end

  describe "invalid password" do
    # I am sure you can deduce this part :-)
  end
end

Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
oinak profile image
Oinak

If you like this but miss ActiveModel's validations, callbacks of serialization capabilities, fear no more, just jump to sophiedebenedetto's smarter rails services with active model modules

Collapse
 
sergiomaia profile image
sergiomaia

Nice article, very usefull. In the usage example on method user_authentication, shouldn't it be @user_authentication ||= ::UserAuthenticate.run(session_params)? Or am i mistaken?

Collapse
 
oinak profile image
Oinak

Thanks!

Depends on what you have on $LOAD_PATH which in rails can be set up via something like

config.autoload_paths += %W(#{config.root}/app/services)

on config/application.rb. That being said, ::UserAuthenticate is less ambiguous and faster to load, so good point.

If you and/or any reader are into the details, I can't recommend enough Xavier's talks or his post on his new loader Zeitwerk.

Collapse
 
oinak profile image
Oinak

Some erratas on a previous version of this post were spotted by the very kind but also eagle-eyed @happywebcoder Thanks pal!