Skip to main content

Pure Functions - Testability, Memoisation, and the Functional Core Pattern

Reading time: ~25 minutes | Level: Intermediate → Engineering

Before reading further, predict the output of this program:

total = 0

def add_to_total(x):
global total
total += x
return total

def add(a, b):
return a + b

print(add_to_total(5)) # ?
print(add_to_total(5)) # ?
print(add(5, 5)) # ?
print(add(5, 5)) # ?
Show Answer
5
10
10
10

add_to_total(5) returns 5 the first time and 10 the second time - the same input 5 produces two different outputs. add(5, 5) returns 10 both times, every time, forever. This is the difference between an impure function and a pure function. The first is untestable without resetting global state. The second can be memoised, parallelised, and reasoned about in isolation.

This single distinction - same inputs, same outputs, no side effects - is the foundation of functional programming and underpins some of the most important engineering properties your code can have: testability without mocks, safe caching with lru_cache, and thread safety without locks.

What You Will Learn

  • The precise definition of a pure function: same inputs → same outputs, no side effects
  • What counts as a side effect and why each type is dangerous
  • Referential transparency: what it means and why it matters for reasoning and optimisation
  • How to identify impurity in existing code
  • Techniques for making functions purer without rewriting everything
  • The functional core, imperative shell pattern used in production systems
  • Why functools.lru_cache requires pure functions to work correctly
  • Real-world examples from FastAPI and Django

Prerequisites

  • Lessons 01–06 of this module (lambda, map/filter/reduce, generators, iterators, decorators, closures)
  • Basic understanding of global scope and mutable data structures
  • Familiarity with functools.lru_cache at the call-site level

Part 1 - What Is a Pure Function?

The Two-Clause Definition

A function is pure if and only if:

  1. Deterministic: given the same arguments, it always returns the same value - regardless of when it is called, how many times it is called, or what else is happening in the program.
  2. No side effects: it does not modify any state outside its own local scope. It does not write to disk, print to console, modify global variables, mutate its arguments, make network calls, or read from external state (clocks, random number generators, databases).
# Pure function - passes both clauses
def celsius_to_fahrenheit(c: float) -> float:
return (c * 9 / 5) + 32

# Impure - fails clause 1 (reads external state: the clock)
from datetime import datetime
def current_hour() -> int:
return datetime.now().hour

# Impure - fails clause 2 (modifies its argument)
def append_default(lst: list, value: int) -> list:
lst.append(value) # mutates the caller's list
return lst

# Impure - fails both clauses (reads and writes global state)
counter = 0
def increment() -> int:
global counter
counter += 1
return counter

The Purity Test

Ask two questions about any function:

  1. Could I replace every call to this function with its return value and have the program behave identically?
  2. Could two threads call this function simultaneously, on the same inputs, without coordinating - and both get correct results?

If the answer to either is "no", the function is impure.

Part 2 - Referential Transparency

Referential transparency is the property of an expression that can be replaced by its value without changing the program's behaviour. Pure functions are referentially transparent; impure functions are not.

# Referentially transparent - these two programs are equivalent:
result = add(3, 4) + add(3, 4)
result = 7 + 7
result = 14

# NOT referentially transparent - cannot substitute freely:
result = add_to_total(5) + add_to_total(5)
# This is NOT the same as:
result = 5 + 5 # Wrong! Second call returns 10, not 5

Referential transparency is not just a mathematical nicety. It enables:

  • Compiler and interpreter optimisations: constant folding, common subexpression elimination
  • Memoisation: if f(x) always returns the same value, cache it
  • Equational reasoning: replace parts of your program with equivalent forms, as in algebra
  • Safe parallelism: two evaluations of f(x) can run concurrently without coordination
from functools import lru_cache

# Safe: pure function, always same output for same input
@lru_cache(maxsize=256)
def expensive_computation(n: int) -> int:
return sum(i ** 2 for i in range(n))

# UNSAFE: impure function - lru_cache will cache the first result
# and return it forever, even when external state has changed
import random
@lru_cache(maxsize=256)
def get_random_value(seed: int) -> float:
return random.random() # ignores seed, reads from RNG state
note

functools.lru_cache only works correctly on pure functions. The cache key is the tuple of arguments. If the same arguments can produce different outputs (because the function reads external state), lru_cache will silently return stale cached results. This is one of the most subtle bugs in Python codebases that use caching.

Part 3 - Identifying Side Effects

Side effects are any interactions a function has with the world outside its arguments and return value. They fall into six categories:

Recognising Each Category in Code

# --- Global state mutation ---
_registry = {}
def register(name: str, value: int) -> None:
_registry[name] = value # side effect: modifies module-level dict

# --- Argument mutation ---
def normalise_tags(tags: list[str]) -> list[str]:
for i, tag in enumerate(tags):
tags[i] = tag.lower() # side effect: mutates caller's list
return tags

# --- I/O ---
def log_and_compute(x: int) -> int:
print(f"Computing for {x}") # side effect: writes to stdout
return x * 2

# --- Randomness ---
import random
def generate_id() -> str:
return str(random.randint(10000, 99999)) # side effect: consumes RNG state

# --- Time dependency ---
from datetime import datetime
def is_business_hours() -> bool:
return 9 <= datetime.now().hour < 17 # side effect: reads system clock

# --- External system state ---
import os
def get_debug_level() -> str:
return os.environ.get("LOG_LEVEL", "INFO") # side effect: reads env

The Subtlest Side Effect: Argument Mutation

Argument mutation is the most dangerous category because it is invisible at the call site.

# Caller has no idea their list is being modified
def process(items: list) -> list:
items.sort() # MUTATION: modifies caller's original list
return items

data = [3, 1, 4, 1, 5]
result = process(data)
print(data) # [1, 1, 3, 4, 5] - caller's data is now sorted
print(result) # [1, 1, 3, 4, 5] - same object, not a copy
danger

Mutating function arguments as an "optimisation" to avoid copying creates subtle bugs in callers. The caller passes data expecting it to remain unchanged. If the function mutates it, every reference to that data is now corrupted - including data structures in other modules, cached values, and ongoing iterations. Always return a new object; let the garbage collector handle the old one.

Part 4 - Making Functions Purer

Strategy 1: Return New Objects Instead of Mutating

# Impure: mutates argument
def add_tag_impure(tags: list[str], new_tag: str) -> list[str]:
tags.append(new_tag)
return tags

# Pure: returns a new list
def add_tag_pure(tags: list[str], new_tag: str) -> list[str]:
return tags + [new_tag]

# Pure: using tuple as immutable sequence
def add_tag_tuple(tags: tuple[str, ...], new_tag: str) -> tuple[str, ...]:
return tags + (new_tag,)

Strategy 2: Pass External State as Arguments

# Impure: reads the clock internally
def is_expired_impure(expiry_timestamp: float) -> bool:
from time import time
return time() > expiry_timestamp # reads external state

# Purer: caller provides the current time
def is_expired(expiry_timestamp: float, current_time: float) -> bool:
return current_time > expiry_timestamp # pure - deterministic

# Now testable without mocking time:
assert is_expired(1000.0, current_time=999.0) is False
assert is_expired(1000.0, current_time=1001.0) is True

Strategy 3: Separate Computation from I/O

# Impure: mixes computation and I/O
def save_user_report_impure(user_id: int, data: dict) -> None:
report = {
"user_id": user_id,
"total": sum(data["transactions"]),
"average": sum(data["transactions"]) / len(data["transactions"]),
}
with open(f"report_{user_id}.json", "w") as f:
import json
json.dump(report, f) # side effect: writes to disk

# Pure computation - fully testable, no mocks needed
def build_user_report(user_id: int, transactions: list[float]) -> dict:
total = sum(transactions)
return {
"user_id": user_id,
"total": total,
"average": total / len(transactions) if transactions else 0,
}

# Impure I/O - thin wrapper, easy to inspect and replace
def save_report(report: dict, path: str) -> None:
import json
with open(path, "w") as f:
json.dump(report, f)

build_user_report now has zero dependencies, zero mocks, and can be tested with a single assert.

Part 5 - The Functional Core, Imperative Shell Pattern

The most important architectural pattern in functional programming is the functional core, imperative shell (sometimes called "functional core, imperative skin" or the "ports and adapters" pattern at the architecture level).

A Concrete Example: Invoice Processing

# ============================================================
# FUNCTIONAL CORE - pure, no I/O, fully testable
# ============================================================

from dataclasses import dataclass
from typing import Sequence

@dataclass(frozen=True)
class LineItem:
description: str
quantity: int
unit_price: float

@dataclass(frozen=True)
class Invoice:
invoice_id: str
customer: str
items: tuple[LineItem, ...]
tax_rate: float

def calculate_subtotal(items: Sequence[LineItem]) -> float:
return sum(item.quantity * item.unit_price for item in items)

def calculate_tax(subtotal: float, tax_rate: float) -> float:
return round(subtotal * tax_rate, 2)

