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
Top comments (4)
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
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?Thanks!
Depends on what you have on
$LOAD_PATH
which in rails can be set up via something likeon
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.
Some erratas on a previous version of this post were spotted by the very kind but also eagle-eyed @happywebcoder Thanks pal!