~ / track H / applied patterns
Railway-oriented programming
IntermediateRailway-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:
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
Resultadvertises 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/catchcan 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
bindisn'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.exceptionfailjure(a popular minimal library)clojure.core/some->is a degenerate-case ROP: it short-circuits onnil, 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:
parse-payload→{:ok m}if the JSON-shaped payload has:emailand:age, else{:err ...}.validate-age→{:ok m}if(>= (:age m) 18), else{:err :underage}.check-not-exists→{:ok m}if(not (db/has? (:email m))), else{:err :duplicate}.save-user→{:ok :registered}if all is well.
Show the pipeline as a single ->/bind chain (mock the functions however
makes the exercise tractable).