Series of Actions with an emphasis on simplicity.
Series of Actions with an emphasis on simplicity.
LightService is a powerful and flexible service skeleton framework with an emphasis on simplicity
What do you think of this code?
…This controller violates SRP all over. Also, imagine what it would 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:
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?
(
LooksUpTaxPercentage,
CalculatesOrderTax,
ProvidesFreeShipping
)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 with one method (that will do only one thing).
This is how the organizer and actions interact with each other:
…And with all that, your controller should be super simple:
class TaxController :edit, :error => service_result.message
else
redirect_to checkout_shipping_path(@order), :notice => "Tax was calculated successfully"
end
end
endI 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.
This gem requires ruby 2.x. Use of generators requires Rails 5+ (tested on Rails 5.x & 6.x only. Will probably work on Rails versions as old as 3.2)
In your Gemfile:
gem 'light-service'And then
bundle installOr install it yourself as:
gem install light-serviceLightService's building blocks are actions that are normally composed within an organizer, but can be run independently. Let's make a simple greeter action. Each action can take an optional list of expected inputs and promised outputs. If these are specified and missing at action start and stop respectively, an exception will be thrown.
class GreetsPerson
extend ::LightService::Action
expects :name
promises :greeting
executed do |context|
context.greeting = "Hey there, #{name}. You enjoying LightService so far?"
end
endWhen an action is run, you have access to its returned context, and the status of the action. You can invoke an
action by calling .execute on its class with key: value arguments, and inspect its status and context like so:
outcome = GreetsPerson.execute(name: "Han")
if outcome.success?
puts outcome.greeting # which was a promised context value
elsif outcome.failure?
puts "Rats... I can't say hello to you"
endYou will notice that actions are set up to promote simplicity, i.e. they either succeed or fail, and they have very clear inputs and outputs. Ideally, they should do exactly one thing. This makes them as easy to test as unit tests.
LightService provides a facility to compose actions using organizers. This is great when you have a business process to execute that has multiple steps. By composing actions that do exactly one thing, you can sequence simple actions together to perform complex multi-step business processes in a clear manner that is very easy to reason about.
There are advanced ways to sequence actions that can be found later in the README, but we'll keep this simple for now.
First, let's add a second action that we can sequence to run after the GreetsPerson action from above:
…And here's the organizer that ties the two together. You implement a call class method that takes some arguments and
from there sends them to with in key: value format which forms the initial state of the context. From there, chain
reduce to with and send it a list of action class names in sequence. The organizer will call each action, one
after the other, and build up the context as it goes along.
class WelcomeAPotentiallyLuckyPerson
extend LightService::Organizer
def self.call(name)
with(:name => name).reduce(GreetsPerson, RandomlyAwardsPrize)
end
endWhen an organizer is run, you have access to the context as it passed through all actions, and the overall status
of the organized execution. You can invoke an organizer by calling .call on the class with the expected arguments,
and inspect its status and context just like you would an action:
outcome = WelcomeAPotentiallyLuckyPerson.call("Han")
if outcome.success?
puts outcome.greeting # which was a promised context value
if outcome.did_i_win
puts "And you've won a prize! Lucky you. Please see the front desk for your #{outcome.prize}."
end
else # outcome.failure? is true, and we can pull the failure message out of the context for feedback to the user.
puts "Rats... I can't say hello to you, because #{outcome.message}."
endBecause organizers generally run through complex business logic, and every action has the potential to cause a failure, testing an organizer is functionally equivalent to an integration test.
For further examples, please visit the project's Wiki and review the "Why LightService" section above.
When nothing unexpected happens during the organizer's call, the returned context will be successful. Here is how you can check for this:
class SomeController "Everything went OK! Thanks!"
else
flash[:error] = result_context.message
render :action => "new"
end
end
endHowever, sometimes not everything will play out as you expect it. An external API call might not be available or some complex business logic will need to stop the processing of the Series of Actions. You have two options to stop the call chain:
When something goes wrong in an action and you want to halt the chain, you need to call fail! on the context object. This will push the context in a failure state (context.failure? # will evalute to true).
The context's fail! method can take an optional message argument, this message might help describing what went wrong.
In case you need to return immediately from the point of failure, you have to do that by calling next context.
In case you want to fail the context and stop the execution of the executed block, use the fail_and_return!('something went wrong') method.
This will immediately leave the block, you don't need to call next context to return from the block.
Here is an example:
class SubmitsOrderAction
extend LightService::Action
expects :order, :mailer
executed do |context|
unless context.order.submit_order_successful?
context.fail_and_return!("Failed to submit the order")
end
# This won't be executed
context.mailer.send_order_notification!
end
endIn the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd had a failure, that pushed the context into a failure state and the 4th action was skipped.
You can skip the rest of the actions by calling context.skip_remaining!. This behaves very similarly to the above-mentioned fail! mechanism, except this will not push the context into a failure state.
A good use case for this is executing the first couple of action and based on a check you might not need to execute the rest.
Here is an example of how you do it:
class ChecksOrderStatusAction
extend LightService::Action
expects :order
executed do |context|
if context.order.send_notification?
context.skip_remaining!("Everything is good, no need to execute the rest of the actions")
end
end
endIn the example above the organizer called 4 actions. The first 2 actions got executed successfully. The 3rd decided to skip the rest, the 4th action was not invoked. The context was successful.
While context.skip_remaining! skips actions in the current scope (e.g. inside a reduce_if or iterate block), sometimes you want to halt the entire execution of the organizer and any parent organizers. You can do this by calling context.skip_all_remaining!.
Consider this example:
def self.actions
[
reduce_if(->(ctx) { ctx.should_execute_conditional_actions }, [
ConditionallyRunAction1, # calls ctx.skip_all_remaining!
ConditionallyRunAction2, # skipped
]),
AlwaysRunAction, # also skipped with skip_all_remaining!
]
endIn this case, AlwaysRunAction will be skipped if skip_all_remaining! was called inside the reduce_if block. If skip_remaining! had been used instead, AlwaysRunAction would still execute because the "skip" state is normally reset when exiting a scoped block.
Benchmarking your action is needed when you profile the series of actions. You could add benchmarking logic to each and every action, however, that would blur the business logic you have in your actions.
Take advantage of the organizer's around_each method, which wraps the action calls as its reducing them in order.
Check out this example:
class LogDuration
def self.call(context)
start_time = Time.now
result = yield
duration = Time.now - start_time
LightService::Configuration.logger.info(
:action => context.current_action,
:duration => duration
)
result
end
end
class CalculatesTax
extend LightService::Organizer
def self.call(order)
with(:order => order).around_each(LogDuration).reduce(
LooksUpTaxPercentageAction,
CalculaNo open issues yet, or sync has not completed.