"Nothing is particularly hard if you divide it into small jobs"
- Henry Ford
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. Steps 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.
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.
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-
FLOWis constructed statically, and assigned to a constant. This alone allows us to eliminate an entire class of problems where you may try to create aFlowfrom things that aren'tSteps. -
The
>>operator is defined as a method, which creates aFlowfrom aStep, or adds aStepto aFlow. The actualFlowconstructor is pretty mundane, but you're not likely to ever use it. Note that if you want to put eachStepon 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
Stepneeds to take an instance ofStateas input and return an instance ofStateas output.Flowhas the same signature, so what we're doing here is creating aStatefrom controller parameters. This is actually safe (unless your business logic requires some sanitization, that is), becauseFlowvalidates its inputs. You can find more information about validation in one of the sections below. -
A properly implemented
Stepwill rescue any exception thrown during execution, and wrap it into an instance ofStatefully::State::Failed. Calling#resolveon it re-raises the exception, while for any otherState(SuccessorFinished) it simply returns itself. Unless you need some form of error introspection, it is advised that you use#resolveliberally and don't explicitly raise from yourSteps. This way unexpected application failures will cause crashes which your favorite exception tracker can notify you about. -
Some
Flows may not be expected to always complete - for example,Auth0::GetUserDatacan 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::Finishedcomes in handy. Both it, andStatefully::State::Successwill respond withtrueto the#successful?message (since there is no exception), but only the former will respond withtrueto#finished?. SinceStatefully::State::Failedis 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::Statebehaves like a read-onlyOpenStruct, so all of its properties are available through reader methods. SinceFlowvalidates inputs and outputs (more on that later), we can safely assume that thetokenfield (actually provided by the lastStep) will be set on the successfulState.
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-
Stepitself 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::Staticis one of them - please see the section below for more details. -
inputsis one of the methods required by theStep'interface'. It's supposed to be aHash<Symbol, Proc>. Keys represent the names ofStateproperties required by theStep. Values are matchers taking actual input and verifying (by returningtrueorfalse) 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 merelytrueto ensure that the property exists but without checking its type or value (not recommended) - please see the documentation for more details. -
outputsis simlar toinputs, but in Linearly's reference implementations it is not required, since empty defaults are provided. Whileinputsare strictly required,outputsonly make sense forStepswhich add something to theStatethey return. -
callis 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,
Steps are effectively functions which take aStateand return aState. Here, by callingsucceedwithuser_datawe're returning a newStatewith an extra property set, compliant with what we promised in theoutputssection. Note that we're callingsucceedwithout a receiver here - thanks to the magic ofmethod_missing, all unknown messages in a subclass ofLinearly::Step::Staticare by default passed to the inputState. -
finishis similar tosucceedin 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::Staticare by default passed to the inputState.Stateitself also passes all unknown messages to its underlying collection of properties, so thecodemessage is eventually correctly resolved using two levels of indirection. -
stateon 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.