Hopscotch
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.
Installation
Add this line to your application’s Gemfile:
ruby
gem 'hopscotch'
And then execute:
$ bundle
Or install it yourself as:
$ gem install hopscotch
Usage
The Hopscotch gem is made up out of 2 essential parts. Runners and Steps.
Some simple usage examples. Detailed explanations below.
```ruby # - 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” ```
Top level architecture
Runners
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.
ruby
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.
```ruby 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 ) ```
Steps
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.
```ruby 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)
A typical use-case
```ruby 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 ```
Reusing steps
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.
```ruby 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.
```ruby 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.
```ruby 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.
```ruby 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.
```ruby 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.
Development
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.
Contributing
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.
License
The gem is available as open source under the terms of the MIT License.