How monads, composition, and pure functions turn a 100-line method into a readable pipeline.
The central thesis of this article series:
When you have a data structure that represents a certain state in the system, and you can show that it is a functor / applicative / monad, you can turn things that would otherwise be long if/else/switch-laden methods into a combination of functions.
This article puts that into practice — with an order processing system.
1. The Problem: A Typical Order Processing
Requirements: 1. Validate the customer's email address 2. Check that the cart is not empty 3. Validate each item against the product catalog 4. Apply an optional discount code 5. Check the minimum order value 6. Build the order
What typically emerges in practice:
def process_order_imperative(customer_email, items, discount_code=None):
if not customer_email or "@" not in customer_email:
return {"status": "error", "message": "Invalid email address"}
if "." not in customer_email.split("@")[-1]:
return {"status": "error", "message": "Invalid email address"}
email = customer_email.strip().lower()
if not items:
return {"status": "error", "message": "Cart is empty"}
# ... 25 lines of item validation ...
if discount_code is not None:
if discount_code not in DISCOUNT_CODES:
return {"status": "error", "message": f"Invalid discount code"}
if final_total < 10.0:
return {"status": "error", "message": "Minimum order value not reached"}
return {"status": "success", "order": {...}}
Eleven if-statements. The problems:
1. Nested control flows: Happy path and error handling are intertwined
2. Hard to test: Only the entire function is testable, not individual steps
3. Hard to extend: Every new requirement means another if
4. No reuse: The email validation is trapped in this function
"Many code bases are completely dominated by error handling. [...] it is nearly impossible to see what the code does because of all of the scattered error handling." — Robert C. Martin: Clean Code, p. 134
2. The Idea: Railway Oriented Programming
"In this approach, the top track is the happy path, and the bottom track is the failure path. You start off on the success track, and if you're lucky you stay on it to the end." — Scott Wlaschin: Domain Modeling Made Functional, p. 205
validate_email → validate_cart → validate_items → apply_discount → check_total → build_order
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ → Ok(Order)
↓ ↓ ↓ ↓ ↓
──────────────────────────────────────────────────────────────────────────────── → Err(msg)
Each function is a switch: it can stay on the success track or divert to the failure track. Once an error occurs, all subsequent steps are automatically skipped.
The adapter: bind.
"The adapter that converts switch functions to two-track functions is a very important one — it's commonly called bind or flatMap." — Scott Wlaschin: Domain Modeling Made Functional, p. 206
3. Step 1: Define Immutable Data Types
from dataclasses import dataclass
@dataclass(frozen=True)
class Product:
product_id: str
name: str
price: float
stock: int
@dataclass(frozen=True)
class OrderItem:
product_id: str
name: str
quantity: int
unit_price: float
line_total: float
@dataclass(frozen=True)
class Order:
email: str
items: tuple[OrderItem, ...]
subtotal: float
discount_code: str | None
discount_percent: float
discount_amount: float
total: float
4. Step 2: Extract Pure Validation Functions
Each function has the signature A → Result[B]:
def validate_email(email: str) -> Result:
if not email or "@" not in email:
return Err("Invalid email address")
if "." not in email.split("@")[-1]:
return Err("Invalid email address")
return Ok(email.strip().lower())
def validate_cart(items: list[dict]) -> Result:
if not items:
return Err("Cart is empty")
return Ok(items)
Each function is: - Pure: No state, no side effects - Independently testable - Reusable - Single responsibility
5. Step 3: Assemble the Pipeline
def process_order_functional(customer_email, items, discount_code=None):
return (
validate_email(customer_email)
.bind(lambda email:
validate_cart(items)
.bind(validate_all_items)
.bind(lambda validated_items:
apply_discount(discount_code, validated_items))
.bind(check_minimum_total)
.map(lambda order_data:
build_order(email, order_data))
)
)
Same logic — but read it aloud: 1. Validate the email 2. then validate the cart 3. then validate all items 4. then apply the discount 5. then check the minimum order value 6. then build the order
Not a single if for error handling. The Result monad takes care of it.
"What really is mind blowing is that all these problems may be solved using the same clever trick: turning to embellished functions." — Bartosz Milewski: Category Theory for Programmers, p. 330
bind vs. map – The Rule of Thumb
| Operation | Purpose | Signature |
|---|---|---|
bind |
Step that can fail | A → Result[B] |
map |
Pure transformation that never fails | A → B |
6. The Comparison
| Property | Imperative | Functional |
|---|---|---|
| Error handling | 11 if-statements | Automatic via Result monad |
| Testability | Only entire function | Each step in isolation |
| Extensibility | New if in the middle |
New bind in the pipeline |
| Return type | dict (what's in it?) |
Result[Order] (typed) |
| Reuse | None | Each validation usable separately |
7. Step 4: Extensibility Through Composition
New feature: "B2B customers should have additional rules."
Functional: Compose New Functions
def check_no_duplicates(items: list[dict]) -> Result:
seen = set()
for item in items:
pid = item.get("product_id")
if pid in seen:
return Err(f"Duplicate item: {pid}")
seen.add(pid)
return Ok(items)
def check_max_items(max_count: int):
def validator(items: list[dict]) -> Result:
if len(items) > max_count:
return Err(f"Maximum {max_count} different items allowed")
return Ok(items)
return validator
Higher-order functions returning closures — parameterized validation rules.
"To replace the strategy classes, we use higher-order functions that implement the needed algorithms." — Stuart Sierra & Luke VanderHart: Functional Patterns, p. 106
Composing Rules
def compose_validators(*validators):
def composed(value):
result = Ok(value)
for validator in validators:
result = result.bind(validator)
return result
return composed
Configurable Pipelines
# Standard pipeline
process_order_standard = create_order_pipeline()
# Strict pipeline for B2B
process_order_strict = create_order_pipeline(
cart_validators=[
check_no_duplicates,
check_max_items(5),
check_max_quantity(10),
],
min_total=50.0,
)
Not a single line of existing logic was changed. Open-closed principle through function composition instead of inheritance.
8. Where Did the Complexity Go?
It was shifted — from the business code into the data structure:
| Before (imperative) | After (functional) |
|---|---|
if result is None: return None |
Result.bind() |
try: ... except: ... |
Result as return type |
if discount_code is not None: if ... |
apply_discount() as its own function |
"Monads not only accomplish, with pure functions, what normally is done with side effects in imperative programming, but they also do it with a high degree of control and type safety." — Bartosz Milewski: Category Theory for Programmers, p. 345
The Three Levels of Shifting
- Error handling → Result monad:
bindinstead of if/else - Configuration → Closures: Higher-order functions instead of parameter threading
- Extensibility → Composition: New functions instead of code changes
Try it yourself
A complete mini-pipeline using the Result monad. Notice how the business logic (parse_qty, check_stock, apply_discount) reads linearly — no if/else, no try/except. All error handling lives in bind.
from dataclasses import dataclass
@dataclass(frozen=True)
class Ok:
value: object
def bind(self, f): return f(self.value)
def map(self, f): return Ok(f(self.value))
@dataclass(frozen=True)
class Err:
error: str
def bind(self, f): return self
def map(self, f): return self
STOCK = {"A": 10, "B": 0, "C": 3}
PRICE = {"A": 5.0, "B": 2.0, "C": 12.0}
def parse_qty(raw):
if not raw.isdigit():
return Err(f"Quantity is not a number: {raw!r}")
return Ok(int(raw))
def check_stock(product):
def step(qty):
available = STOCK.get(product, 0)
if qty > available:
return Err(f"Not enough stock for {product} ({available} < {qty})")
return Ok(qty)
return step
def to_total(product):
return lambda qty: qty * PRICE[product]
def apply_discount(total):
return total * 0.9 if total >= 50 else total
def order(product, raw_qty):
return (
parse_qty(raw_qty)
.bind(check_stock(product))
.map(to_total(product))
.map(apply_discount)
)
for product, qty in [("A", "4"), ("A", "20"), ("B", "1"), ("C", "abc"), ("C", "3")]:
print(f"order({product!r}, {qty!r}) → {order(product, qty)}")
Extension idea: add another bind step check_min_total(20.0) that rejects orders below 20 € as an Err — without modifying any existing function.
Summary
| Article | Concept | Tool |
|---|---|---|
| 1 – FP Fundamentals | Pure Functions, Immutability, Composition | lambda, map, filter, reduce |
| 2 – Algebraic Data Types | Product Types, Sum Types, Pattern Matching | @dataclass(frozen=True), match/case |
| 3 – Functor, Applicative, Monad | Transform and chain values in context | Maybe, Result, Validation |
| 4 – Shifting Complexity | Everything combined: pipeline instead of if/else | bind, map, composition |
The Core Idea in One Sentence
Functional programming shifts complexity from control flow into data structures — and monads are the tool that makes those data structures composable.
"The word transform is the clue. Functional programming is all about transforming data. [...] This lets us build pipelines of functions." — Dave Thomas & Bruce Tate: Functional Anthology, p. 125
Sources
- Bartosz Milewski: Category Theory for Programmers, pp. 330, 345
- Scott Wlaschin: Domain Modeling Made Functional, pp. 205–208
- Robert C. Martin: Clean Code, p. 134
- Stuart Sierra & Luke VanderHart: Functional Patterns, pp. 96, 106, 189
- Alexander Granin: Pragmatic Type-Level Design, p. 89
- Dave Thomas & Bruce Tate: Functional Anthology, p. 125
Code Examples
src/imperative_order.py– Imperative approach with if/else chainssrc/functional_order.py– Functional approach with Result monadsrc/composition.py– Composition and configurable pipelines