Description
Be able to chain ruby commands, and treat them like a flow.
It thus provides a new approach to flow control.
General presentation slides can be found here.
Check the slides here for a refactoring example.
Waterfall alternatives and similar gems
Based on the "Abstraction" category.
Alternatively, view Waterfall alternatives based on common mentions on social networks and blogs.
-
Interactor
Interactor provides a common interface for performing complex user interactions. -
wisper
A micro library providing Ruby objects with Publish-Subscribe capabilities -
ActiveInteraction
:briefcase: Manage application specific business logic. -
Decent Exposure
A helper for creating declarative interfaces in controllers -
Mutations
Compose your business logic into commands that sanitize and validate input. -
Rails Event Store
A Ruby implementation of an Event Store based on Active Record -
dry-types
Flexible type system for Ruby with coercions and constraints -
Light Service
Series of Actions with an emphasis on simplicity. -
Amoeba
A ruby gem to allow the copying of ActiveRecord objects and their associated children, configurable with a DSL on the model -
SimpleCommand
A simple, standardized way to build and use Service Objects (aka Commands) in Ruby -
u-service
Represent use cases in a simple and powerful way while writing modular, expressive and sequentially logical code. -
Surrounded
Create encapsulated systems of objects and focus on their interactions -
PageletRails
Improve perceived performance of your rails application with minimum effort -
Smart Init - Simple service objects in Ruby
A simple gem for eliminating Ruby initializers boilerplate code, and providing unified service objects API -
SuperModule
SuperModule allows defining class methods and method invocations the same way a super class does without using def included(base). This also succeeds ActiveSupport::Concern by offering lighter syntax -
skinny_controllers
A pattern for allowing for easier testing of large projects' business logic -
Invokable
Objects are functions! Treat any Object or Class as a Proc (like Enumerable but for Procs). -
Lionshare
A Ruby interface to the Lionshare API (cryptocurrency prices) -
EasilyTypable
Ruby module that facilitates English-like type checking in an inheritance hierarchy via "type_name?" methods -
grpc_serializer
A simple library to encode nested hash to grpc object and vice versa -
simple_active_link_to
Simple rails view helper to manage "active" state of a link -
dry-rb
dry-rb is a collection of next-generation Ruby libraries, each intended to encapsulate a common task.
Access the most powerful time series database as a service
* Code Quality Rankings and insights are calculated and provided by Lumnify.
They vary from L1 to L5 with "L5" being the highest.
Do you think we are missing an alternative of Waterfall or a related project?
README
Goal
Chain ruby commands, and treat them like a flow, which provides a new approach to application control flow.
When logic is complicated, waterfalls show their true power and let you write intention revealing code. Above all they excel at chaining services.
Material
Upcoming book about failure management patterns, leveraging the gem: The Unhappy path
General presentation blog post there: Chain services objects like a boss.
Reach me @apneadiving
Overview
A waterfall object has its own flow of commands, you can chain your commands and if something wrong happens, you dam the flow which bypasses the rest of the commands.
Here is a basic representation:
- green, the flow goes on,
chain
bychain
- red its bypassed and only
on_dam
blocks are executed.
Example
class FetchUser
include Waterfall
def initialize(user_id)
@user_id = user_id
end
def call
chain { @response = HTTParty.get("https://jsonplaceholder.typicode.com/users/#{@user_id}") }
when_falsy { @response.success? }
.dam { "Error status #{@response.code}" }
chain(:user) { @response.body }
end
end
and call / chain:
Flow.new
.chain(user1: :user) { FetchUser.new(1) }
.chain(user2: :user) { FetchUser.new(2) }
.chain {|outflow| puts(outflow.user1, outflow.user2) } # report success
.on_dam {|error, context| puts(error, context) } # report error
Which works like:
Installation
For installation, in your gemfile:
gem 'waterfall'
then bundle
as usual.
Waterfall mixin
Outputs
Each waterfall has its own outflow
and error_pool
.
outflow
is an Openstruct so you can get/set its property like a hash or like a standard object.
Wiki
Wiki contains many details, please check appropriate pages:
Koans (!)
You can try and exercise your understanding of Waterfall using the Koans here
Illustration of chaining
Doing
Flow.new
.chain(foo: :bar) { Flow.new.chain(:bar){ 1 } }
is the same as doing:
Flow.new
.chain do |outflow, parent_waterfall|
unless parent_waterfall.dammed?
child = Wf.new.chain(:bar){ 1 }
if child.dammed?
parent_waterfall.dam(child.error_pool)
else
parent_waterfall.ouflow.foo = child.outflow.bar
end
end
end
Hopefully you better get the chaining power this way.
Syntactic sugar
Given:
class MyWaterfall
include Waterfall
def call
self.chain { 1 }
end
end
You may have noticed that I usually write:
Flow.new
.chain { MyWaterfall.new }
instead of
Flow.new
.chain { MyWaterfall.new.call }
Both are not really the same:
- the only source of information for the gem is the return value of the block
- if it returns a
Waterfall
, it will apply chaining logic. If ever the waterfall was not executed yet, it will triggercall
, hence the convention. - if you call your waterfall object inside the block, the return value would be whatever your
call
method returns. So the gem doesnt know there was a waterfall involved and cannot apply chaining logic... unless you ensureself
is always returned, which is cumbersome, so it's better to avoid this
Syntax advice
# this is valid
self
.chain { Service1.new }
.chain { Service2.new }
# this is equivalent
self.chain { Service1.new }
self.chain { Service2.new }
# this is equivalent too
chain { Service1.new }
chain { Service2.new }
# this is invalid Ruby due to the extra line
self
.chain { Service1.new }
.chain { Service2.new }
Tips
Error pool
For the error_pool, its up to you. But using Rails, I usually include ActiveModel::Validations in my services.
Thus you:
- have a standard way to deal with errors
- can deal with multiple errors
- support I18n out of the box
- can use your model errors out of the box
Conditional Flow
In a service, there is one and single flow, so if you need conditionals to branch off, you can do:
self.chain { Service1.new }
if foo?
self.chain { Service2.new }
else
self.chain { Service3.new }
end
Halting chain
Sometimes you have a flow and you need a return value. You can use halt_chain
, which is executed whether or not the flow is dammed. It returns what the block returns. As a consequence, it cannot be chained anymore, so it must be the last command:
self.halt_chain do |outflow, error_pool|
if error_pool
# what you want to return on error
else
# what you want to return from the outflow
end
end
Rails and transactions
I'm used to wrap every single object involving database interactions within transactions, so it can be rolled back on error. Here is my usual setup:
module Waterfall
extend ActiveSupport::Concern
class Rollback < StandardError; end
def with_transaction(&block)
ActiveRecord::Base.transaction(requires_new: true) do
yield
on_dam do
raise Waterfall::Rollback
end
end
rescue Waterfall::Rollback
self
end
end
And to use it:
class AuthenticateUser
include Waterfall
include ActiveModel::Validations
validates :user, presence: true
attr_reader :user
def initialize(email, password)
@email, @password = email, password
end
def call
with_transaction do
chain { @user = User.authenticate(@email, @password) }
when_falsy { valid? }
.dam { errors }
chain(:user) { user }
end
end
end
The huge benefit is that if you call services from services, everything will be rolled back.
Undo
If you get to dam a flow, this would trigger the reverse_flow
method in all Services previously executed.
reverse_flow
is not executed on the service which just failed, consider the on_dam
hook in this case.
Take this as a hook to undo whatever you need to undo if things go wrong. Yet, you probably do not need to bother about databases inserts: this is the purpose of with_transaction
.
FYI
Flow
is just an alias for the Wf
class, so just use the one you prefer :)
Examples / Presentations
- Check the wiki for other examples.
- Structure and chain your POROs.
- Service objects implementations.
- Handling error in Rails.
Thanks
Huge thanks to robhorrigan for the help during infinite naming brainstorming.