Home Fp Basics Python Article 4
Part 4 Functional Programming & Category Theory

Shifting Complexity – From if/else Chains to Functional Composition

Image: AI-generated


Shifting Complexity – From if/else Chains to Functional Composition

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

  1. Error handling → Result monad: bind instead of if/else
  2. Configuration → Closures: Higher-order functions instead of parameter threading
  3. 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 Errwithout 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

Code Examples

📄 As PDF