Gourmet Service Objects
Is your Rails app’s business logic hidden in ugly controllers with 10+ lines long method and fat models powered by Linguini callbacks? Are your tests getting out of control and you spend most of your days looking at green dots? Do you want to impress your coworkers with Unicorn level code?
You need Gourmet Service Objects™!
I have been using service objects for the past three years and they reconciled my take on Rails (as much as automated testing reconciled my feelings for software programming!).
A service object does one thing
A service object (aka method object) performs one action. It holds the business logic to perform that action. Here is an example:
# app/services/accept_invite.rb
class AcceptInvite
def self.call(invite, user)
invite.accept!(user)
UserMailer.invite_accepted(invite).deliver
end
end
The three conventions I follow are:
- Services go under the
app/services
directory. I encourage you to use subdirectories for business logic-heavy domains. For instance:- The file
app/services/invite/accept.rb
will defineInvite::Accept
- while
app/services/invite/create.rb
will defineInvite::Create
- The file
- Services start with a verb (and do not end with Service):
ApproveTransaction
,SendTestNewsletter
,ImportUsersFromCsv
- Services respond to the
call
method. I found using another verb makes it a bit redundant:ApproveTransaction.approve()
does not read well. Also, thecall
method is the de facto method for lambda, procs, and method objects.
Benefits
Service objects show what my application does
I can just glance over the services
directory to see what my application does: ApproveTransaction
, CancelTransaction
, BlockAccount
, SendTransactionApprovalReminder
…
A quick look into a service object and I know what business logic is involved. I don’t have to go through the controllers, ActiveRecord model callbacks and observers to understand what “approving a transaction” involves.
Clean-up models and controllers
Controllers turn the request (params, session, cookies) into arguments, pass them down to the service and redirect or render according to the service response.
class InviteController < ApplicationController
def accept
invite = Invite.find_by_token!(params[:token])
if AcceptInvite.call(invite, current_user)
redirect_to invite.item, notice: "Welcome!"
else
redirect_to '/', alert: "Oopsy!"
end
end
end
Models only deal with associations, scopes, validations and persistence.
class Invite < ActiveRecord::Base
def accept!(user, time=Time.now)
update_attributes!(
accepted_by_user_id: user.id,
accepted_at: time
)
end
end
This makes models and controllers much easier to test and maintain!
DRY and Embrace change
I keep service objects as simple and small as I can. I compose service objects with other service objects, and I reuse them. My code is quite modular and I’m ready to Embrace Change™.
class SendTestNewsletter
def self.call(newsletter)
campaign = CreateMailchimpCampaign.call(newsletter)
DeliverTestEmail.call(campaign)
DeleteCampaign.call(campaign) # Don't keep the test campaign around
end
end
class SendNewsletter
def self.call(newsletter)
campaign = CreateMailchimpCampaign.call(newsletter)
DeliverCampaign.call(campaign)
# Could easily delete here as well, but we want to retain the legit campaigns
end
end
Clean up and speed up your test suite
Services are easy and fast to test since they are small ruby objects with one point of entry (the call
method). Complex services are composed with other services, so you can split up your tests easily.
I tend not to use any mocks or stub to test services that deal with ActiveRecord objects. rspec-set helps me keep the running time quite low while having simple and robusts test. Once again, service objects are small and do one thing, so they tend to have a limited amount of dependencies.
Call them from anywhere
Service objects are likely to be called from controllers as well as:
- Other service objects:
class BatchSyncUsers
def self.call(users)
users.each { |user| SyncUser.call(user) }
end
end
- DelayedJob / Rescue / Sidekiq Jobs:
class SyncInvoicesJob
def work
SyncInvoices.call
end
end
- Rake tasks:
task :sync do
SyncInvoices.call
end
- The console:
$> ApproveTransaction.call(transaction, user, 2.days.ago)
- Even from test helpers to setup my integration tests!
def create_approved_transaction
transaction = FactoryGirl.create(:transaction)
ApproveTransaction.call(transaction, FactoryGirl.create(:user))
transaction
end
Real world services
I like to use instances of service objects to take advantage of private methods. I add Virtus into the mix to handle parameters. For instance:
class AcceptInvite
def self.call(*args)
new(*args).call
end
include Virtus.model
attribute :invite, Invite
attribute :user, User
attribute :account, Account
attribute :time, Time, default: proc { Time.now }
def call
unless invite_already_accepted?
accept_invite
send_notification_to_inviter
end
end
private
def invite_already_accepted?
# ...
end
def accept_invite
# ...
end
def send_notification_to_inviter
# ...
end
end
I extracted the def self.call
into a helper module Service
:
module Service
extend ActiveSupport::Concern
included do
def self.call(*args)
new(*args).call
end
end
end
class AcceptInvite
include Service
include Virtus.model
attribute :invite, Invite
attribute :user, User
attribute :account, Account
attribute :time, default: proc { Time.now }
def call
unless invite_already_accepted?
accept_invite
send_notification_to_inviter
end
end
private
# ...
end
I sometimes inject dependencies to test services that orchestrate
complex operations. Since services respond to the call
method, a
simple proc
does the job.
class Trumpet
include Service
# ...
end
class Bass
include Service
# ...
end
class OutOfTuneError < StandardError
end
class Conductor
include Service
include Virtus.model
attribute :trumpet, Trumpet, default: proc { Trumpet }
attribute :bass, Bass, default: proc { Bass }
def call
trumpet.call('C4 .. G4')
bass.call('C2 D2 E2 E2')
rescue OutOfTuneError => e
# ...
end
end
expect(Conductor.call(
trumpet: proc { "onk! onk!" },
bass: proc { raise OutOfTuneError }
)).to sound_awful
Values: The Return
The services I write have three flavours when it comes to communicating back to the caller.
Flavour #1: Fail loudly
Most services are not supposed to fail. They do not return anything (meaningful) but they raise an exception when something goes wrong. Those services are likely to use methods that fail loudly such as Hash#fetch
, create!
, save!
, find_by_name!
etc.
class ContractController < LoggedInController
def sign
contract = current_user.contracts.find(params.fetch(:id))
SignContract.call(contract: contract, user: current_user)
flash[:notice] = "Contract signed!"
redirect_to contract
end
end
Flavour #2: Return a persisted ActiveRecord model
The caller can check if an AR instance is persisted and then has access to its errors.
class InviteController < LoggedInController
def create
attributes = invite_params.merge(creator: current_user)
@invite = CreateInvite.call(attributes)
if @invite.persisted?
redirect_to @invite
else
render :new, alert: errors_for_humans(@invite.errors)
end
end
private
def invite_params
params.fetch(:invite).permit(:token)
end
end
Flavour #3: Response object
Some services have several outcomes and complex error handling. They return a response object which responds to success?
and error(s)
.
class InviteController < LoggedInController
def accept
result = AcceptInvitation.call(
invite: Invite.find_by_token!(params[:token]),
user: current_user
)
if result.success?
redirect_to root_path, notice: "Welcome!"
else
redirect_to root_path, alert: result.error
end
end
end
That’s it for service objects for now. Experiment with them, as I believe they will make your codebase more expressive and easier to maintain!
I’m happy to respond to any question or concern you guys might have. Feel free to leave a comment below. <3 <3 <3