{{ page.title }}

  • Last updated 05 May 2017
  • representable v3.0

Tutorials

  • Last updated 10 Dec 19

Railway Basics

Code is here 🕮

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 ifs and elses, 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_names 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

Code is here 🕮

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 of Trailblazer::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.

Hello hello early birds

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque ac ligula convallis, mollis velit eu, porta odio. Proin nibh ipsum, bibendum eu auctor volutpat, consectetur vitae erat. Duis condimentum dapibus hendrerit.