{{ page.title }}
- Last updated 05 May 2017
- representable v3.0
Tutorials
- Last updated 27 Feb 21
Railway Basics
Every modern application is composed of many different domain processes that need to be modeled, implemented, and maintained. Whether this is the life-cycle of a <user>
entity or just a sign-up function, it has to be defined and coded somewhere.
Trailblazer strikes when it comes to organizing business code.
If you’re interested in learning how to organize code, where to use Trailblazer’s activities and how to model basic workflows using the Railway
pattern, this tutorial is for you.
Already know how step
, pass
and fail
work? Keyword arguments from ctx
and #wtf?
bore you? Jump right to [the next chapter]!
Activities
Trailblazer is an architectural pattern that comes with Ruby libraries to implement that pattern. While there are many interesting layers such as Cells for the view, or Reform for validations and forms, the Activity component is the heart of TRB.
An activity is a high-level concept to structure code flow and provide interfaces so you don’t have to think about them. Instead of one big code pile, activities will gently enforce a clean, standardized way for organizing code.
Activities are a necessary abstraction on top of Ruby. They help streamlining the control flow, and take away control code while providing you with an incredibly cool developer experience.
You’re allowed to blame us for a terrible developer experience in Trailblazer 2.0. It’s been quite painful to find out which step caused an exception. However, don’t look back in anger! We’ve spent a lot of time on working out a beautiful way for both tracing and debugging Trailblazer activities in 2.1.
Activities can be used for any kind of logic and any level of complexity. Originally designed to “only” implement railways for CRUD logic, we now use activities in many parts of Trailblazer itself, from DSL options processing code, for pluggable, nested components of our applications, up to long-running processes, such as a user’s life-cycle, that is comprised of a dozen or more nested activities.
An Oauth Signup
In this tutorial, we implement a sign-up function for a Ruby application. The first version only allows signups (and signing-in existing users) via Github Oauth. Don’t worry, we are not going to discuss the bits ‘n bytes of Oauth.
It’s a scenario directly taken from the Trailblazer PRO application which allows us to discuss a bunch of important concepts in Trailblazer.
When clicking the Github link, the beautiful [omniauth gem] performs its magic. It handles all Oauth details and will - in case of a successful authorization via Github - send a hash of login data shared by Github to a pre-configured controller actions of our app.
All we need to do now is receive the data sent from Github, decide whether this is a new user and save their data, or an existing user, and then sign them into our application.
At this stage, routing, controllers, etc is irrelevant. Just imagine a Rails controller action, a Sinatra router or a Hanami action as follows.
def auth
# at this stage, we're already authenticated, it's a valid Github user!
result = Signup.call(params: params)
end
The Trailblazer architectural style encapsulates all business logic for one function in one operation*. In other words: the controllers usually contain only routing and rendering code and dispatch instantly to a particular operation/activity class.
*An Operation is always an activity.
Whatever data from the outside is needed in the activity has to be passed explicitely into the activity’s call
method.
In our case, the sign-up is handled in the Signup
activity. We pass the params
hash into it, which roughly looks like this.
{
:provider=>"github",
:info=>{
:nickname=>"apotonick",
:email=>"apotonick@gmail.com",
:name=>"Nick Sutterer"
}
}
So, let’s review: Omniauth handles Oauth authorization for us. Regardless of the implementation, this is usually automagic for the developer. The gist of it is: Omniauth sends a hash of data to our auth
controller action once it’s done. Now, it’s the Signup
-activity’s duty to process that data. We’re going to implement just that now. Are you ready?
A Railway activity
In the first throw of this requirement, we need to parse the omniauth hash, make sure it’s in a format we understand, find out what user is signing in, log that successful sign-in somewhere, and finally communicate to the outer world that the signin was successful.
We do ignore new sign-ups at this stage and only allow existing users to sign in.
A diagram resembling this chain of tasks would look like the following fragment of a BPMN diagram.
Now let’s drop our optimism for a second, and face cold reality. Things could go wrong in two places here. First, the validation could fail if Github sends us data we don’t understand. Second, we might not know the user signing in, meaning the “find user” logic has to error-out, leading us to a diagram like this.
If anything here did fail in “validate omniauth”, all other steps in the chain would be skipped as the flow would follow the path leading to the failure
terminus.
Assuming the validation was successful, if the user provided by Github wasn’t found in our system (in the “find user” box), the remaining logging step would be skipped, ending up in that mysterious failure
circle, again.
Intuitively, you understand the flow just by looking at the BPMN diagram. And, heck, we haven’t even discussed BPMN or any terminology, yet!
Modelling the flow of a program where chunks of code are executed in a certain order, with a successful “happy path” and an “error-out” path is called a Railway. It popped up in functional languages [a long time ago].
Terminology
Before we continue, let us quickly clarify some lingo when working with Trailblazer, BPMN, and activities.
Be honest, you’re loving my handwriting already, aren’t you?
- The start event is where the flow starts. It’s a simple circle.
- Every path or flow stops in a terminus event. Those are the filled circles. Often, we call them end event, too!
- Your actual logic happens in tasks, the labeled boxes. A task may be any callable Ruby object, an instance method or even another activity.
- Every task has at least one output which identifies an outgoing connection to the next element. An output in Trailblazer keeps a semantic and is triggered by exactly one signal from its task. A task is often called step.
- The “happy path” or “success track” is the straight path from start to the terminus named
success
. - The “error path” or “failure track” is the lower path going the the
failure
terminus.
Implementation
Our present job is to implement those four consecutively invoked steps.
While you could program this little piece of logic and flow yourself using a bunch of Ruby methods along with a considerable amount of if
s and else
s, and maybe elsif
, if you’re feeling fancy, a Trailblazer activity provides you a simple API for creating such flow without having to write and maintain any control code. It is an abstraction.
class Signup < Trailblazer::Activity::Railway
step :validate
pass :extract_omniauth
step :find_user
pass :log
end
Six lines of code create an executable object that, when invoked, will run your code in the order as visible in our diagram, plus the ability to “error out” when something goes wrong.
We’ll get to explaining what such a step looks like, or how its code chunk can “go wrong”. Relax.
Please do note that we’re using two different DSL methods: The #step
method will allow “erroring out”, its friend #pass
enforces successful outcomes, only. More on that later.
In order to invoke, or run, this activity we just created, there is one recommended public way: its invoke
method. You might remember that from the controller example above.
ctx = {params: {provider: "Nickhub"}}
signal, (ctx, _) = Signup.invoke([ctx], {})
Ignore the clumsy-looking invoke
API here for a minute. This is what sets off the execution of the activity. From start to end all boxes on the taken path are executed. Each step receives the return value of its predecessor. The return value decides about what next step is called.
Your excitement when running this code the first time will be smashed to pieces in an instant with the following exception.
NameError: undefined method `validate' for class `Signup'
Obviously, the implementation of the actual tasks is still due. It’s a good time to do that now.
Technically, a task in an activity can be any callable Ruby object. There are [numerous ways to define tasks]. What we will be using at this stage of the tutorial is the :instance_method
-style. This means you define instance methods in the activity class and pass their :method_name
s to the DSL step
method.
Let’s go through each method step by step (no pun intended!). Here’s #validate
implemented in pure Ruby.
class Signup < Trailblazer::Activity::Railway
step :validate
pass :extract_omniauth
step :find_user
pass :log
# Validate the incoming Github data.
# Yes, we could and should use Reform or Dry-validation here.
def validate(ctx, params:, **)
is_valid = params.is_a?(Hash) && params["info"].is_a?(Hash) && params["info"]["email"]
is_valid # return value matters!
end
# ...
end
Task methods always expose the so called [task interface] (unless configured otherwise), meaning both its arguments as well as the return value do matter!
Now, have a look at #validate
. The first argument is the ctx
data structure. This is whatever you passed into the activity invocation. Use the ctx
object to access string keys and to write state to.
def validate(ctx, params:, **)
raise params.inspect
end
Every symbol key in ctx
is automatically available as a keyword argument, such as params:
above. Using a hard-core raise
you can quickly find out what’s this about.
To hit the raise
, invoke the activity.
ctx = {params: {provider: "Nickhub"}}
signal, (ctx, _) = Signup.invoke([ctx], {})
#=> RuntimeError: {:provider=>"Nickhub"}
As an avid engineer, you instantly realize the params:
keyword argument in #validate
represents whatever was passed under that key in the ctx
object. So simple!
Keyword arguments are very encouraged in Trailblazer as they’re elegant and have a bunch of beautiful features.
Keyword arguments allow to define particular parameters as required. Should the parameter be missing, they also provide a way to set a default value. This is all done with pure Ruby.
Always remember that you don’t have to use keyword arguments - you could simply go the longer, less elegant way through ctx
.
def validate(ctx, **)
params = ctx[:params] # no keyword argument used!
raise params.inspect
end
The outcome is identical to the above.
Now that we understand what goes into a task, let’s learn about what goes out.
When using the task interface, the return value of a method is important!
def validate(ctx, params:, **)
is_valid = params.is_a?(Hash) && params["info"].is_a?(Hash) && params["info"]["email"]
is_valid # return value matters!
end
In #validate
, our highly sophisticated validation will return either true
or false
, causing the activity to proceed to the next step, or “error out”.
In other words: different return values will trigger different outputs of the task. The flow will continue on the outgoing connection of the output.
Two things.
- Yes, we are going to use a real validation library later, Reform or Dry-validation, or both.
- And, yes, you may return other signals from a task and thus have more than two outgoing connections. We’ll elaborate on that [in part II]!
We still haven’t put all pieces into place in the activity. Some task methods are missing.
Pass
May I bring your attention to the second step extract_omniauth
? In this step, we extract relevant data from the Oauth data structure for further processing.
class Signup < Trailblazer::Activity::Railway
step :validate
pass :extract_omniauth
step :find_user
pass :log
# ...
end
Since the structure from Github is already validated, we can safely assume there’s no “erroring out” necessary here. The pass
DSL method does ignore the actual return value and will always return true
.
def extract_omniauth(ctx, params:, **)
ctx[:email] = params["info"]["email"]
end
Given Omniauth & Github are providing a proper data structure, we now have the email of the signing-in user directly on the ctx
object - simply by writing to it. You can use this new value email:
in following methods as a keyword argument!
A key/value pair written to ctx
, e.g. by doing ctx[:email] = "yogi@trb.to"
is sometimes called a variable. So, when we’re talking about a variable, it’s refering to either a key and its value in ctx
, or to a keyword argument (which is one and the same).
Again, when using a task method with pass
, the returned value of your logic is irrelevant, it will always stay on the “happy path”.
Active Record? Logging?
Still some work to do! After validating and extracting the email address from the structure provided by Github, we can finally check if this user is already existing in our system, or needs to be added. The #find_user
method is our next step.
def find_user(ctx, email:, **)
user = User.find_by(email: email)
ctx[:user] = user
end
In this code chunk we use ActiveRecord* and its #find_by
method to either retrieve an existing user model, or nil
. Check out how we can use the variable email
as a keyword argument, being computed and provided by (one of) the previous step(s).
As this step is added to the activity via step
, the return value is crucial!
*The fact we’re using ActiveRecord (or something looking like it) doesn’t mean Trailblazer only works with Rails! Most people are familiar with its API, so we chose to use “ActiveRecord” in this tutorial.
The activity - at this stage - deviates to the error track when there’s no user and skips all remaining logic. This is, of course, because the last statement in #find_user
will evaluate to nil
in case of a new signup, as this email address is yet unknown. A falsey return value means “error track”.
To finish up the activity v1, we add an empty logging step. You probably already got the hang of it, anyway.
def log(ctx, **)
# run some logging here
end
Our Signup
activity is now ready to be executed, even though it doesn’t cover all business requirements, yet, and is unfinished.
Invocation, for real!
First, let’s learn about a failing case where Github sends us data of a user we don’t have in our system, yet.
The following snippet shows you a realistic data structure coming from Github (given that I’m logged-in there, which… I currently am). The last line shows how the initial ctx
is created.
data_from_github = {
"provider"=>"github",
"info"=>{
"nickname"=>"apotonick",
"email"=>"apotonick@gmail.com",
"name"=>"Nick Sutterer"
}
}
ctx = {params: data_from_github}
Our “database” is initialized to contain no users, which should make the activity end up on the failure
terminus.
User.init! # Empty users table.
signal, (ctx, _) = Signup.invoke([ctx], {})
puts signal #=> #<Trailblazer::Activity::End semantic=:failure>
puts ctx[:user] #=> nil
Admittedly, both the signature and the return values of invoke
feel a bit clumsy. That’s becaus we’re currently working with the low-level interfaces.
The returned signal
is the last element in the activity that’s being executed. Visually, it’s usually represented by a circle. In our railway example, that would be either the success
or the failure
terminus event.
Inspecting the signal
, it looks as if we hit the failure
terminus. Furthermore, peeking into ctx
, the :user
variable is unset, hinting that the path we took is the one visualized here.
To have a successful sign-up, the user must be present in our database. Here’s the invocation with the user already exising.
User.init!(User.new("apotonick@gmail.com"))
signal, (ctx, _) = Signup.invoke([ctx], {})
puts signal #=> #<Trailblazer::Activity::End semantic=:success>
puts ctx[:user] #=> #<User email: "apotonick@gmail.com">
This time, the signal
looks like we’re winning. Also, the user is set from the #find_user
step, suggesting the following flow.
The provided data from Github is sane, the user is found by their email, and we’re set to sign them in with a cookie (yet to be done).
Error handling
For completeness, let’s quickly discuss how to place tasks on the error track. #fail
allows you to add error handlers.
class Signup < Trailblazer::Activity::Railway
step :validate
pass :extract_omniauth
fail :save_validation_data
step :find_user
pass :log
# ...
def save_validation_data(ctx, params:, **)
Logger.info "Signup: params was #{params.inspect}"
end
end
As visible in the diagram, #fail
puts the error handler on the error track, while maintaining the linear order of the flow: it sits after extract_omniauth
, but before find_user
.
Per default, the return value from a fail
task is irrelevant, it will always stay on the error track. In part II we will cover how to jump back to the happy path.
It’s also important to understand that save_validation_data
will only be invoked for an error from validate
(and if extract_omniauth
were attached using step
). In other words, find_user
and followers do not have an error handler.
We are going to revert this fail
feature for the time being. In the next tutorial about nesting and the wiring API, we will meet this handler, again.
Terminus signals
When invoking activities, you will want to find out where it stopped and what end has been hit, so you can decide what to do. For example, a Rails controller could either redirect to the dashboard page, in case of a successful sign-in, or render an error page.
There are two ways to learn that a terminus has been reached. You could simply inspect its semantic.
signal.to_h[:semantic] #=> :success
Every element in TRB provides a #to_h
method to decompose it. Terminus events will have a field :semantic
, and in a standard railway activity, they’re either :success
or :failure
.
Alternatively, you can check the signal’s identity against the terminus that you’re expecting, for instance by using the Introspection API.
Trailblazer::Activity::Introspect::Graph(Signup).find("End.success")[:task] == signal
The return signal
is simply the object or task that was executed last. In railways, the automatic IDs for end events are End.success
and End.failure
, that why you can [retrieve those object using Graph#find
].
Exposing data
You probably already got the idea from previous example code: the only way to expose data to the outer world (whoever invoked the activity) is by reading from the ctx
object. In other words, if any data from within the activity is needed, say, for rendering an HTML page or taking a routing decision in a controller, it has to be written to ctx
at some point during execution of the activity.
ctx[:user] #=> #<struct User email=\"apotonick@gmail.com\">
There are [mechanics to filter what goes in and out], but keep in mind that Trailblazer forces you, in a gentle, tender way, to explicitly define what you want the caller to know, by writing it to ctx
.
WTF?
Now that we’ve discussed all basic concepts of activities, let’s check out some of the tooling around Trailblazer.
A huge advantage over messy Rails code is that Trailblazer’s Activity
hides logic, flow and variables from the caller. The entire sign-up works by invoking the black box activity in the controller.
While developers appreciate the encapsulation, they used to hate the debugging: finding out what path the execution took. We “recently” added tracing to do just that (it only took three years).
This is my absolute favorite feature ever and the official reason for (re-)writing Trailblazer 2.1. It makes me happy every time I use it.
Simply use #wtf?
to invoke your operation.
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, ctx)
The signature and return values are identical to #invoke
. However, now, tracing is turned on, and will output the flow to the terminal.
No more guessing anymore, you can follow the path and even in deeply nested activity structures you won’t get lost anymore.
What sounds like a cheesy commercial slogan is actually about to become your best friend. Check out how #wtf?
also allows to find out where things broke, in case of an exception.
By making the ctx
object frozen, it will throw an exception whenever we write to ctx
using ctx[:something] = ...
, which should be the case in #extract_omniauth
the first time.
ctx = {params: data_from_github}.freeze #=> no setter anymore!
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, ctx)
As promised, #wtf?
catches that and shows you the closest task in red.
With Ruby only knowing methods and files and stack-traces from hell, Trailblazer is years ahead when it comes to debugging. Having an understanding of higher level abstractions, such as tasks, activities and the historical code path taken, its debugging trace is much closer to how you, as an engineer, think about your code.
This feature has saved me hours of debugging, before it was even released.
Next!
In the next tutorial we will focus on the Wiring API and learn how to create more complex activities. The sign-up code to enter a new user into our database needs to be written. To help you reduce complexity, we will learn about nesting activities, too.
Wiring API
In our current version as written in the previous tutorial, we can process an Oauth signup via Github and handle existing users who have signed up before. Those who haven’t cause our Signup
activity to “error-out”. It will end in the failure
terminus.
It’s now time to implement signing-up new users! Instead of introducing a new “track” to handle this case, I’d like to play around with some important concepts of the wiring API.
Output
Why don’t we put the “create user” task onto the failure
track, and in case of successfully persisting the new user, we deviate back to the happy path? This is totally possible with Trailblazer.
Here’s a diagram of the flow we’re about to implement.
Placing a step on the error track is something we discussed before. However, deviating back to the happy path is new.
class Signup < Trailblazer::Activity::Railway
step :validate
pass :extract_omniauth
step :find_user
fail :create_user, Output(:success) => Id(:log)
pass :log
def create_user(ctx, email:, **)
ctx[:user] = User.create(email: email)
end
# ...
end
In line 5, where you see fail :create_user
, we can see something new and unsettling.
Remember, in a railway activity each task has two standard outputs with the “semantics” success
and failure
. When returning a trusy value, the task will trigger the output marked with the success
semantic, and likewise for failure
.
By using Output(:semantic)
, you can select an existing output of the task and rewire it.
Id
To actually connect the selected output to a specific target, you can use Id()
and provide the ID as the only argument.
Output(:success) => Id(:log)
Since IDs are assigned automatically (unless you’re [using :id
]), this is very simple and intuitive.
Reconnecting the success
output results in a customized flow as visible in the diagram above. We can reassure it works by invoking the activity using wtf?
.
User.init!()
ctx = {params: data_from_github}
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, [ctx])
Since the user database is empty, we’re taking the new path via #create_user
and then back to the happy path to #log
.
The flow is deviated back after #create_user
if we return a trusy value - exactly what we wanted!
Track
Instead of connecting an output to a particular task, you can also choose to let it connect to a track. A track is created by taking a task’s output, retrieving its semantic, and then connecting it to the next available task that is “magnetic to” this semantic. Repeating this process automatically, the activity’s DSL creates “tracks” for you. We will talk about this a bit later.
class Signup < Trailblazer::Activity::Railway
step :validate
pass :extract_omniauth
step :find_user
fail :create_user, Output(:success) => Track(:success)
pass :log
# ...
end
When using Track(:semantic)
the output will “snap to” the closest, following task that is “magnetic to” it, resulting in an identical circuit or flow as above.
Render the circuit
When reconnecting outputs you might feel the urge to see what monster you’ve just created. Especially when the flow doesn’t flow as you want it to, rendering the circuit of the activity is crucial.
Use Developer.render
to visualize the activity’s circuit.
puts Trailblazer::Developer.render(Signup)
Thanks to puts
, there will be an ugly but sufficient rendering of your activity in the terminal.
It lists each task and its outgoing connections. You can see the signal and its target task, the output semantics are not shown.
Having a closer look, you will see that putting “create user” on the failure track probably isn’t such a great idea, as it will also get invoked when #validate
errors-out.
It’s a good idea to introduce a new, separate path for handling new users.
Adding outputs
When “trailblazing” a new, unbeaten track in your activity you have two options: manually inserting new steps and connecting them forming a new path, or using a macro. We will discuss the manual technique first.
Looking at the new diagram, you understand that our goal is to branch out from #find_user
, then execute one or more tasks on the new path, and finally end in a new terminus called new
.
Our activity has slightly changed to introduce the new “track”.
class Signup < Trailblazer::Activity::Railway
NewUser = Class.new(Trailblazer::Activity::Signal)
step :validate
pass :extract_omniauth
step :find_user, Output(NewUser, :new) => Track(:create)
step :create_user, Output(:success) => End(:new), magnetic_to: :create
pass :log
# ...
end
To add a new output to the #find_user
task, we can use Output()
with two arguments!
- The first argument (
NewUser
) is the signal that#find_user
returns in order to trigger that very output. This must be a subclass ofTrailblazer::Activity::Signal
. - The second is the semantic of this new output. Semantics are mostly relevant for nesting, which we will discuss later.
Please note that find_user
now has three outputs.
The new output will snap to a track called :create
, which is discussed in the next section.
Returning signals
Below is the new task method #find_user
. Keep in mind the we got three outputs here, so we somehow need to communicate to the activity which output to take.
def find_user(ctx, email:, **)
user = User.find_by(email: email)
ctx[:user] = user
user ? true : NewUser
end
Per default, having just two outgoing connections in a railway, that’s easy: a trusy value and a falsey value returned are enough to command which path to take.
However, now we got three outputs, so we need a third “signal”. That’s exactly why we introduced NewUser
(it could have any name), and since it’s configured to trigger the :new
output, your activity now has a third path to travel, should find_user
return this signal.
Magnetic_to
To understand how tracks work, we need to understand the :magnetic_to
option.
step :find_user, Output(NewUser, :new) => Track(:create)
step :create_user, Output(:success) => End(:new), magnetic_to: :create
We already discussed “polarizing” an outgoing connection using Track()
. For example, an output using Track(:create)
will snap to the next possible task that is “magnetic to” :create
. That’s how tracks or paths are created. Nothing more!
This is exactly what we do with create_user
: it’s got magnetic_to: :create
, which won’t put it on the “happy path” (:success
) but a new path.
Have another look at the new diagram above. While create_user
sits on a newly branched out path, its failure
output still goes to the error track automatically. You could change that by redirecting it with Output(:failure)
.
Adding a terminus
It is up to the activity modeler what to do next. In our case, from create_user
we head straight into an additional terminus, or end event as it’s called in BPMN.
You can add an additional terminus to an activity [using End()
].
When using End(:semantic)
multiple times with the same semantic, they will all refer to the identical terminus.
Using multiple termini has three magnificent, beautiful advantages.
- You may communicate more than a binary outcome of an activity. For instance, a controller endpoint activity could have end events for success and failure, but also for “not authorized”, or “validation failed”. You’re not limited to a binary setup here.
- It is much easier to track what is going on within the activity. Instead of transporting additional state via
ctx
, you expose the outcome via an additional end event. - When nesting an activity with multiple outcomes, you can wire each terminus to a different route. We will discuss that in a following section.
Our activity has three outcomes now: something went wrong (obviously the failure
end event), we got an existing user signing-in (success
terminus) or a new potential payee signed-up, ending in the new
terminus.
Path
After installing that third “path”, let’s assume we wanted more than one step on it. The final Signup
activity that you’ve all been waiting for has three steps to perform when a new sign-up occurs.
While we could achieve this using Track()
and the :magnetic_to
option, there’s a handy macro for branching out a custom track: Path()
.
class Signup < Trailblazer::Activity::Railway
NewUser = Class.new(Trailblazer::Activity::Signal)
step :validate
pass :extract_omniauth
step :find_user, Output(NewUser, :new) => Path(end_id: "End.new", end_task: End(:new)) do
step :compute_username
step :create_user
step :notify
end
pass :log
# ...
end
While it’s obvious that all step
tasks placed into the block will be neatly arranged on the new path, the options for Path()
need some explanation. The :end_id
is important since it sets the terminus’ ID, and that very terminus is created using :end_task
. This will lead the path straight into the terminus.
Check [Path()’s docs] for a list of all options. You don’t have to terminate in an end event, you can reconnect the path to other elements.
For completeness, here’s the code of the three added tasks.
def compute_username(ctx, email:, **)
ctx[:username] = email.split("@")[0]
end
def create_user(ctx, email:, username:, **)
ctx[:user] = User.create(email: email, username: username)
end
def notify(ctx, **)
true
end
Again, we’re not implementing a sophisticated notification framework or an advanced username generator here, but merely focus on structural mechanics of Trailblazer.
Do note, though, that Path()
only connects the :success
outputs of its tasks. Put differently, this means if #compute_username
would fail, things will break.
Path escape
Why not mock an error in #compute_username
, even though our validation should protect us from that.
def compute_username(ctx, email:, **)
false
end
When invoking the Signup
activity now, it will break with the following exception.
Trailblazer::Activity::Circuit::IllegalSignalError: <>[][ Trailblazer::Activity::Left ]
Well, that’s because compute_username
returned false, which is translated into a Left
signal. This signal, in turn, doesn’t have any output configured as Path()
only handles :success
outputs per default.
To add this, you need to manually add it.
class Signup < Trailblazer::Activity::Railway
NewUser = Class.new(Trailblazer::Activity::Signal)
step :validate
pass :extract_omniauth
step :find_user, Output(NewUser, :new) => Path(end_id: "End.new", end_task: End(:new)) do
step :compute_username, Trailblazer::Activity.Output(Trailblazer::Activity::Left, :failure) => Trailblazer::Activity::DSL::Linear.Track(:failure)
step :create_user
step :notify
end
pass :log
# ...
end
We now added a :failure
output, leading to a new flow as visible in this diagram.
The fragile #compute_username
task now got its error-out path leading to the failure
terminus.
Terminus Interpretation
Reverting #compute_username
to the original version, let’s run the finished and interpret the outcomes real quick.
When running with an empty user database, we should create one!
User.init!()
ctx = {params: data_from_github}
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, [ctx])
signal.to_h[:semantic] #=> :new
ctx[:user] #=> #<User email=\"apotonick@gmail.com\", username=\"apotonick\">
Given that we hit the new
terminus and we have a User
object in ctx
with the data we’re expecting, this must’ve worked. The trace on the console verifies this, too!
Having this user in the system, let’s run another sign-in.
User.init!(User.new("apotonick@gmail.com", 1, "apotonick"))
ctx = {params: data_from_github}
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, [ctx])
signal.to_h[:semantic] #=> :success
ctx[:user] #=> #<User email=\"apotonick@gmail.com\", username=\"apotonick\">
Beautiful! We end up on the success
end event, and no additional user is created.
The Signup
activity in its divine entireness is completed! You now know all the mechanics of the wiring API and the underlying concept of the circuit, tasks, signals, outputs and connections.
Nesting
Knowing about the wiring mechanics in Trailblazer is one thing. However, the real fun starts with nesting activities. That’s when the ideas of encapsulation, interfaces and reducing dependencies really come into play.
To demonstrate that, we need to complicate out example application a bit.
def validate(ctx, params:, **)
is_valid = params.is_a?(Hash) && params["info"].is_a?(Hash) && params["info"]["email"]
is_valid # return value matters!
end
Suppose that the validate
task was getting quite complex and bloated. When writing “normal” Ruby, you’d break up one method into several. In Trailblazer, that’s when you introduce a new, smaller activity.
In the sketched activity, we are separating the former #validate
method and its chain of &&
ed conditions into three steps. Since every step might go wrong, all of them have an “error-out” option.
But, hang on, isn’t that the exact layout of a Railway
activity? Absolutely, that’s why implementing this new activity takes five lines of code in Trailblazer.
class Validate < Trailblazer::Activity::Railway
# Yes, you can use lambdas as steps, too!
step ->(ctx, params:, **) { params.is_a?(Hash) }
step ->(ctx, params:, **) { params["info"].is_a?(Hash) }
step ->(ctx, params:, **) { params["info"]["email"] }
end
Every condition became a separate step. We didn’t use the usual :method
style [but lambdas as a short-cut]. Should one of the conditions fail, the activity will instantly deviate to the error track and skip the rest of the steps. This will be indicated by the last signal being the :failure
terminus.
Hey, that’s is an imaginary complication of our example - please don’t do this with every condition you have in your app.
You’re free to test this activity in a separate unit test. We will skip this for now (*cough), and integrate it directly into our original Signup
activity.
Subprocess
To use another activity as a “step”, use the Subprocess()
macro.
class Signup < Trailblazer::Activity::Railway
NewUser = Class.new(Trailblazer::Activity::Signal)
step Subprocess(Validate)
pass :extract_omniauth
step :find_user, Output(NewUser, :new) => Path(end_id: "End.new", end_task: End(:new)) do
step :compute_username
step :create_user
step :notify
end
pass :log
# ...
end
When running the sign-up activity, you will realize the behavior is identical to what we had before our over-engineered refactoring.
User.init!()
ctx = {params: data_from_github}
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, [ctx])
signal.to_h[:semantic] #=> :new
ctx[:user] #=> #<User email=\"apotonick@gmail.com\", username=\"apotonick\">
The validation still does its job.
The trace shows the nested activity beautifully intented.
So why the over-complication? What we got now is replicating a chain of &&
in the former version. This time, however, you will know which condition failed and what went in by using tracing. Look at the trace above - it’s impossible to not understand what was going on.
Additionally, you may add debugging steps, error handler or rewire the conditions dynamically without touching the original snippet.
Visualized, our new composed structure would look as follows.
Once the nested Valdiate
sub process is hit, it is invoked and executes task by task, eventually reaching a terminus. This is where the outer activity continues.
However, how does the outer Signup
activity know what termini the nested Validate
activity exposes? And why are they automatically wired to the success and failure track?
This is where all our learnings about semantics, outputs, signals and the DSL come together.
Since a Railway
knows about the two outputs failure
and success
, it automatically connects each task’s outputs. Speaking in Ruby, it’s a bit as if the following wiring is applied to every task added via #step
.
step Subprocess(Validate),
Output(:success) => Track(:success),
Output(:failure) => Track(:failure)
The beautiful thing here is: you don’t even need to know which signal is emitted by the task (or the nested activity). Since you can reference outputs by their semantic, you as a modeller only connect conceptual termini to ongoing connections! Trailblazer takes care of wiring the underlying output and its signal.
Being able to reference outputs by their semantic is incredibly helpful when using third-party activities (from gems, for instance). You should not know details such as “the :new
terminus emits a NewUser
signal”. The abstract concept of a terminus named :new
is sufficient for modelling.
Now that we’re rolling, let’s go nuts and add another terminus to Validate
. When the "info"
key is absent in the params
structure, it should error-out into a separate end event.
To implement such an activity, we only need to rewire the second step’s failure
output to a new terminus.
class Validate < Trailblazer::Activity::Railway
# Yes, you can use lambdas as steps, too!
step ->(ctx, params:, **) { params.is_a?(Hash) }
step ->(ctx, params:, **) { params["info"].is_a?(Hash) },
Output(:failure) => End(:no_info)
step ->(ctx, params:, **) { params["info"]["email"] }
end
When running the nested Validate
activity separatedly with a insufficiently filled params
hash, we terminate on the :no_info
end event.
ctx = {params: {}}
signal, (ctx, _) = Trailblazer::Developer.wtf?(Validate, [ctx])
signal.to_h[:semantic] #=> :no_info
However, when running the Signup
activity with an incomplete params
hash, it crashes!
ctx = {params: {}}
signal, (ctx, _) = Trailblazer::Developer.wtf?(Signup, [ctx])
#=> Trailblazer::Activity::Circuit::IllegalSignalError: <>[][ #<Trailblazer::Activity::End semantic=:no_info> ]
The last signal of the nested Validate
activity happens to be the no_info
terminus - and that bad boy is not wired to any connection in the outer Signup
activity, yet!
Remember, the Railway
strategy only connects success and failure automatically, so we need to connect the third end by ourselves.
class Signup < Trailblazer::Activity::Railway
NewUser = Class.new(Trailblazer::Activity::Signal)
step Subprocess(Validate), Output(:no_info) => End(:no_info)
pass :extract_omniauth
step :find_user, Output(NewUser, :new) => Path(end_id: "End.new", end_task: End(:new)) do
step :compute_username
step :create_user
step :notify
end
pass :log
# ...
end
It’s as easy as using Output(:no_info)
and connecting it using one of the DSL target methods. Here, we use End()
to wire the nested activities terminus directly to a new terminus in Signup
. Feel free to play around with Track()
or Id()
to model the flow you desire.
Nesting an activity into another is a bit like calling a library method from another method. However, the explicit modelling has one massive advantage: all possible outcomes of the nested activity are visible and have to be connected in the outer diagram. It’s up to the modeler how those ends are connected, if they lead to separate, new termini, or connect to further business flow.
We covered all important aspects about nesting and are ready for more coding! Once understood that nesting activities is all about connecting their termini to ongoing connections, it becomes a very helpful concept to implement more complex flows and to introduce reusable components.
External tutorials
Step by step refactoring
A series about refactoring a typical Rails spaghetti into Trailblazer architecture: