~ / track I / applied patterns

Railway-oriented programming

Intermediatepattern

Railway-oriented programming (ROP) is a metaphor for chaining operations that may fail, without nesting i‌fs 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. See Error handling: exceptions vs values for when to throw versus return and the Handler golden path (validate → core → HTTP status); The algebraic hierarchy names the bind abstraction; and Category theory with cats provides Either when you want a library implementation.

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

In a Clojure service

Inside the functional core, a request walks several fallible steps — parse items, validate total, apply discount — without nested i‌fs. A bind helper threads [:ok value] through the chain and short-circuits on the first [:error info]. The handler calls process-order once; if the result is [:error ...] it maps to a 400 response, otherwise it persists and returns 201. Exceptions stay at the shell for truly unexpected failures.

(defn bind-result [r f]                            ;; sketch
  (if (= :ok (first r)) (f (second r)) r))
 
(defn process-order [raw]                        ;; pure core (sketch)
  (-> [:ok raw]
      (bind-result parse-items)
      (bind-result validate-total)
      (bind-result apply-discount)))

Check yourself

? quiz

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

Exercise

Build process-order as a bind chain over three steps — parse an order id string, fetch the order from a fake db, approve it if still :draft. Edit the Repl below; run until every assert passes.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
Reference solution
(defn process-order [id-str]
  (-> {:ok id-str}
      (bind parse-order-id)
      (bind fetch-order)
      (bind approve-order)))
 status: new