Flows
Small and fast ruby framework for implementing railway-like operations.
By design it is close to Trailblazer::Operation and Dry::Transaction,
but has simpler and flexible DSL for defining operations and matching results. Also flows is faster, see Performance.
flows has no production dependencies so it can be used with any framework.
Installation
Add this line to your application's Gemfile:
gem 'flows'
And then execute:
bundle
Or install it yourself as:
gem install flows
Usage
Flows::Flow
Low-level instrument for defining execution flows. Used internally as execution engine for Flows::Operation.
Check out source code and specs for details.
Flows::Result
Result Object implementation. Inspired by Dry::Monads::Result and Rust Result Objects.
Main concepts & conventions:
- separate classes for successful (
Flows::Result::Ok) and failure (Flows::Result::Err) results- both classes has same parent class
Flows::Result
- both classes has same parent class
- result data should be a
Hashwith symbol keys and any values - result has a status
- default status for successful results is
:success - default status for failure results is
:failure
- default status for successful results is
Basic usage:
# create successful result with data {a: 1, b: 2}
result_ok = Flows::Result::Ok.new(a: 1, b: 2)
# get `:a` from result
result_ok.unwrap[:a] # 1
# get error data from result
result_ok.error[:a] # raises exception
# get status from result
result_ok.status # :success
# boolean flags
result_ok.ok? # true
result_ok.err? # false
# create successful result with data {a: 1, b: 2} and status `:custom`
result_ok_custom = Flows::Result::Ok.new({ a: 1, b: 2 }, status: :custom)
# get status from result
result_ok_custom.status # :custom
# create failure result with data {a: 1, b: 2}
result_err = Flows::Result::Err.new(a: 1, b: 2)
# get `:a` from result
result_err.unwrap[:a] # raises exception
# get error data from result
result_err.error[:a] # 1
# get status from result
result_err.status # :failure
# boolean flags
result_ok.ok? # false
result_ok.err? # true
# create failure result with data {a: 1, b: 2} and status `:custom`
result_err_custom = Flows::Result::Err.new({ a: 1, b: 2 }, status: :custom)
# get status from result
result_err_custom.status # :custom
Mixin Flows::Result::Helpers contains tools for simpler generating and matching Result Objects:
include Flows::Result::Helpers
# create successful result with data {a: 1, b: 2}
result_ok = ok(a: 1, b: 2)
# create successful result with data {a: 1, b: 2} and status `:custom`
result_ok_custom = ok(:custom, a: 1, b: 2)
# create failure result with data {a: 1, b: 2}
result_err = err(a: 1, b: 2)
# create failure result with data {a: 1, b: 2} and status `:custom`
result_err_custom = err(:custom, a: 1, b: 2)
# matching helpers
result = SomeOperation.new.call
case result
when match_ok(:custom)
# matches only successful results with status :custom
do_something
when match_ok
# matches only successful results with any status
do_something
when match_err(:custom)
# matches only failure results with status :custom
do_something
when match_err
# matches only failure results with any status
do_something
end
Flows::Operation
Let's solve simple task using operation:
- given numbers
aandb - result should contain sum of this numbers
- result should contain square of this sum
class Summator
# Make this class an operation by including this module.
# It adds DSL, initializer and call method.
# Also it includes Flows::Result::Helper both on DSL and instance level.
include Flows::Operation
# This is step definitions.
# In simplest form step defined by its name and
# step implementation expected to be in a method
# with same name.
#
# Steps will be executed in a definition order.
step :validate
step :calc_sum
step :calc_square
# Which keys of operation data we want to expose on success
ok_shape :sum, :sum_square
# Which keys of operation data we want to expose on failure
err_shape :message
# Step implementation receives execution context as keyword arguments.
# For the first step context equals to operation arguments.
#
# Step implementation must return Result Object.
# Result Objects's data will be merged into operation context.
#
# If result is successful - next step will be executed.
# If not - operation terminates and returns failure.
def validate(a:, b:, **)
err(message: 'a is not a number') unless a.is_a?(Number)
err(message: 'b is not a number') unless b.is_a?(Number)
ok
end
def calc_sum(a:, b:, **)
ok(sum: a + b)
end
# We may get data from previous steps because all results' data are merged to context.
def calc_square(sum:, **)
ok(sum_square: sum * sum)
end
end
# prepare operation
operation = Summator.new
# execute operation
result = operation.call(a: 1, b: 2)
result.ok? # true
result.unwrap # { sum: 3, sum_square: 9 } - only keys from success shape present
result = operation.call(a: nil, b: nil)
result.ok? # false
result.error # { message: 'a is not a number' } - only keys from error shape present
Result Shapes
You may limit list of exposed fields by defining success and failure shapes. After step definitions use ok_shape to define shapes of success result, and err_shape to define shapes of failure result. Examples:
# Set exposed keys for :success status of successful result.
#
# Success result will have shape like { key1: ..., key2: ... }
#
# If one of keys is missing in the final operation context an exception will be raised.
ok_shape :key1, :key2
# Set different exposed keys for different statuses.
#
# Operation result status is a status of last executed step result.
ok_shape status1: %i[key1 key2],
status2: [:key3]
# Failure shapes defined in the same way:
err_shape :key1, :key2
err_shape status1: %i[key1 key2],
status2: [:key3]
Operation definition should have exact one ok_shape DSL-call and zero or one err_shape DSL-call. If you want to disable shaping
you can write no_shape DSL-call instead of shape definitions.
Routing & Tracks
You define side tracks, even nested ones:
step :outer_1 # next step is outer_2
track :some_track do
step :inner_1 # next step is inner_2
track :inner_track do
step :deep_1 # next step is deep_2
step :deep_2 # next step is inner_2
end
step :inner_2 # next step in outer_2
end
step :outer_2
In definition above tracks will not be used because there is no routes to this tracks. You may define routing like this:
# if result is successful and has status :to_some_track - next step will be inner_1
# for any other successful results - outer_2
step :outer_1, routes(
when_ok(:to_some_track) => :some_track
)
track :some_track do
step :inner * 1, routes(when_err => :inner_track) # redirect to inner_track on any failure result
track :inner_track do
step :deep_1, routes(when_ok(:some_status) => :outer_2) # you may redirect to steps too
step :deep_2
end
step :inner_2
end
step :outer_2
You also can use less verbose, but shorter form of definition:
step :name,
match_ok(:status) => :track_name,
match_ok => :track_name
Step has default routes:
routes(
when_ok => next_step_name,
when_err => :term
)
Custom routes have bigger priority than default ones. Moreover, default routes can be overriden.
Lambda Steps
You can use lambda for in-place step implementation:
step :name, ->(a:, b:, **) { ok(sum: a + b) }
Dependency Injection
You can override or inject step implementation on initialization:
class Summator
include Flows::Operation
step :sum
ok_shape :sum
end
summator = Summator.new(deps: {
sum: ->(a:, b:, **) { ok(sum: a + b) }
})
summator.call(a: 1, b: 2).unwrap[:sum] # 3
Wrapping steps
You can wrap several steps with some logic:
step :first
wrap :wrapper do
step :wrapped
end
def wrapper(**_context)
# do smth
result = yield # execute wrapped steps
# do smth or modify result
result
end
There is routing limitation when you use wrap:
- outside
wrapblock you may route to wrapped block by wrapper name (:wrapperin the provided example) - you may route wrapped steps only to wrapped steps in the same wrap block
- you cannot route to wrapped steps from outside
Performance
You can compare performance for some cases by executing bin/benchmark. Examples for benchmark are presented in bin/examples.rb.
Flows::Operation and Dry::Trancation may be executed in two ways:
Build once: when we create operation instance once (build operation):
operation = OperationClass.new
10_000.times { operation.call }
Build each time: when we create operation instance each execution:
10_000.times { OperationClass.new.call }
flows and dry are much faster in build once way of using. Note that Trailblazer gives you only one way to execute operation.
Benchmark Results
Host:
- MacBook Pro (13-inch, 2017, Four Thunderbolt 3 Ports)
- 3.1 GHz Intel Core i5
- 8 GB 2133 MHz LPDDR3
Results:
--------------------------------------------------
- task: A + B, one step implementation
--------------------------------------------------
Warming up --------------------------------------
Flows::Operation (build each time)
9.147k i/100ms
Flows::Operation (build once)
25.738k i/100ms
Dry::Transaction (build each time)
2.294k i/100ms
Dry::Transaction (build once)
21.836k i/100ms
Trailblazer::Operation
5.057k i/100ms
Calculating -------------------------------------
Flows::Operation (build each time)
96.095k (± 2.3%) i/s - 484.791k in 5.047684s
Flows::Operation (build once)
281.248k (± 1.7%) i/s - 1.416M in 5.034728s
Dry::Transaction (build each time)
23.683k (± 1.7%) i/s - 119.288k in 5.038506s
Dry::Transaction (build once)
237.379k (± 3.3%) i/s - 1.201M in 5.066073s
Trailblazer::Operation
52.676k (± 1.5%) i/s - 268.021k in 5.089306s
Comparison:
Flows::Operation (build once): 281248.4 i/s
Dry::Transaction (build once): 237378.7 i/s - 1.18x slower
Flows::Operation (build each time): 96094.9 i/s - 2.93x slower
Trailblazer::Operation: 52676.3 i/s - 5.34x slower
Dry::Transaction (build each time): 23682.9 i/s - 11.88x slower
--------------------------------------------------
- task: ten steps returns successful result
--------------------------------------------------
Warming up --------------------------------------
Flows::Operation (build each time)
1.496k i/100ms
Flows::Operation (build once)
3.847k i/100ms
Dry::Transaction (build each time)
274.000 i/100ms
Dry::Transaction (build once)
2.992k i/100ms
Trailblazer::Operation
1.082k i/100ms
Calculating -------------------------------------
Flows::Operation (build each time)
15.013k (± 3.8%) i/s - 76.296k in 5.089734s
Flows::Operation (build once)
39.239k (± 1.6%) i/s - 196.197k in 5.001538s
Dry::Transaction (build each time)
2.743k (± 3.7%) i/s - 13.700k in 5.002847s
Dry::Transaction (build once)
30.441k (± 1.8%) i/s - 152.592k in 5.014565s
Trailblazer::Operation
11.022k (± 1.4%) i/s - 55.182k in 5.007543s
Comparison:
Flows::Operation (build once): 39238.6 i/s
Dry::Transaction (build once): 30440.5 i/s - 1.29x slower
Flows::Operation (build each time): 15012.7 i/s - 2.61x slower
Trailblazer::Operation: 11022.1 i/s - 3.56x slower
Dry::Transaction (build each time): 2743.0 i/s - 14.30x slower
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. 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.
Contributing
Bug reports and pull requests are welcome on GitHub at ffloyd/fflows. 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.
Code of Conduct
Everyone interacting in the Flows project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.