def build_invoice_summary(invoice: Invoice) -> dict:
subtotal = calculate_subtotal(invoice.items)
tax = calculate_tax(subtotal, invoice.tax_rate)
return {
"invoice_id": invoice.invoice_id,
"customer": invoice.customer,
"subtotal": subtotal,
"tax": tax,
"total": round(subtotal + tax, 2),
"item_count": len(invoice.items),
}

# ============================================================
# IMPERATIVE SHELL - handles I/O, calls the core
# ============================================================

def process_invoice_from_file(filepath: str) -> None:
import json

# 1. Read from the world
with open(filepath) as f:
raw = json.load(f)

# 2. Build domain objects (still pure - just constructors)
items = tuple(
LineItem(
description=i["description"],
quantity=i["quantity"],
unit_price=i["unit_price"],
)
for i in raw["items"]
)
invoice = Invoice(
invoice_id=raw["invoice_id"],
customer=raw["customer"],
items=items,
tax_rate=raw.get("tax_rate", 0.1),
)

# 3. Call pure core - no I/O in here
summary = build_invoice_summary(invoice)

# 4. Write back to the world
output_path = filepath.replace(".json", "_summary.json")
with open(output_path, "w") as f:
json.dump(summary, f, indent=2)

print(f"Summary written to {output_path}")

The entire business logic - tax calculation, subtotal, summary assembly - is pure and testable with plain assert statements. The I/O is isolated to process_invoice_from_file, which is intentionally thin and contains no logic worth testing in isolation.

tip

Write pure functions for all computation and business logic. Push I/O - file reads, network calls, database queries, clock reads, random numbers - to a thin outer layer. The thinner the imperative shell, the more of your codebase is testable without mocks, fakes, or database fixtures.

Part 6 - Purity and Testability

The engineering payoff of pure functions is most visible in tests.

# Testing the impure version - requires setup and teardown
def test_add_to_total_impure():
global total
total = 0 # setup: must reset external state
assert add_to_total(5) == 5
assert add_to_total(5) == 10
total = 0 # teardown: reset for other tests
# If another test runs first and modifies total, this test fails for a wrong reason

# Testing the pure version - no setup, no teardown, no dependencies
def test_add_pure():
assert add(5, 5) == 10
assert add(5, 5) == 10 # idempotent: same result every call
assert add(0, 0) == 0
assert add(-1, 1) == 0
# Can run in any order, in parallel, any number of times

Pure functions exhibit three testing superpowers:

  1. No setup or teardown: there is no external state to reset between tests
  2. Parallelisable: a test runner can execute pure function tests in any order across any number of threads
  3. Property-based testable: tools like hypothesis can generate thousands of random inputs because the function's behaviour depends only on its inputs
# Property-based testing works perfectly on pure functions
from hypothesis import given
import hypothesis.strategies as st

@given(st.floats(allow_nan=False, allow_infinity=False))
def test_celsius_to_fahrenheit_roundtrip(c):
# This test runs hundreds of times with random values
# Pure function - no external state to corrupt across runs
f = celsius_to_fahrenheit(c)
assert abs(f - (c * 9 / 5 + 32)) < 1e-9

Part 7 - Purity in Production Frameworks

FastAPI: Request Handlers Built on a Pure Core

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Sequence

app = FastAPI()

# ============================================================
# PURE CORE - business logic, no framework dependencies
# ============================================================

class OrderItem(BaseModel):
product_id: str
quantity: int
price: float

def calculate_order_total(items: Sequence[OrderItem], discount_rate: float = 0.0) -> dict:
subtotal = sum(item.quantity * item.price for item in items)
discount = round(subtotal * discount_rate, 2)
total = round(subtotal - discount, 2)
return {"subtotal": subtotal, "discount": discount, "total": total}

def validate_order(items: Sequence[OrderItem]) -> list[str]:
errors = []
if not items:
errors.append("Order must contain at least one item")
for item in items:
if item.quantity <= 0:
errors.append(f"Item {item.product_id}: quantity must be positive")
if item.price < 0:
errors.append(f"Item {item.product_id}: price cannot be negative")
return errors

# ============================================================
# IMPERATIVE SHELL - FastAPI endpoint (thin)
# ============================================================

class OrderRequest(BaseModel):
items: list[OrderItem]
discount_code: str | None = None

@app.post("/orders/calculate")
def calculate_order(request: OrderRequest):
# Shell: orchestrate I/O and call pure core
errors = validate_order(request.items)
if errors:
raise HTTPException(status_code=422, detail=errors)

