Rails + Service Objects
and their different flavors
@apneadiving
Rails & Angular freelancer
They are useless!
According to DHH (Rails creator):
If anything, controller actions are the service layer. Which is the point. Don't put another service layer on top of it.
Talk over?
Community is big, with various point of views.
Services are sometimes recommended:
- Steve Klabnik - MVC is the problem
- Bryan Helmkamp - Code climate blog
- Philippe Creux - Gourmet service objects
- and many others...
So when?
do you want to have business logic leak in controller actions?
do you really think a fat model always make sense?
then you'd create a class to abstract the logic?
No
No
Yes
you have a service object...
Cons Pros
- encapsulate logic
- create a clear context
- abstract concepts
- reusable
- easily testable
- let people understand
what you app does
- usually more code
- not standard Rails way
- no standard way to write
service objects
class PlaceOrder
attr_reader :order, :client
def initialize(client, order)
@client, @order = client, order
end
def call
ActiveRecord::Base.transaction do
debit_client
remove_items_from_stock
validate_order
prepare_for_shipping
end
true
rescue ActiveRecord::ActiveRecordError => exception
false
end
private
# def debit_client
# def remove_items_from_stock
# def validate_order
# def prepare_for_shipping
end
Example 1/5
Meaningful name
class PlaceOrder
attr_reader :order, :client
def initialize(client, order)
@client, @order = client, order
end
def call
ActiveRecord::Base.transaction do
debit_client
remove_items_from_stock
validate_order
prepare_for_shipping
end
true
rescue ActiveRecord::ActiveRecordError => exception
false
end
private
# def debit_client
# def remove_items_from_stock
# def validate_order
# def prepare_for_shipping
end
Standard way to trigger
Example 2/5
class PlaceOrder
attr_reader :order, :client
def initialize(client, order)
@client, @order = client, order
end
def call
ActiveRecord::Base.transaction do
debit_client
remove_items_from_stock
validate_order
prepare_for_shipping
end
true
rescue ActiveRecord::ActiveRecordError => exception
false
end
private
# def debit_client
# def remove_items_from_stock
# def validate_order
# def prepare_for_shipping
end
Do what you need
Example 3/5
class PlaceOrder
attr_reader :order, :client
def initialize(client, order)
@client, @order = client, order
end
def call
ActiveRecord::Base.transaction do
debit_client
remove_items_from_stock
validate_order
prepare_for_shipping
end
true
rescue ActiveRecord::ActiveRecordError => exception
false
end
private
# def debit_client
# def remove_items_from_stock
# def validate_order
# def prepare_for_shipping
end
Decide the output
Example 4/5
# in controller
def place_order
if PlaceOrder.new(current_user, order).call
flash[:success] = "thank you"
render :index
else
flash[:error] = "sorry mate"
render :cart
end
end
def order
@order ||= Order.find(params[:id])
end
Example 5/5
Use in controller
Isnt it brilliant?
No...
New problems arise...
- what is a meaningful output?
- what if I need a service in a service?
- how would I chain services?
- what if a service fails within the chain?
Meaningful output?
- a boolean doesnt tell much
- real errors would be better
Service in a service?
if child_service.success?
# continue with parent service code
# extract relevant data
else
# extract errors
# make parent service fail
end
Chain services?
if service1.success?
if service2.success?
if service3.success?
if service4.success?
else
end
else
end
else
end
else
end
not great, lets check gems
Example
class LooksUpTaxPercentageAction
include LightService::Action
expects :order
promises :tax_percentage
executed do |context|
tax_ranges = TaxRange.for_region(context.order.region)
next context if object_is_nil?(tax_ranges, context, 'tax ranges not found')
context.tax_percentage = tax_ranges.for_total(context.order.total)
next context if object_is_nil?(context.tax_percentage, context, 'tax % not found')
end
def self.object_is_nil?(object, context, message)
if object.nil?
context.fail!(message)
return true
end
false
end
end
I'd say YES
-
error messages
Meaningful output?
-
named `promises`, and properties attached to context
context.tax_percentage =
context.fail!(message)
Service in a service?
standard if / else check
No win here
Chain services?
I'd say yes (at the cost of a class)
class CalculatesTax
include LightService::Organizer
def self.for_order(order)
with(order: order).reduce(
LooksUpTaxPercentageAction,
CalculatesOrderTaxAction,
ProvidesFreeShippingAction
)
end
end
Use in controller
def update
@order = Order.find(params[:id])
service_result = CalculatesTax.for_order(@order)
if service_result.failure?
render action: :edit, error: service_result.message
else
redirect_to checkout_shipping_path(@order),
notice: "Tax was calculated successfully"
end
end
Failure within the chain
rolled_back do |context|
context.user.destroy
end
- next elements in the chain skipped
- previous elements rolled back using
Cons Pros
- clear expect / promise syntax
- organizes flow
- syntax
- strict rules
- not working with instances
- mandatory to create a class to chain
- global context shared across chained services
Example
class AuthenticateUser
include Interactor
def call
if user = User.authenticate(context.email, context.password)
context.user = user
context.token = user.secret_token
else
context.fail!(message: "authenticate_user.failure")
end
end
end
Meaningful output?
I'd say YES
-
error messages
-
properties attached to the context
context.fail!(message: "authenticate_user.failure")
context.token =
Service in a service?
standard if / else check
No win here
Chain services?
class PlaceOrder
include Interactor::Organizer
organize CreateOrder, ChargeCard, SendThankYou
end
I'd say yes (at the cost of a class)
Failure within the chain
- next elements in the chain skipped
- previous elements rolled back using
def rollback
context.order.destroy
end
Use in controller
def create
result = PlaceOrder.call(order_params: order_params)
if result.success?
redirect_to result.order
else
@order = result.order
render :new
end
end
Cons Pros
- not clear what is the input of an Interactor
- mandatory to create a class to chain
- global context shared across chained services
- organizes flow
- work with instances
class TaxCalculator
include WaterFall
include ActiveModel::Validations
validates :tax_ranges, :tax_percentage, presence: true
def initialize(order); @order = order; end
def call
self
.when_falsy { valid? }
.dam { errors }
.chain(:tax_result) { calculate }
end
private
def calculate
(@order.total * (tax_percentage/100)).round(2)
end
def tax_percentage
@tax_percentage ||= tax_ranges.for_total(@order.total)
end
def tax_ranges
@tax_ranges ||= TaxRange.for_region(@order.region)
end
end
Example
I'd say YES
-
error messages
Meaningful output?
-
properties attached to the service
.dam { errors }
.chain(:tax_result) { calculate }
Use whatever container for your errors, could be a mere string
Service in a service?
self
.chain do
CloseProject.new(project).call
.chain { NotifyProjectClosed.new(project) }
end
.chain { Log.new(:project_closed, project) }
A second service would be a waterfall
so its just a matter of chaining flows
using chain
errors would propagate & stop next chains
Chain services?
Yes, thats still the point of chain
Wf.new
.chain(order: :order) { CreateOrder.new(order_params, current_user) }
.chain(bill: :bill) {|outflow| ChargeCard.new(outflow.order) }
.chain {|outflow| SendThankYou.new(outflow.order, outflow.bill) }
Failure within the chain
- next elements in the chain skipped
- previous elements rolled back using
def rollback
order.destroy
end
(FYI I recommend nested transactions though)
def get_tax
Wf.new
.chain(tax: :tax_result) { TaxCalculator.new(order) }
.chain {|outflow| render json: { value: outflow.tax } }
.on_dam {|errors| render json: { errors: errors.full_messages }, status: 422 }
end
Use in controller
Just like when you chain services
Cons Pros
- dedicated semantic to embrace
- think code as flow
Disclaimer: I wrote the library...
(think of it as sync promises if you know javascript)
My conclusion
- Service objects deserve you to look at them, to better represent your business logic
- Service objects demand you decide how you want to use them
(with or without gems)
Service objects and their flavors in Rails
By Benjamin Roth
Service objects and their flavors in Rails
service objects, light service, interactor and waterfall using Rails
- 10,437