padi / light-service

Series of Actions with an emphasis on simplicity.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

LightService

Gem Version Build Status Code Climate

What do you think of this code?

class TaxController < ApplicationController
  def update
    @order = Order.find(params[:id])
    tax_ranges = TaxRange.for_region(order.region)

    if tax_ranges.nil?
      render :action => :edit, :error => "The tax ranges were not found"
      return # Avoiding the double render error
    end

    tax_percentage = tax_ranges.for_total(@order.total)

    if tax_percentage.nil?
      render :action => :edit, :error => "The tax percentage  was not found"
      return # Avoiding the double render error
    end

    @order.tax = (@order.total * (tax_percentage/100)).round(2)

    if @order.total_with_tax > 200
      @order.provide_free_shipping!
    end

    redirect_to checkout_shipping_path(@order), :notice => "Tax was calculated successfully"
  end
end

This controller violates SRP all over. Also, imagine what would it take to test this beast. You could move the tax_percentage finders and calculations into the tax model, but then you'll make your model logic heavy.

This controller does 3 things in order:

  • Looks up the tax percentage based on order total
  • Calculates the order tax
  • Provides free shipping if the total with tax is greater than $200

The order of these tasks matters: you can't calculate the order tax without the percentage. Wouldn't it be nice to see this instead?

(
  LooksUpTaxPercentageAction,
  CalculatesOrderTaxAction,
  ChecksFreeShippingAction
)

This block of code should tell you the "story" of what's going on in this workflow. With the help of LightService you can write code this way. First you need an organizer object that sets up the actions in order and executes them one-by-one. Then you need to create the actions which will only have one method and will do only one thing.

This is how the organizer and actions interact with eachother:

LightService

class CalculatesTax
  include LightService::Organizer

  def self.for_order(order)
    with(:order => order).reduce(
        LooksUpTaxPercentageAction,
        CalculatesOrderTaxAction,
        ProvidesFreeShippingAction
      )
  end
end

class LooksUpTaxPercentageAction
  include LightService::Action
  expects :order
  promises :tax_percentage

  executed do |context|
    tax_ranges = TaxRange.for_region(context.order.region)
    context.tax_percentage = 0

    next context if object_is_nil?(tax_ranges, context, 'The tax ranges were not found')

    context.tax_percentage = tax_ranges.for_total(context.order.total)

    next context if object_is_nil?(context.tax_percentage, context, 'The tax percentage was not found')
  end

  def self.object_is_nil?(object, context, message)
    if object.nil?
      context.fail!(message)
      return true
    end

    false
  end
end

class CalculatesOrderTaxAction
  include ::LightService::Action
  expects :order, :tax_percentage

  executed do |context|
    order.tax = (order.total * (tax_percentage/100)).round(2)
  end

end

class ProvidesFreeShippingAction
  include LightService::Action
  expects :order

  executed do |context|
    if order.total_with_tax > 200
      order.provide_free_shipping!
    end
  end
end

And with all that, your controller should be super simple:

class TaxController < ApplicationContoller
  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
end

I gave a talk at RailsConf 2013 on simple and elegant Rails code where I told the story of how LightService was extracted from the projects I had worked on.

Expects and Promises

Let me introduce to you the expects and promises macros. Think of these as a rule set of inputs/outputs for the action. expects describes what keys it needs to execute and promises makes sure the keys are in the context after the action is reduced. If either of them are violated, a custom exception is thrown.

This is how it's used:

class FooAction
  include LightService::Action
  expects :baz
  promises :bar

  executed do |context|
    baz = context.fetch :baz

    bar = baz + 2
    context[:bar] = bar
  end
end

The expects macro does a bit more for you: it pulls the value with the expected key from the context, and makes it available to you through a reader. You can refactor the action like this:

class FooAction
  include LightService::Action
  expects :baz
  promises :bar

  executed do |context|
    bar = context.baz + 2
    context[:bar] = bar
  end
end

The promises macro will not only check if the context has the promised keys, it also sets it for you in the context if you use the accessor with the same name. The code above can be further simplified:

class FooAction
  include LightService::Action
  expects :baz
  promises :bar

  executed do |context|
    context.bar = context.baz + 2
  end
end

Take a look at this spec to see the refactoring in action.

Error Codes

You can add some more structure to your error handling by taking advantage of error codes in the context. Normally, when something goes wrong in your actions, you fail the process by setting the context to failure:

class FooAction
  include LightService::Action

  executed do |context|
    context.fail!("I don't like what happened here.")
  end
end

However, you might need to handle the errors coming from your action pipeline differently. Using an error code can help you check what type of expected error occurred in the organizer or in the actions.

class FooAction
  include LightService::Action

  executed do |context|
    unless (service_call.success?)
      context.fail!("Service call failed", 1001)
    end

    # Do something else

    unless (entity.save)
      context.fail!("Saving the entity failed", 2001)
    end
  end
end

Requirements

This gem requires ruby 1.9.x

Installation

Add this line to your application's Gemfile:

gem 'light-service'

And then execute:

$ bundle

Or install it yourself as:

$ gem install light-service

Usage

Based on the refactoring example above, just create an organizer object that calls the actions in order and write code for the actions. That's it.

For further examples, please visit the project's Wiki.

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

Huge thanks to the contributors!

Release Notes

TBA

  • remove previously deprecated method Context#context_hash
  • Skipping the promised keys check in the context when the context is in failure state

0.3.4

  • The method call with is now optional in case you have nothing to put into the context.
  • Action name is being displayed in the error message when the expected or promised key is not in the context.

0.3.3

  • Switching the promises and expects key accessors from Action to Context

0.3.2

  • Fixing documentation and using separate arguments instead of a hash when setting the context to failure with error code

0.3.1

0.3.0

  • Adding the expects and promises macros - Read more about it in this blog post

0.2.2

  • Adding the gem version icon to README
  • Actions can be invoked now without arguments, this makes it super easy to play with an action in the command line

0.2.1

  • Improving deprecation warning for the renamed methods
  • Making the message an optional argument for succeed! and fail! methods

0.2.0

  • Renaming the set_success! and set_failure! methods to succeed! and fail!
  • Throwing an ArgumentError if the make method's argument is not Hash or LightService::Context

License

LightService is released under the MIT License.

About

Series of Actions with an emphasis on simplicity.

License:MIT License


Languages

Language:Ruby 100.0%