Skip to content

Instantly share code, notes, and snippets.

@lackac
Last active September 24, 2015 07:58
Show Gist options
  • Save lackac/ec61a32d6d76300d0b3e to your computer and use it in GitHub Desktop.
Save lackac/ec61a32d6d76300d0b3e to your computer and use it in GitHub Desktop.
Gourmet Service Object presentation for budapest.rb on 2015-09-23
# a service object (aka method object) performs one action
# the simplest service object is a lambda
send_notification = lambda do |user, message|
DeviceNotifier.new(user).send(message)
NotificationMailer.new(user, message).deliver_later
end
send_notification.call(alice, "Bob says 'Hi!'")

When to use Service Objects?

Use service objects to encapsulate actions one or more of the following traits:

  • the action is complex
  • the action reaches across multiple models
  • the action interacts with an external service
  • the action is not a core concern of the underlying model
  • there are multiple ways of performing the action (see Strategy Pattern)

source

Gourmet Service Objects

  • responds to #call
  • other public methods are strongly discouraged
  • name starts with a verb (e.g. SendNotification, RefundPayment)
  • place them under app/services, use subdirectories and namespaces for grouping together related services

This is one way of writing services. There are a number of other approaches out there, and some styles could be even mixed in the same project for different purposes.

# the same service object but as a class
class SendNotification
def self.call(user, message)
DeviceNotifier.new(user).send(message)
NotificationMailer.new(user, message).deliver_later
end
end
# things get complicated
class MakePayment
def self.call(order, gateway)
transaction = create_transaction(order)
gateway.make_payment(payment_payload(transaction)).tap do |response|
log_response(response)
end
end
private
def create_transaction(order)
order.transactions.create type: :payment, amount: order.amount_due
end
def payment_payload(transaction)
{
merchant_ref: transaction.uuid,
# ...
}
end
end
# let's simplify things
module Service
extend ActiveSupport::Concern
included do
def self.call(*args)
new(*args).call
end
end
end
# now that we can use instances, let's simplify initialization
# introducing [Virtus][1]: Attributes on Steroids for POROs
#
# [1]: https://github.com/solnic/virtus
class MakePayment
include Service
include Virtus.model
attribute :order, Order
attribute :gateway, nil, default: ->(*) { CreateGatewayClient.call }
def call
create_transaction
gateway.make_payment(payment_payload) do |response|
log_response(response)
end
end
private
attr_accessor :transaction
# ...
end

Countless benefits

  • discoverable application – just browse through app/services

  • self-documenting

  • clean controllers and models

  • modular and composable

  • fast and focused tests

  • easily reusable, call from anywhere (other service objects, rake tasks, cron jobs, console, test helpers, etc.)

  • almost functional

What to return from the #call method

There are 3 flavors:

  • Fail loudly! – a return value means success, otherwise an exception is raised; use methods such as Hash#fetch, create!, save!, etc.
  • Return a persisted model
  • Return a response object – such an object could have query methods like success?, error, status, etc.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment