~ / track H / applied patterns

Railway-oriented programming

Intermediate

Railway-oriented programming (ROP) is a metaphor for chaining operations that may fail, without nesting ifs or scattering try/catch. You picture two parallel tracks: a success rail carrying the value, and a failure rail carrying an error. Every step either stays on the success rail or switches to the failure rail, where the rest of the steps are skipped. The shape of the pipeline stays flat.

The metaphor was popularized by Scott Wlaschin (F#); it maps to the Either / Result monad in Haskell, ? operator in Rust, and idiomatic Result returns in Clojure.

The shape of the problem

Without ROP, a pipeline of fallible steps becomes a staircase:

(defn process [input]
  (let [parsed (parse input)]
    (if (:err parsed)
      parsed
      (let [validated (validate (:ok parsed))]
        (if (:err validated)
          validated
          (let [saved (save (:ok validated))]
            (if (:err saved)
              saved
              {:ok :done})))))))

Three steps; eleven lines; the success path is hidden behind error checking at every level.

ROP: bind the two rails

Define a Result shape — {:ok x} or {:err msg} — and a bind that runs the next step only if we're on the success rail:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

The success path reads top-to-bottom: parse → validate → save. The failure path is implicit — bind short-circuits as soon as any step returns :err.

Why this is better than exceptions

  • Errors are values. You can return them, log them, transform them, test for them — they're not unwinding the stack at random points.
  • The signature tells the truth. A function returning Result advertises that it can fail. An exception-throwing function looks identical to a pure one until it bites you.
  • Composition is uniform. Any pipeline of fallible functions composes through the same bind/-> shape; no special-casing per error type.
  • No control-flow surprises. try/catch can skip over assignments, cleanups, locks. ROP makes early-exit explicit and local.

When to use exceptions anyway

ROP isn't the answer for everything:

  • Programmer errors (null where there shouldn't be one, bad inputs from a caller that should have validated) — assertions / exceptions communicate "this is a bug, don't catch it" better than a Result.
  • JVM interop — Java APIs throw; you'll catch at the boundary and convert.
  • One-shot scripts — overhead of bind isn't worth it for 20 lines.

A common pattern: use exceptions at the outermost boundary (HTTP middleware catches anything not handled), use Result inside the application.

What Clojure libraries call this

  • cats.monad.either / cats.monad.exception
  • failjure (a popular minimal library)
  • clojure.core/some-> is a degenerate-case ROP: it short-circuits on nil, treating absence as failure
  • Hand-rolled {:ok ... | :err ...} is fine in many projects

Check yourself

? quiz

Why does ROP describe the failure path as 'implicit'?

Exercise

Using bind above, build a register-user pipeline of four steps:

  1. parse-payload{:ok m} if the JSON-shaped payload has :email and :age, else {:err ...}.
  2. validate-age{:ok m} if (>= (:age m) 18), else {:err :underage}.
  3. check-not-exists{:ok m} if (not (db/has? (:email m))), else {:err :duplicate}.
  4. save-user{:ok :registered} if all is well.

Show the pipeline as a single ->/bind chain (mock the functions however makes the exercise tractable).

 status: new