Oinak
Posted on August 5, 2019
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
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
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
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
Posted on August 5, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.