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:

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

  • 5,040
Loading comments...

More from Benjamin Roth