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 define Invite::Accept
    • while app/services/invite/create.rb will define Invite::Create
  • 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, the call 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

Let's Work Together

Find out why our transparent, collaborative process is the best way to make well-loved products.

Get in touch today
comments powered by Disqus