Linearly: linear workflow framework for Ruby
"Nothing is particularly hard if you divide it into small jobs"
- Henry Ford
TL;DR
It's like interactor
, solid_use_case
, or codequest_pipes
. But subtly different.
Linearly is a microframework for building complex workflows out of small, reusable, and composable parts. We call each such part a Step
, and their sequence - a Flow
. Step
s are effectively functions which take a State
and return a State
. Each Step
is expected to represent a discrete part of your business logic, with explicitly defined inputs
(always) and outputs
(if applicable).
State
can either be a Success
, a Failure
or it can be Finished
. The difference between Failure
and Finished
states is like between an exception and a guard clause (early return). Linearly
uses the statefully
gem for state management, so be sure to visit its docs for more details. Inside a Flow
, each individual Step
will only be executed if passed a Success
state, otherwise the flow is terminated early. It's both a simple and powerful concept.
Example
Code speaks louder than words, so let's see how Linearly could help you strucure an actual workflow. It comes from a Rails API for a React/Redux SPA, from a controller resposible for exchanging an OAuth code for a JWT token, simplified for clarity.
Controller
class SessionsController < ApplicationController
FLOW = # See [1]
Auth0::GetUserData
.>> Auth0::FindOrCreateUser # See [2]
.>> Users::EnsureActive
.>> Users::IssueToken
def create
state = Statefully::State.create(**params) # See [3]
result = FLOW.call(state).resolve # See [4]
head :unauthorized and return if result.finished? # See [5]
render json: {token: result.token}, status: :created # See [6]
end
end
Controller notes
-
FLOW
is constructed statically, and assigned to a constant. This alone allows us to eliminate an entire class of problems where you may try to create aFlow
from things that aren'tSteps
. -
The
>>
operator is defined as a method, which creates aFlow
from aStep
, or adds aStep
to aFlow
. The actualFlow
constructor is pretty mundane, but you're not likely to ever use it. Note that if you want to put eachStep
on a single line (my personal preference, heavily influenced by Elixir pipe operator), you'll need to prepend each>>
call with a dot, to tell the parser that the new line is a part of the previous statement. -
As already mentioned before, every
Step
needs to take an instance ofState
as input and return an instance ofState
as output.Flow
has the same signature, so what we're doing here is creating aState
from controller parameters. This is actually safe (unless your business logic requires some sanitization, that is), becauseFlow
validates its inputs. You can find more information about validation in one of the sections below. -
A properly implemented
Step
will rescue any exception thrown during execution, and wrap it into an instance ofStatefully::State::Failed
. Calling#resolve
on it re-raises the exception, while for any otherState
(Success
orFinished
) it simply returns itself. Unless you need some form of error introspection, it is advised that you use#resolve
liberally and don't explicitly raise from yourStep
s. This way unexpected application failures will cause crashes which your favorite exception tracker can notify you about. -
Some
Flow
s may not be expected to always complete - for example,Auth0::GetUserData
can terminate the entire flow if user data associated with the parameters passed to the controller cannot be verified with the identity provider (Auth0 in this case). It's not an exception per se, but it makes you want not to run subsquent steps. That's whereStatefully::State::Finished
comes in handy. Both it, andStatefully::State::Success
will respond withtrue
to the#successful?
message (since there is no exception), but only the former will respond withtrue
to#finished?
. SinceStatefully::State::Failed
is no longer an option (we unwrapped the state with#resolve
- see above), we can distinguish between a flow which completed, and one which was terminated early. In the latter case, we don't issue a JWT token but inform the user about their unauthorized status. -
Statefully::State
behaves like a read-onlyOpenStruct
, so all of its properties are available through reader methods. SinceFlow
validates inputs and outputs (more on that later), we can safely assume that thetoken
field (actually provided by the lastStep
) will be set on the successfulState
.
Step
Each of the steps in the flow is about 30 lines long, so you can view it whole in your text editor or IDE without having to scroll. Its tests can easily cover mutliple condition and their associated code paths. Below you will find an actual annotated Step
- the first one used in the Flow
described above.
module Auth0
class GetUserData < Linearly::Step::Static # See [1]
def self.inputs # See [2]
{code: String, redirect_path: String, state: String}
end
def self.outputs # See [3]
{user_data: Hash}
end
def call # See [4]
succeed(user_data: user_data) # See [5]
rescue Auth0Service::NotFound
finish # See [6]
end
private
def user_data
Auth0Service.from_env.user_data(auth0_params)
end
def auth0_params
{
code: code, # See [7]
redirect_path: redirect_path,
state: state.state, # See [8]
}
end
end
end
Step notes
-
Step
itself is first and foremost a concept, which we'd call an interface or a typeclass if we used a different programming language. Still, in order to make the package easier to use, Linearly includes valid implementations you can use as your base classes.Linearly::Step::Static
is one of them - please see the section below for more details. -
inputs
is one of the methods required by theStep
'interface'. It's supposed to be aHash<Symbol, Proc>
. Keys represent the names ofState
properties required by theStep
. Values are matchers taking actual input and verifying (by returningtrue
orfalse
) if it matches expectations. If you don't need such a fine-grained control over your input, you can use class name for a shorthand type checking (my personal favorite), or merelytrue
to ensure that the property exists but without checking its type or value (not recommended) - please see the documentation for more details. -
outputs
is simlar toinputs
, but in Linearly's reference implementations it is not required, since empty defaults are provided. Whileinputs
are strictly required,outputs
only make sense forSteps
which add something to theState
they return. -
call
is the only public instance method you need to implement on a subclass ofLinearly::Step::Static
. Please see its own section for more details. -
As already mentioned,
Step
s are effectively functions which take aState
and return aState
. Here, by callingsucceed
withuser_data
we're returning a newState
with an extra property set, compliant with what we promised in theoutputs
section. Note that we're callingsucceed
without a receiver here - thanks to the magic ofmethod_missing
, all unknown messages in a subclass ofLinearly::Step::Static
are by default passed to the inputState
. -
finish
is similar tosucceed
in that it returns aState
- albeit a finished instead of a successful one. What we're doing here is transforming a well known exception (identity provider not recognizing user credentials) into an orderly early return of ourflow
. -
As already mentioned, all unknown messages in a subclass of
Linearly::Step::Static
are by default passed to the inputState
.State
itself also passes all unknown messages to its underlying collection of properties, so thecode
message is eventually correctly resolved using two levels of indirection. -
state
on the other hand is a valid input, but it has a naming conflict with the a private method ofLinearly::Step::Static
, giving you access to the input state. Hence, we can't use double message redirection as withcode
, and need to explicitly send this message to the inputstate
.