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!