Hopscotch allows us to chain together complex logic and ensure if any specific part of the chain fails, everything is rolled back to its original state.
Add this line to your application's Gemfile:
gem 'hopscotch'
And then execute:
$ bundle
Or install it yourself as:
$ gem install hopscotch
The Hopscotch gem is made up out of 2 essential parts. Runners and Steps.
Some simple usage examples. Detailed explanations below.
# - simple lambdas steps
# - compose steps into 1 function
# - runner call function with success/failure callbacks
success_step = -> { Hopscotch::Step.success! }
fail_step = -> { Hopscotch::Step.failure!("bad") }
reduced_fn = Hopscotch::StepComposer.compose_with_error_handling(success_step, success_step, success_step)
Hopscotch::Runner.call(reduced_fn, success: -> { "success" }, failure: -> (x) { "failure: #{x}" })
# => "success"
error_reduced_fn = Hopscotch::StepComposer.compose_with_error_handling(success_step, fail_step, success_step)
Hopscotch::Runner.call(error_reduced_fn, success: -> { "success" }, failure: -> (x) { "failure: #{x}" })
# => "failure: bad"
# - simple lambdas steps
# - runner call function + compose steps inline with success/failure callbacks
success_step_1 = -> { Hopscotch::Step.success! }
success_step_2 = -> { Hopscotch::Step.success! }
Hopscotch::Runner.call(
Hopscotch::StepComposer.call_each(success_step_1, success_step_2),
success: -> { "success" },
failure: -> (x) { "failure: #{x}" },
)
# => "success"
# Module method to compose multiple steps into 1 step
# - runner call composed function with success/failure callbacks
module ChainSteps
extend self
def call
Hopscotch::StepComposer.call_each(
-> { Hopscotch::Step.success! },
-> do
if 2.even?
Hopscotch::Step.success!
else
Hopscotch::Step.failure!
end
end
)
end
end
Hopscotch::Runner.call_each(
ChainSteps,
success: -> { "success" },
failure: -> (x) { "failure: #{x}" },
)
# => "success"
A runner is a pipeline to run steps and handle the success or failure of the group of them.
Runners are not meant to be the point of reuse or shared behavior. They are simply a way to run steps.
Runners can call an array of steps and compose them under the hood.
Hopscotch::Runner.call_each(
-> { Hopscotch::Step.success! },
-> { Hopscotch::Step.success! },
success: -> { success.call("The step was successful!", Time.now.to_i) },
failure: failure
)
You can optionally compose the steps manually (great for reuse) and just make use of the Runner#call
method.
success_step_1 = -> { Hopscotch::Step.success! }
success_step_2 = -> { Hopscotch::Step.success! }
Hopscotch::Runner.call(
Hopscotch::StepComposer.call_each(success_step_1, success_step_2),
success: -> { success.call("The step was successful!", Time.now.to_i) },
failure: failure
)
A step is a function type. It can be plugged into any module/class as long as it conforms to returning Hopscotch::Step.success!
or Hopscotch::Step.failure!
These two functions wrap the return value to let the runner know if the step was successful or not.
module Service
module AddItemToCart
extend self
def call(item, cart)
if cart.add(item)
Hopscotch::Step.success!
else
Hopscotch::Step.failure!
end
end
end
end
note You can optionally pass in values to success!
and failure!
to be used outside of the step. ie: failure!(cart.errors)
class UsersController < ApplicationController
def create
success = -> (response, time) { redirect_to root_path, notice: "#{response} - at: #{time}" }
failure = -> { render :new }
Workflow::CreateUser.call(params[:name], success: success, failure: failure)
end
end
module Workflow
module CreateUser
extend self
def call(name, success:, failure:)
Hopscotch::Runner.call_each(
-> { Service::CreateUser.call(name) },
success: -> { success.call("Workflow::CreateUser worked!", Time.now.to_i) },
failure: failure
)
end
end
end
module Service
module CreateUser
extend self
def call(name)
if User.create(name: name)
Hopscotch::Steps.success!
else
Hopscotch::Steps.failure!
end
end
end
end
A common problem you might run into when dealing with multiple runners and steps is the need to copy 90% of a previous runner but just change 1 or 2 step calls. Let's make it happen.
Let's take an example of Signup
runner which creates a student, and sends them an email.
module Workflow
module Signup
def call(student_params, success:, failure:)
# We make heavy use of form objects, so this is a common pattern for us
# but you can really do what ever you want in here..
form = Form::NewStudent.new(student_params)
Hopscotch::Runner.call_each(
-> { Service::CreateStudent.call(form) }, # these return `Hopscotch::Step.success!` or `Hopscotch::Step.failure!`
-> { Service::NotifyStudent.call(form) }
success: success,
failure: failure
)
end
end
end
Here's a brief example of what the services might look like.
module Service
module CreateStudent
extend self
def call(student_form)
if student_form.valid? && student_form.save
Hopscotch::Steps.success!
else
Hopscotch::Steps.failure!(student_form.errors)
end
end
end
end
module Service
module NotifyStudent
extend self
def call(student_form)
if Notify::SendMail.new(student_form).deliver
Hopscotch::Steps.success!
else
Hopscotch::Steps.failure!
end
end
end
end
Here we just ensure the student is valid and persisted and then send a mailer.
All is well in love and steps. Let's assume that something in the system has to change (as it rarely does..), and we need to add a new step to the process to the runner, but only for a segmented part of our user base. Phew..
The new feature request comes in and we want to give free points to a student when they signup if they are home schooled. While we could chunk some lovely if statements into our runner or steps to do this, I prefer to create a new runner only for this particular interaction in the system. Let's start.
module Workflow
module SignupWithFreePoints
def call(student_params, success:, failure:)
form = Form::NewStudent.new(student_params)
Hopscotch::Runner.call_each(
-> { Service::CreateStudent.call(form) }, # this is duplication.. :(
-> { Service::NotifyStudent.call(form) }, # this is duplication.. :(
-> { Service::GiveFreePointsToStudent.call(form) }, # this sucker is the new one
success: success,
failure: failure
)
end
end
end
While this example could work, we're already duplicating code and things could easily get out of sync with the previous Signup runner. Let's say steps get removed or changed and we forget to update in both places.. bug reports will soon roll in. Let's fix this.
Here is where Steps shine. Steps can be composed to nest other steps. Let's see how we can clean up our code with a new Step that utilizes this.
module Service
module CreateStudentAndNotify
extend self
def call(student_form)
# This will bubble up the success or failure of both of these nested steps
# and return a success! or failure! depending on the collected results.
Hopscotch::StepComposer.call_each(
-> { Service::CreateStudent.call(student_form) },
-> { Service::NotifyStudent.call(student_form) }
)
end
end
end
What does this mean for our runner? They get simpler and allow for quick reuse! Let's see.
module Workflow
module Signup
def call(student_params, success:, failure:)
form = Form::NewStudent.new(student_params)
Hopscotch::Runner.call_each(
-> { Service::CreateStudentAndNotify.call(form) },
success: success,
failure: failure
)
end
end
end
module Workflow
module SignupWithFreePoints
def call(student_params, success:, failure:)
form = Form::NewStudent.new(student_params)
Hopscotch::Runner.call_each(
-> { Service::CreateStudentAndNotify.call(form) }, # re-use steps
-> { Service::GiveFreePointsToStudent.call(form) }, # the new step
success: success,
failure: failure
)
end
end
end
2 runners, different behavior - no duplication. It's a beauty.
After checking out the repo, run bin/setup
to install dependencies. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/blake-education/hopscotch. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
The gem is available as open source under the terms of the MIT License.