Elixir-style Pipelines in 9 Lines of Ruby

gregnavis

Greg Navis

Posted on April 24, 2023

Elixir-style Pipelines in 9 Lines of Ruby

Elixir pipelines are an elegant construct for sequencing operations in a readable way. Fortunately, 9 lines is all it takes to implement them in Ruby.

Background: << and >>

Ruby offers some pipelining primitives. Proc and Method respond to #<< and #>>, which can be used in pipelines:

FindByLogin = proc { |login| ... }
ConfirmUserAccount = proc { |user| ... }
SendConfirmationNotification = proc { |user| ... }

(FindByLogin >>
  ConfirmUserAccount >>
  SendConfirmationNotification).call("gregnavis")
Enter fullscreen mode Exit fullscreen mode

This approach has several drawbacks:

  1. The Proc returned by #>> cannot be called using result(...), but only via proc.call(...) or proc.(...) or proc[...], which is inconsistent with regular method calls. Admittedly, this is unavoidable, but can be made to matter less.
  2. The pipeline argument comes at the end, but having it at the front would be more readable and more consistent with the pipeline structure.
  3. Operations taking more than one parameter must be implemented as higher-order procs or use currying. Introducing or eliminating additional parameters entails switching between regular procs and higher-order or curried procs.

To illustrate the last problem, let's make SendConfirmationNotification take an argument determining the type of notification: e-mail or SMS. It has to be rewritten as:

# Using a higher-order proc.
SendConfirmationNotification = proc do |method|
  proc { |user| ... }
end

# Using currying; notice .curry after the block
SendConfirmationNotification = proc do |method, user|
  ...
end.curry
Enter fullscreen mode Exit fullscreen mode

Unfortunately, the pipeline still suffers from the problem of argument coming at the end:

(FindByLogin >>
  ConfirmUserAccount >>
  SendConfirmationNotification[:sms])["gregnavis"]
Enter fullscreen mode Exit fullscreen mode

The rest of the article shows how to use Ruby refinements, a built-in but relatively obscure facility, to make the code below work:

"gregnavis" >>
  FindByLogin >>
  ConfirmUserAccount >>
  SendConfirmationNotification[:sms]
Enter fullscreen mode Exit fullscreen mode

Let's start with operator definitions. Monkey-patching will be used initially, but will be replaced with refinements by the end of the article.

Step 1: Defining Parameterized Operations

Parameterized and parameterless operations should be defined the same way. A new Kernel method will help here:

module Kernel
  def operation(...) = proc(...).curry
end
Enter fullscreen mode Exit fullscreen mode

Basically, operation is an automatically curried proc. The pipeline can now be defined as:

FindByLogin = operation { |login| ... }
ConfirmUserAccount = operation { |user| ... }

# Notice user comes last.
SendConfirmationNotification = operation { |method, user| ... }
Enter fullscreen mode Exit fullscreen mode

Due to currying, the first argument to SendConfirmationNotification can be provided in the pipeline, while the execution is "paused" until user is provided, too. The code below now works as expected:

(FindByLogin >>
  ConfirmUserAccount >>
  SendConfirmationNotification[:sms])["gregnavis"]
Enter fullscreen mode Exit fullscreen mode

The next goal is moving the pipeline to the front.

Step 2: Piping Arguments into Callables

The following two expressions should be equivalent:

# When we write this:
argument >> callable

# we actually mean this:
callable.call(argument)
Enter fullscreen mode Exit fullscreen mode

The snippet hints at what needs to be done: all objects must respond to >> and that method must call call. A top-level class (Object or BasicObject) must be modified to make #>> available on all objects, resulting in the following patch:

class Object
  def >>(callable) = callable.call(self)
end
Enter fullscreen mode Exit fullscreen mode

Finally, we're able to write:

"gregnavis" >>
  FindUserByLogin >>
  ConfirmUserAccount >>
  SendConfirmationNotification[:sms]
Enter fullscreen mode Exit fullscreen mode

The patches on Kernel and Object must be turned into a refinement to avoid global monkey patching.

Step 3: Introducing Refinements

Refinements are a topic for a separate article, but in short they are monkey-patches that can enabled inside a specific module or class by calling Module#using. Let's approach the problem outside in by starting with how we want the code to be used.

Suppose we're working inside a Rails controller. We'd like to be able to write code like this:

class UsersController < ApplicationController
  using Pipelines

  def confirm
    params[:login] >>
      FindUserByLogin >>
      ConfirmUserAccount >>
      SendConfirmationNotification[:sms]
  end
end
Enter fullscreen mode Exit fullscreen mode

using is built into Ruby, and Pipelines is the refinement to be defined. It's an ordinary Ruby module that refines (i.e. patches) Kernel and Object:

module Pipelines
  refine Kernel do
    def operation(...) = proc(...).curry
  end

  refine Object do
    def >>(callable) = callable.call(self)
  end
end
Enter fullscreen mode Exit fullscreen mode

That's it! Pipelines are now enabled only in UsersController, and no other code will be affected. Keep in mind you need to be using the refinement when defining operations too (so that operation is available).

Summary

That pipeline implementation would fit on a napkin. Let's have a critical look at this approach.

First, calling a curried proc with no arguments keeps the execution "paused", so missing an argument can make the pipeline return a curried proc, instead of the expected return value. This will likely lead to difficult to understand errors later in the program.

Second, procs are difficult to inspect. Seeing #<Proc:0x...> in the terminal is unhelpful when debugging. It is possible to inspect parameters passed to an operation via operation_object.binding.local_variables and operation_object.binding.local_variable_get(name) for parameters of interest. It'd be more helpful if inspecting an operation produced something along the lines of SendConfirmationNotification[method: :sms].

Next Steps

The above was my second approach to implementing pipelines. The first approach was object-oriented and didn't have the drawbacks mentioned above at the expense of slightly more complex implementation. I'll cover it an an upcoming article.

💖 💪 🙅 🚩
gregnavis
Greg Navis

Posted on April 24, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related