⚡ Functional vs OOP — Same Problem, Both Ways
The same order processing system implemented in OOP and functional styles — classes with mutation vs pure functions with immutable data — so you can compare tradeoffs directly.
🎯 The Problem
We will implement an order processing system that handles:
- Line items with quantities and prices
- Percentage discounts
- Tax calculation (configurable rate)
- Inventory deduction
- Receipt generation
Same business logic, two programming paradigms.
🏛️ Object-Oriented Style
The Design
In OOP, the system models the real world: objects have state, behavior is attached to data, and mutation is how things change.
from dataclasses import dataclass, field
from typing import List, Optional
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
@dataclass
class LineItem:
sku: str
name: str
quantity: int
unit_price: float
def subtotal(self) -> float:
return self.quantity * self.unit_price
class DiscountPolicy:
"""Strategy pattern for discount calculations."""
def apply(self, items: List[LineItem], total: float) -> float:
return total
class PercentageDiscount(DiscountPolicy):
def __init__(self, percent: float):
self.percent = percent
def apply(self, items: List[LineItem], total: float) -> float:
return total * (1.0 - self.percent / 100.0)
class BulkDiscount(DiscountPolicy):
def __init__(self, min_qty: int, discount_percent: float):
self.min_qty = min_qty
self.discount_percent = discount_percent
def apply(self, items: List[LineItem], total: float) -> float:
if any(item.quantity >= self.min_qty for item in items):
return total * (1.0 - self.discount_percent / 100.0)
return total
class Inventory:
"""Mutable inventory store with encapsulated state."""
def __init__(self):
self._stock: dict[str, int] = {}
def add_stock(self, sku: str, quantity: int):
self._stock[sku] = self._stock.get(sku, 0) + quantity
def reserve(self, sku: str, quantity: int) -> bool:
available = self._stock.get(sku, 0)
if available >= quantity:
self._stock[sku] = available - quantity
return True
return False
def available(self, sku: str) -> int:
return self._stock.get(sku, 0)
class Order:
"""Order as a mutable object. State changes over its lifecycle."""
def __init__(self, order_id: str, items: List[LineItem],
discount: Optional[DiscountPolicy] = None):
self.order_id = order_id
self.items = items
self.discount = discount or DiscountPolicy()
self.status = OrderStatus.PENDING
self.total: Optional[float] = None
def calculate_total(self) -> float:
subtotal = sum(item.subtotal() for item in self.items)
discounted = self.discount.apply(self.items, subtotal)
tax = discounted * 0.08 # 8% sales tax
self.total = round(discounted + tax, 2)
return self.total
def confirm(self, inventory: Inventory) -> bool:
if self.status != OrderStatus.PENDING:
return False
for item in self.items:
if not inventory.reserve(item.sku, item.quantity):
return False # Insufficient stock — order fails
self.status = OrderStatus.CONFIRMED
if self.total is None:
self.calculate_total()
return True
def receipt(self) -> str:
lines = [f"Order #{self.order_id}"]
for item in self.items:
lines.append(f" {item.name:20} x{item.quantity} @ ${item.unit_price:.2f}")
lines.append(f" {'─' * 36}")
lines.append(f" Total: ${self.total or self.calculate_total():.2f}")
lines.append(f" Status: {self.status.value}")
return "\n".join(lines)
Usage
inv = Inventory()
inv.add_stock("WIDGET-A", 10)
inv.add_stock("GADGET-B", 5)
order = Order(
order_id="ORD-001",
items=[
LineItem(sku="WIDGET-A", name="Widget A", quantity=2, unit_price=10.00),
LineItem(sku="GADGET-B", name="Gadget B", quantity=1, unit_price=25.00),
],
discount=PercentageDiscount(10.0),
)
order.confirm(inventory) # Mutates order status
print(order.receipt())
Output:
Order #ORD-001
Widget A x2 @ $10.00
Gadget B x1 @ $25.00
────────────────────────────────────
Total: $40.50
Status: confirmed
🌀 Functional Style
The Design
In FP, data is immutable and functions are pure — same input always produces the same output. Composition replaces inheritance.
from dataclasses import dataclass
from typing import List, Callable, Tuple
from enum import Enum
# Immutable data — just values, no behavior
@dataclass(frozen=True)
class LineItem:
sku: str
name: str
quantity: int
unit_price: float
class OrderStatus(Enum):
PENDING = "pending"
CONFIRMED = "confirmed"
SHIPPED = "shipped"
@dataclass(frozen=True)
class OrderData:
"""Pure data. No methods, no mutation."""
order_id: str
items: List[LineItem]
status: OrderStatus
total: float
@dataclass(frozen=True)
class InventoryState:
stock: dict[str, int]
Pure functions that operate on data:
# ---- Pure functions ----
def calculate_subtotal(items: List[LineItem]) -> float:
"""Pure: same items → same subtotal, every time."""
return sum(item.quantity * item.unit_price for item in items)
def apply_percentage_discount(percent: float, total: float) -> float:
"""Pure function — takes discount parameters, returns reducer."""
return total * (1.0 - percent / 100.0)
def apply_tax(rate: float, total: float) -> float:
"""Pure: total + tax = new total."""
return round(total * (1.0 + rate), 2)
def build_order(
order_id: str,
items: List[LineItem],
discounts: List[Callable[[float], float]],
tax_rate: float,
) -> OrderData:
"""
Pure: builds an OrderData by piping data through functions.
This is function composition in action. No mutation anywhere —
each step produces a new value consumed by the next.
"""
subtotal = calculate_subtotal(items)
after_discount = compose(*discounts)(subtotal) # Pipeline
total = apply_tax(tax_rate, after_discount)
return OrderData(
order_id=order_id,
items=items,
status=OrderStatus.PENDING,
total=total,
)
def compose(*fns: Callable) -> Callable:
"""Function composition: f ∘ g means f(g(x))."""
def composed(value):
for fn in reversed(fns):
value = fn(value)
return value
return composed
Inventory operations return new state:
# ---- Inventory (returns new state) ----
def add_stock(inv: InventoryState, sku: str, qty: int) -> InventoryState:
"""Returns a NEW InventoryState — original is unchanged."""
new_stock = dict(inv.stock)
new_stock[sku] = new_stock.get(sku, 0) + qty
return InventoryState(stock=new_stock)
def try_reserve(
inv: InventoryState, sku: str, qty: int
) -> Tuple[bool, InventoryState]:
"""Returns (success, new_inventory) without mutation."""
available = inv.stock.get(sku, 0)
if available >= qty:
new_stock = dict(inv.stock)
new_stock[sku] = available - qty
return True, InventoryState(stock=new_stock)
return False, inv
def confirm_order(
order: OrderData, inv: InventoryState
) -> Tuple[bool, OrderData, InventoryState]:
"""
Pure: returns (succeeded, updated_order, updated_inventory).
Original order and inventory are untouched.
"""
if order.status != OrderStatus.PENDING:
return False, order, inv
new_inv = inv
for item in order.items:
ok, new_inv = try_reserve(new_inv, item.sku, item.quantity)
if not ok:
return False, order, inv # Rollback is just returning original
confirmed = OrderData(
order_id=order.order_id,
items=order.items,
status=OrderStatus.CONFIRMED,
total=order.total,
)
return True, confirmed, new_inv
Receipt generation as a pure function:
def format_receipt(order: OrderData) -> str:
"""Pure: order → string. No side effects."""
lines = [f"Order #{order.order_id}"]
for item in order.items:
lines.append(
f" {item.name:20} x{item.quantity} @ ${item.unit_price:.2f}"
)
lines.append(f" {'─' * 36}")
lines.append(f" Total: ${order.total:.2f}")
lines.append(f" Status: {order.status.value}")
return "\n".join(lines)
Usage
inv = InventoryState(stock={})
inv = add_stock(inv, "WIDGET-A", 10)
inv = add_stock(inv, "GADGET-B", 5)
# Build order with function composition
ten_percent_off = lambda t: apply_percentage_discount(10.0, t)
order = build_order(
order_id="ORD-001",
items=[
LineItem(sku="WIDGET-A", name="Widget A", quantity=2, unit_price=10.00),
LineItem(sku="GADGET-B", name="Gadget B", quantity=1, unit_price=25.00),
],
discounts=[ten_percent_off],
tax_rate=0.08,
)
ok, confirmed, final_inv = confirm_order(order, inv)
print(format_receipt(confirmed))
Output: (same as OOP — correct by construction)
Order #ORD-001
Widget A x2 @ $10.00
Gadget B x1 @ $25.00
────────────────────────────────────
Total: $40.50
Status: confirmed
⚖️ Side-by-Side Comparison
State Management
| Aspect | OOP | FP |
|---|---|---|
| How state lives | Encapsulated in objects | Exists as values passed through functions |
| State change | self.field = new_value (mutate) | Return new value (immutable) |
| Who owns state | The object | No one — values are just values |
| Serialization | Need special methods | @dataclass(frozen=True) → json.dumps for free |
Side Effects
# OOP — methods mutate and return void:
order.confirm(inventory) # Side effect: changes order.status
print(order.total) # Reading mutated state
# FP — functions return new values:
ok, confirmed, inv = confirm_order(order_data, inv)
print(confirmed.total) # Reading a new value from pure computation
Composition
OOP: Object Hierarchy
DiscountPolicy (abstract base)
├── PercentageDiscount
└── BulkDiscount
Order ────► Inventory (dependency injection)
FP: Function Pipeline
items → calculate_subtotal
→ apply_percentage_discount(10%)
→ apply_tax(8%)
→ OrderData
confirm_order : (OrderData, InventoryState) → (bool, OrderData, InventoryState)
Concurrency
OOP — shared mutable state is the root of all evil:
# Thread A:
order.confirm(inventory) # Reads inventory, writes order.status
# Thread B (concurrent):
order.calculate_total() # Reads order.status, writes order.total
# ⚠️ Data race! A and B both mutate order without synchronization.
FP — immutable data is inherently safe:
# Thread A:
ok1, confirmed1, inv1 = confirm_order(order, inv)
# Thread B:
ok2, confirmed2, inv2 = confirm_order(order, inv) # Same input, same output
# ✅ No races! Neither thread mutates shared state.
# Both get the same result regardless of ordering.
Testing
OOP — mocking is essential:
@patch.object(Inventory, 'reserve', return_value=True)
def test_confirm_order(mock_reserve):
order = Order("ORD-TEST", items=[LineItem("X", "Test", 1, 10.00)])
inv = MagicMock(spec=Inventory)
result = order.confirm(inv)
assert result is True
mock_reserve.assert_called_once()
FP — pure functions, no mocking:
def test_confirm_order():
order = OrderData(order_id="ORD-TEST", items=[
LineItem("X", "Test", 1, 10.00),
], status=OrderStatus.PENDING, total=10.80)
inv = InventoryState(stock={"X": 5})
ok, confirmed, new_inv = confirm_order(order, inv)
assert ok is True
assert confirmed.status == OrderStatus.CONFIRMED
assert new_inv.stock["X"] == 4 # Deducted
assert inv.stock["X"] == 5 # Original unchanged
No mocking required — just pass values in, assert values out.
🎯 When Each Excels
Use OOP When
- GUI applications — widgets, views, controllers map naturally to objects
- Simulation / game engines — entities with state that changes over time
- Complex state machines — protocols, workflow engines, device drivers
- Plugin architectures — polymorphism via interfaces makes extension trivial
Use FP When
- Data processing pipelines — ETL, map-reduce, streaming
- Concurrent / parallel systems — immutable data eliminates whole classes of bugs
- Transformation-heavy logic — one data structure to another (JSON → domain → SQL)
- Business rules with many combinations — composable functions over deep inheritance
The Sweet Spot: Both
The most maintainable codebases use both:
Big architecture: OOP (modules organized around domain concepts)
Data flow within: FP (pure functions transforming data inside modules)
# Hybrid approach — best of both worlds:
class OrderService:
"""OOP architecture: the service is a thing that does things."""
def __init__(self, inventory_repo, tax_service):
self.inventory = inventory_repo
self.tax = tax_service
def place_order(self, order_id: str, items: List[LineItem],
discount_rates: List[float]) -> OrderData:
"""FP data flow: inside the method, data flows through pure functions."""
subtotal = self._calculate_subtotal(items)
discounted = self._apply_discounts(subtotal, discount_rates)
total = self.tax.apply(discounted)
order = OrderData(order_id, items, OrderStatus.CONFIRMED, total)
self._reserve_inventory(items)
return order
@staticmethod
def _calculate_subtotal(items: List[LineItem]) -> float:
return sum(i.quantity * i.unit_price for i in items)
@staticmethod
def _apply_discounts(total: float, rates: List[float]) -> float:
return functools.reduce(lambda t, r: t * (1 - r / 100), rates, total)
The class provides the architecture; the static methods provide the correctness guarantees. This is the pattern used in production codebases at Spotify, Netflix, and Bloomberg.
📚 Further Reading
| Resource | Link |
|---|---|
| ”Structure and Interpretation of Computer Programs” (SICP) | mitpress.mit.edu/sicp/ |
| “Design Patterns: Elements of Reusable OOP” (GoF) | Addison-Wesley, 1994 |
| Out of the Tar Pit (Moseley & Marks) | curtclifton.net/papers/MoseleyMarks06a.pdf |
| F# for Fun and Profit — FP vs OOP | fsharpforfunandprofit.com/ |
| Domain Modeling Made Functional (Scott Wlaschin) | Pragmatic Bookshelf, 2018 |