discount_rate = 0.1 if request.discount_code == "SAVE10" else 0.0
totals = calculate_order_total(request.items, discount_rate)
return totals

calculate_order_total and validate_order are pure: input → output, no side effects. The FastAPI handler is the imperative shell - it reads the request (I/O) and raises errors (I/O), but contains no business logic.

warning

Python has no enforcement mechanism for purity. There is no @pure decorator that prevents side effects at the language level. Purity is a design discipline, not a compiler guarantee. Tools like mypy can catch some issues (mutable default arguments, None returns), but they cannot detect that your function reads the system clock or writes to a global. Enforce purity through code review conventions and architecture patterns, not through language features.

Common Mistakes

Mistake 1 - Invisible Global Reads

# The function looks pure but reads module-level config
_config = {"tax_rate": 0.1}

def apply_tax(price: float) -> float:
return price * (1 + _config["tax_rate"]) # hidden dependency on _config

# If _config changes, apply_tax(100) returns different values - impure

Fix: pass configuration explicitly.

def apply_tax(price: float, tax_rate: float) -> float:
return price * (1 + tax_rate)

Mistake 2 - Default Mutable Arguments

# Classic Python trap: default list is created once, shared across ALL calls
def append_item(item: str, results: list = []) -> list:
results.append(item)
return results

print(append_item("a")) # ['a']
print(append_item("b")) # ['a', 'b'] - not ['b']!

Fix: use None as the sentinel, create a new list inside the function.

def append_item(item: str, results: list | None = None) -> list:
if results is None:
results = []
return results + [item] # return new list, don't mutate

Mistake 3 - Generators That Exhaust

# Generator is stateful - not pure
def make_counter():
n = 0
while True:
yield n
n += 1

counter = make_counter()
next(counter) # 0
next(counter) # 1 - same call, different result

Generators are inherently stateful. Use them for iteration, but keep the values they produce as inputs to pure functions.

Engineering Checklist

Before moving on, verify you can answer these without looking:

  1. State the two-clause definition of a pure function.
  2. What is referential transparency, and why does it matter for caching?
  3. Name the six categories of side effects.
  4. Why does lru_cache require the decorated function to be pure?
  5. Why is argument mutation more dangerous than other side effects?
  6. What is the functional core, imperative shell pattern? Where does each category of code belong?
  7. How do you make a time-dependent function testable without mocking?
  8. Why can pure functions be tested in parallel safely?

Key Takeaways

  • A pure function satisfies exactly two properties: (1) same inputs always produce the same output, and (2) it produces no side effects - no mutations, no I/O, no reads of external state.
  • Referential transparency means you can replace any call to a pure function with its return value and the program's behaviour is unchanged. This is the property that enables memoisation, optimisation, and equational reasoning.
  • functools.lru_cache only works correctly on pure functions. If the decorated function reads external state, the cache will return stale results silently - a class of bug that is extremely hard to diagnose.
  • Argument mutation is the most dangerous side effect because it is invisible at the call site. Always return new objects; never modify what the caller passed in.
  • The six categories of side effects are: global state mutation, argument mutation, I/O (print/file/network/database), randomness, time dependency, and external system state.
  • The functional core, imperative shell pattern is the production-grade architecture: keep all business logic in pure functions (the core), and push all I/O to a thin outer layer (the shell). The core is fully unit-testable; the shell is integration-tested.
  • Python has no language-level enforcement of purity. It is a design discipline enforced through code review and architectural convention.
  • Pure functions are testable without setup, teardown, mocks, or fixtures. They can run in any order, any number of times, in parallel - because they have no shared state.

Graded Practice

Level 1 - Predict the Output

Question 1

results = []

def collect(x: int) -> int:
results.append(x)
return x * 2

a = collect(3)
b = collect(3)
print(a)
print(b)
print(results)
Show Answer
6
6
[3, 3]

collect is impure: it returns the same value for the same input (6 both times - deterministic), but it has a side effect: it appends to the global results list. So clause 1 (determinism) is satisfied, but clause 2 (no side effects) is violated. The function is still impure. Both properties must hold. Note that results contains [3, 3] - the side effect ran twice.

Question 2

def make_adder(n: int):
total = 0
def add(x: int) -> int:
nonlocal total
total += x
return total + n
return add

add5 = make_adder(5)
print(add5(10))
print(add5(10))
Show Answer
15
25

add5(10) returns 15 the first time: total starts at 0, becomes 10, result is 10 + 5 = 15. The second call returns 25: total is now 10, becomes 20, result is 20 + 5 = 25. The closure's total is mutable state - add5 is impure. Same input 10, different outputs 15 and 25. This is the classic closure-as-accumulator pattern: useful, but impure. Never memoize it with lru_cache.

Question 3

from functools import lru_cache
import random

@lru_cache(maxsize=4)
def cached_random(seed: int) -> float:
return random.random() # ignores seed

r1 = cached_random(1)
r2 = cached_random(1)
r3 = cached_random(2)

print(r1 == r2)
print(r1 == r3)
Show Answer
True
False (almost certainly)

r1 == r2 is True because lru_cache cached the result of the first call to cached_random(1) and returns the same cached value for the second call - no matter what random.random() would have returned. r1 == r3 is almost certainly False because cached_random(2) is a different cache key and calls random.random() again, returning a different (random) value. This demonstrates the danger of caching impure functions: the cache hides the impurity on repeat calls but exposes it on first calls per key.

Question 4

def process(items: list[int]) -> list[int]:
items.sort()
return [x * 2 for x in items]

original = [3, 1, 4, 1, 5, 9]
result = process(original)
print(original)
print(result)
Show Answer
[1, 1, 3, 4, 5, 9]
[2, 2, 6, 8, 10, 18]

process mutates the caller's list via items.sort() (in-place sort) before returning a new list of doubled values. The caller's original is permanently modified - it is now [1, 1, 3, 4, 5, 9] instead of [3, 1, 4, 1, 5, 9]. The return value is a new list, so result is correct. But the mutation of original is an invisible side effect - the caller has no warning this will happen. The fix: use sorted(items) instead of items.sort() to leave the original untouched.

Question 5

def pure_or_not(data: dict) -> dict:
data["processed"] = True
return {"id": data["id"], "processed": data["processed"]}

payload = {"id": 42}
result = pure_or_not(payload)
print(payload)
print(result)
Show Answer
{'id': 42, 'processed': True}
{'id': 42, 'processed': True}

pure_or_not is impure even though it returns a new dict. It modifies the caller's payload dict by adding the key "processed" before building and returning the result dict. The caller's payload now contains "processed": True - a side effect the caller did not request. The fix: do not write to data; compute the result without touching the input: return {"id": data["id"], "processed": True}.

Level 2 - Debug Challenge

The following function is supposed to apply a discount to a list of prices and return the discounted prices. It has two purity violations. Find both and fix them.

applied_discounts = []

def apply_discount(prices: list[float], discount: float) -> list[float]:
for i in range(len(prices)):
prices[i] = prices[i] * (1 - discount) # BUG 1
applied_discounts.append(discount) # BUG 2
return prices
Show Answer

Bug 1 - Argument mutation: prices[i] = ... modifies the caller's list in-place. The caller's original list is permanently altered, even though the function "returns" a result. This is invisible at the call site: result = apply_discount(my_prices, 0.1) - the caller has no idea my_prices is now different.

Bug 2 - Global state mutation: applied_discounts.append(discount) writes to a module-level list. This is a side effect on external state. The function now has a hidden dependency on (and modification of) applied_discounts. Calling it twice with the same inputs will produce the same return value but a different applied_discounts - violating clause 2.

Fixed version:

def apply_discount(prices: list[float], discount: float) -> list[float]:
return [price * (1 - discount) for price in prices]

# Usage - original list is untouched:
original_prices = [100.0, 200.0, 300.0]
discounted = apply_discount(original_prices, 0.1)
print(original_prices) # [100.0, 200.0, 300.0] - unchanged
print(discounted) # [90.0, 180.0, 270.0]

# If you need to track applied discounts, do it in the imperative shell:
audit_log = []
audit_log.append({"discount": 0.1, "prices": discounted})

The pure core returns a new list. The imperative shell handles logging and audit trails.

Level 3 - Design Challenge

You are building a pricing engine for an e-commerce platform. The engine must:

  1. Apply product-specific discounts
  2. Apply a coupon code discount (read from a database)
  3. Calculate tax based on the customer's region
  4. Log every pricing calculation to an audit table

Design this system using the functional core, imperative shell pattern. Identify exactly which parts are pure functions and which parts belong in the shell. Write the pure core in full. Sketch the shell structure (comments are sufficient for I/O calls).

Show Answer
# ============================================================
# FUNCTIONAL CORE - pure functions, zero I/O
# ============================================================

from dataclasses import dataclass
from typing import Sequence

@dataclass(frozen=True)
class Product:
product_id: str
base_price: float
discount_rate: float # e.g. 0.15 for 15% off

@dataclass(frozen=True)
class PricingResult:
product_id: str
base_price: float
product_discount: float
coupon_discount: float
tax: float
final_price: float

def apply_product_discount(product: Product) -> float:
"""Pure: returns discounted price for one product."""
return round(product.base_price * (1 - product.discount_rate), 2)

def apply_coupon(price: float, coupon_rate: float) -> float:
"""Pure: applies coupon rate to a price."""
return round(price * (1 - coupon_rate), 2)

def calculate_tax(price: float, tax_rate: float) -> float:
"""Pure: returns tax amount."""
return round(price * tax_rate, 2)

def price_product(
product: Product,
coupon_rate: float,
tax_rate: float,
) -> PricingResult:
"""Pure: full pricing pipeline for one product."""
after_product_discount = apply_product_discount(product)
after_coupon = apply_coupon(after_product_discount, coupon_rate)
tax_amount = calculate_tax(after_coupon, tax_rate)
final = round(after_coupon + tax_amount, 2)
return PricingResult(
product_id=product.product_id,
base_price=product.base_price,
product_discount=round(product.base_price - after_product_discount, 2),
coupon_discount=round(after_product_discount - after_coupon, 2),
tax=tax_amount,
final_price=final,
)

def price_all_products(
products: Sequence[Product],
coupon_rate: float,
tax_rate: float,
) -> list[PricingResult]:
"""Pure: prices a collection of products."""
return [price_product(p, coupon_rate, tax_rate) for p in products]

def calculate_order_total(results: Sequence[PricingResult]) -> float:
"""Pure: sums final prices."""
return round(sum(r.final_price for r in results), 2)

# ============================================================
# IMPERATIVE SHELL - thin, handles all I/O
# ============================================================

def process_order(
customer_id: str,
product_ids: list[str],
coupon_code: str | None,
region: str,
db, # injected database connection
audit_log, # injected audit logger
) -> dict:
# 1. READ: fetch products from database (I/O)
products = [db.get_product(pid) for pid in product_ids]

# 2. READ: fetch coupon rate (I/O)
coupon_rate = db.get_coupon_rate(coupon_code) if coupon_code else 0.0

# 3. READ: fetch tax rate for region (I/O)
tax_rate = db.get_tax_rate(region)

# 4. CALL PURE CORE - zero I/O in here
results = price_all_products(products, coupon_rate, tax_rate)
order_total = calculate_order_total(results)

# 5. WRITE: log to audit table (I/O)
audit_log.record({
"customer_id": customer_id,
"coupon_code": coupon_code,
"region": region,
"results": [vars(r) for r in results],
"order_total": order_total,
})

# 6. RETURN: response dict
return {
"customer_id": customer_id,
"items": [vars(r) for r in results],
"order_total": order_total,
}

# ============================================================
# TEST THE PURE CORE - no mocks, no DB, no I/O
# ============================================================

def test_price_product():
product = Product(product_id="SKU-001", base_price=100.0, discount_rate=0.1)
result = price_product(product, coupon_rate=0.05, tax_rate=0.08)
# after product discount: 90.0
# after coupon: 90.0 * 0.95 = 85.5
# tax: 85.5 * 0.08 = 6.84
# final: 85.5 + 6.84 = 92.34
assert result.final_price == 92.34
assert result.product_discount == 10.0
assert result.coupon_discount == 4.5
assert result.tax == 6.84

test_price_product()
print("All tests passed")

Key observations:

  • Every pricing calculation (apply_product_discount, apply_coupon, calculate_tax, price_product, price_all_products, calculate_order_total) is a pure function
  • No pure function touches db, audit_log, or any I/O resource
  • The imperative shell (process_order) is a thin orchestrator: it reads from external systems, calls the pure core, and writes results back
  • The pure core is tested with plain assert statements - no mocks, no fakes, no database fixtures
  • The shell is tested at the integration level (with real or fake db/audit_log objects), but business logic correctness is verified entirely through unit tests of the pure core

What's Next

Lesson 08 covers Immutability Strategies - the data structure complement to pure functions. Pure functions return new values instead of mutating; immutable data structures enforce that invariant at the type level. You will see Python's full immutability toolkit: tuple, frozenset, typing.NamedTuple, dataclasses.dataclass(frozen=True), and types.MappingProxyType, along with the strategies for "modifying" immutable structures without mutation.

© 2026 EngineersOfAI. All rights reserved.