~ / track I / applied patterns
Railway-oriented programming
IntermediatepatternRailway-oriented programming (ROP) is a metaphor for chaining
operations that may fail, without nesting ifclojure.core/ifConditional: (if test then else?).view on clojuredocs →s 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:
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/->clojure.core/->Thread the value through forms by inserting it as the first arg.view on clojuredocs → 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
In a Clojure service
Inside the functional core, a request walks several fallible steps — parse
items, validate total, apply discount — without nested ifclojure.core/ifConditional: (if test then else?).view on clojuredocs →s. 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.
Reference solution
(defn process-order [id-str]
(-> {:ok id-str}
(bind parse-order-id)
(bind fetch-order)
(bind approve-order)))