Railway-Oriented Programming (ROP)¶
A Python port of Scott Wlaschin’s Railway-Oriented Programming toolkit. Models a computation as a two-track railway: one track for Success values, one for Failures. The Failure track carries a non-empty list of errors, so independent failures from parallel validations can accumulate without loss.
A sister port exists in Go at ascetic-ddd-go/asceticddd/rop/. The two
ports share vocabulary and overall structure; differences are noted
below.
Description¶
Two common needs come up in DDD codebases:
Validate many independent fields and report all problems to the caller at once — not just the first one that failed.
Chain steps where each depends on the previous one (parse → look up → save) and short-circuit on the first failure, because continuing makes no sense.
ROP gives both in one library: an applicative phase that accumulates errors, and a monadic phase that short-circuits. They compose freely.
from ascetic_ddd.rop import Succeed, Fail, map3
def validate_symbol(s):
return Fail("symbol required") if s == "" else Succeed(s)
def validate_side(s):
return Fail("side must be BUY or SELL") if s not in ("BUY", "SELL") else Succeed(s)
def validate_quantity(q):
return Fail("quantity must be > 0") if q <= 0 else Succeed(q)
# All three problems are reported together:
form = map3(
validate_symbol(""),
validate_side("X"),
validate_quantity(-1),
lambda sym, side, qty: {"symbol": sym, "side": side, "quantity": qty},
)
# form.errors() == ["symbol required", "side must be BUY or SELL", "quantity must be > 0"]
With a single-error Result the user would have to fix-and-resubmit three times.
When to Use¶
Validating forms / DTOs / command payloads with multiple independent rules.
Composing pipelines that mix validation (accumulate errors) with effectful steps (short-circuit on first failure).
Any place you currently use
try/exceptto control branching rather than to react to an exceptional situation.
Mapping to Wlaschin’s canon¶
The 13 entries of Wlaschin’s “Here they all are together” table:
Wlaschin / OCaml |
Python |
Notes |
|---|---|---|
|
|
Success constructor |
|
|
Single-error failure constructor |
— |
|
Multi-error constructor; raises |
|
|
Core eliminator |
|
|
Functor map on the Success track |
|
|
Monadic bind; short-circuits on first Failure |
|
|
Applicative; concatenates error lists on dual Failure |
|
|
Pairs two Results, accumulates errors |
|
|
No syntactic sugar; fixed-arity helpers cover the common cases |
|
|
Lifts a plain function into a switch |
|
|
Side-effect pass-through |
|
|
Catches |
|
|
Maps Success and each Failure element |
|
|
Combine two switches; pluggable success/failure mergers |
|
|
Validation flavour of |
|
|
Kleisli composition of two switch functions |
|
|
Plain function composition ( |
|
— (no operator overloading) |
Use the named functions / methods above |
|
|
Bridge to Python’s |
— |
|
Bridge from |
Total: 13/13 of the canonical table, plus four extensions (FailMany,
apply, map2/map3/map4, from_exception, of_option).
Why a list of errors¶
The original Wlaschin / OCaml Rop uses a single error per Failure
because F#/Haskell-style applicative chains can pair Failures with a
semigroup instance for the error type. Python doesn’t have that
infrastructure, so we follow the OCaml port’s choice: the Failure
branch always holds a non-empty list of errors. The applicative
combinators (apply, map2, map3, map4, plus, and_) then
concatenate those lists when both arguments fail.
Mixing accumulation with short-circuit¶
Applicative phase accumulates; monadic phase short-circuits. Mix freely in one pipeline:
from ascetic_ddd.rop import Succeed, Fail, map3
order = map3(
validate_symbol(payload["symbol"]),
validate_side(payload["side"]),
validate_quantity(payload["qty"]),
build_order,
)
# If any field failed, persist_to_db is never called; the original
# field errors propagate to the caller.
saved = order.and_then(persist_to_db)
API¶
Class Result[T, E]¶
Methods (instance-level, fluent):
is_ok(), is_error(), errors(), unwrap(), unwrap_or(default),
unwrap_or_else(f), either(success_fn, failure_fn), map(f),
and_then(f), bind(f) (alias of and_then), both(rb),
double_map(success_fn, failure_fn), or_else(f), __or__(alt)
(| operator), __eq__, __hash__, __repr__, __str__.
Top-level functions¶
Constructors: Succeed(v), Fail(err), FailMany(errs).
Bridges:
from_exception(v, exc)—(value, Exception | None)→ Result.of_option(o, err)— bridgesascetic_ddd.option.Option.
Adapters: switch(f), tee(f, x), try_catch(f, exc_handler).
Multi-Result accumulators: apply(rf, rx), map2(ra, rb, f),
map3(ra, rb, rc, f), map4(ra, rb, rc, rd, f).
Switch combinators: plus(add_success, add_failure, s1, s2),
and_(v1, v2).
Composition: compose(f, g) (Kleisli), pipe(f, g) (plain function
composition).
Differences from the Go port¶
Aspect |
Python |
Go |
|---|---|---|
|
Methods on |
Top-level functions (Go disallows new type-params on methods) |
|
Catches |
Lifts |
Bridge to runtime error |
|
|
` |
` operator |
Overloaded for |
The reason try_catch differs: in Python the failure mechanism is
exceptions, matching OCaml. In Go it is the error return value, so
the Go port adapted accordingly. Both ports stay idiomatic to their
host language.
References¶
Scott Wlaschin, Railway-Oriented Programming, Part 2 — The Recipe
See also¶
Railway oriented programming with returns library
Expression – a toolkit for Railway oriented programming
OSlash – Functors, Applicatives, And Monads in Python