Programming Paradigms

Structured, Object-Oriented & Functional Programming

Why Paradigms Matter

Programming paradigms are fundamental approaches to structuring code and solving problems. They're not just academic concepts, they shape how you think about design, influence the maintainability of your systems, and determine which patterns are natural vs. awkward in your codebase. Understanding multiple paradigms makes you a better architect because you can choose the right tool for each problem rather than forcing every problem into one way of thinking.

The Three Major Paradigms

Each paradigm offers a different mental model for organizing and expressing computation:

Structured

Focus: Control flow and procedures

Key concept: Sequential execution with functions

When to use: Scripts, utilities, imperative algorithms

Object-Oriented

Focus: Objects and their interactions

Key concept: Encapsulation and polymorphism

When to use: Large systems, domain modeling, frameworks

Functional

Focus: Pure functions and immutability

Key concept: Transformation of data

When to use: Data pipelines, concurrent systems, transformations

Structured Programming: Control Flow First

Structured programming emerged as a reaction to "goto spaghetti code." It emphasizes clear control flow through sequences, conditionals, and loops, organized into reusable functions.

Core Principles: The Three Control Structures

Key Insight: Structured programming eliminated goto statements and introduced the idea that any program can be built from just three fundamental control structures: sequence, selection, and iteration.
1. Sequence
Executing statements one after another in order, where each step completes before the next begins. This is the most basic form of control flow.
def process_order(order):
    """Sequential steps executed in order"""
    validate_order(order)      # Step 1
    charge_payment(order)      # Step 2
    ship_order(order)          # Step 3
    send_confirmation(order)   # Step 4
    # Each step happens in sequence
2. Selection (Conditionals)
Choosing between different paths of execution based on conditions (if/else statements), allowing programs to make decisions and branch based on data.
def handle_order(order):
    """Branching logic based on conditions"""
    if validate_order(order):
        # Path A: valid order
        process_payment(order)
        ship_order(order)
    else:
        # Path B: invalid order
        notify_customer(order)
        refund_payment(order)
3. Iteration (Loops)
Repeating a block of code multiple times until a condition is met (for/while loops), enabling processing of collections and repeated operations.
def process_orders(orders):
    """Iterate through collection"""
    total_revenue = 0
    for order in orders:
        total_revenue += order.amount
    return total_revenue

# Or with while loop
def process_queue(queue):
    """Repeat until condition met"""
    while not queue.is_empty():
        item = queue.pop()
        process(item)
All Three Structures Combined
def process_orders(orders):
    """Combining sequence, selection, and iteration"""
    total_revenue = 0
    failed_orders = []

    # ITERATION: Loop through orders
    for order in orders:
        # SELECTION: Choose path based on validation
        if validate_order(order):
            # SEQUENCE: Execute steps in order
            total_revenue += process_payment(order)
            ship_order(order)
        else:
            failed_orders.append(order)
            notify_customer(order)

    # SEQUENCE: Final steps
    generate_report(total_revenue, failed_orders)
    return total_revenue

# Clear, top-down structure with reusable functions
def validate_order(order):
    return order.has_payment_info() and order.items_in_stock()

def process_payment(order):
    return order.total

def ship_order(order):
    pass

When Structured Programming Shines

  • Scripts and utilities: One-off tasks with clear beginning and end
  • Algorithms: Step-by-step procedures like sorting or searching
  • Simple programs: When object modeling would be overkill
  • Performance-critical code: Direct, minimal abstraction overhead

Object-Oriented Programming: Modeling Reality

OOP organizes code around objects that encapsulate both data and behavior. It excels at modeling real-world (business) entities and complex systems with many interacting components.

The Four Pillars

1. Encapsulation
Bundling data and methods that operate on that data within a single unit (class), while hiding internal implementation details and exposing only what's necessary through a public interface.
class BankAccount:
    def __init__(self, account_number, balance=0):
        self._account_number = account_number  # Protected
        self._balance = balance

    def deposit(self, amount):
        if amount > 0:
            self._balance += amount
            return True
        return False

    def withdraw(self, amount):
        if 0 < amount <= self._balance:
            self._balance -= amount
            return True
        return False

    @property
    def balance(self):
        return self._balance  # Read-only access to internal state
2. Inheritance
A mechanism where a new class (child/subclass) derives properties and behaviors from an existing class (parent/superclass), allowing code reuse and establishing hierarchical relationships.
class SavingsAccount(BankAccount):
    """Inherits from BankAccount and extends with interest functionality"""
    def __init__(self, account_number, balance=0, interest_rate=0.02):
        super().__init__(account_number, balance)
        self.interest_rate = interest_rate

    def apply_interest(self):
        """New behavior specific to SavingsAccount"""
        interest = self._balance * self.interest_rate
        self.deposit(interest)
3. Polymorphism
The ability of different classes to be treated as instances of the same class through a common interface, where each class can provide its own implementation of methods with the same name.
class CheckingAccount(BankAccount):
    """Same interface, different implementation of withdraw"""
    def __init__(self, account_number, balance=0, overdraft_limit=100):
        super().__init__(account_number, balance)
        self.overdraft_limit = overdraft_limit

    def withdraw(self, amount):
        # Different implementation allowing overdraft
        if amount <= self._balance + self.overdraft_limit:
            self._balance -= amount
            return True
        return False

# Polymorphism in action: same method name, different behavior
accounts = [SavingsAccount("001", 1000), CheckingAccount("002", 500)]
for account in accounts:
    account.withdraw(100)  # Calls appropriate withdraw method
4. Abstraction
Hiding complex implementation details and exposing only essential features through abstract interfaces, allowing different implementations while maintaining a consistent contract.
from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    """Abstract interface - defines what, not how"""
    @abstractmethod
    def process_payment(self, amount):
        pass

    @abstractmethod
    def refund(self, transaction_id):
        pass

class StripeProcessor(PaymentProcessor):
    """Concrete implementation for Stripe"""
    def process_payment(self, amount):
        return stripe.charge(amount)

    def refund(self, transaction_id):
        return stripe.refund(transaction_id)

# Client code depends on abstraction, not concrete implementation
def checkout(cart, processor: PaymentProcessor):
    total = cart.calculate_total()
    return processor.process_payment(total)

Composition vs. Inheritance

The classic advice "favor composition over inheritance" is a useful starting point, but it is not a universal law. The real question is: which tool fits the domain? Both have scenarios where they are not just acceptable but clearly the better choice. Understanding that distinction is what separates good OOP from blindly following a rule. In ETL and pipeline architectures, inheritance is almost always preferred once you go beyond a single processor class. In UI, services, and physical entity modeling, composition is the natural fit.
The Trade-offs at a Glance
❌ Inheritance Problems
  • Tight coupling to parent classes
  • Changes to parent break children
  • Deep hierarchies hard to understand
  • Can't change behavior at runtime
  • Forced to inherit unwanted methods
✓ Composition Benefits
  • Loose coupling between components
  • Easy to modify independently
  • Flat, simple structure
  • Change behavior at runtime
  • Use only what you need
Composition Example
# Simple, independent components
class EmailNotifier:
    def send(self, message, recipient):
        print(f"Email to {recipient}: {message}")

class SMSNotifier:
    def send(self, message, recipient):
        print(f"SMS to {recipient}: {message}")

class PushNotifier:
    def send(self, message, recipient):
        print(f"Push notification to {recipient}: {message}")

# Compose behaviors by combining objects
class NotificationService:
    def __init__(self):
        self.notifiers = []  # Contains other objects

    def add_notifier(self, notifier):
        """Add notification strategy at runtime"""
        self.notifiers.append(notifier)

    def notify_all(self, message, recipient):
        """Delegate to composed objects"""
        for notifier in self.notifiers:
            notifier.send(message, recipient)

# Usage: Flexible composition at runtime
service = NotificationService()
service.add_notifier(EmailNotifier())    # Add email
service.add_notifier(SMSNotifier())      # Add SMS
# Can add/remove notifiers dynamically
service.notify_all("Your order shipped!", "user@example.com")
Key Advantage: With composition, you can change which notifiers are used at runtime. With inheritance, the class hierarchy is fixed at design time. Composition = flexibility.
Common Pitfall: Over-engineering with unnecessary class hierarchies. Not everything needs to be an object. Use OOP when you have clear entities with state and behavior, not just to organize functions.
When to Use Inheritance

Inheritance is the right tool when a true "is-a" relationship exists, the subclass is a more specific version of the parent, and that relationship is stable over time.

Use inheritance when:
  • A clear "is-a" relationship exists (Dog IS-A Animal)
  • The parent class is stable and unlikely to change
  • You need polymorphism through a shared interface
  • Extending a framework's base class (Django views, SQLAlchemy models)
  • Subclasses share the majority of the parent's behavior
Avoid inheritance when:
  • You only want to reuse code (use a utility function instead)
  • The relationship is "has-a", not "is-a" (Car HAS-A Engine)
  • Depth grows without each level adding a clear, single responsibility
  • The subclass needs to override most parent methods
  • You need to mix behaviors from multiple sources
from abc import ABC, abstractmethod

# Good use of inheritance: true is-a relationship, shared contract
class Shape(ABC):
    @abstractmethod
    def area(self) -> float:
        pass

    @abstractmethod
    def perimeter(self) -> float:
        pass

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2

    def perimeter(self) -> float:
        return 2 * 3.14159 * self.radius

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height

    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

# Polymorphism works cleanly - all Shapes share the same interface
def print_shape_info(shape: Shape):
    print(f"Area: {shape.area():.2f}, Perimeter: {shape.perimeter():.2f}")
Exception: Layered Inheritance Done Right

Deep hierarchies are not inherently bad. When each layer adds exactly one responsibility and concrete implementations pick the level that matches their requirements, deep inheritance enforces SRP and Interface Segregation simultaneously. This is the Template Method pattern applied in layers.

A classic example is an ETL pipeline where each layer builds on the last:

from abc import ABC, abstractmethod

# Layer 1: Template Method - defines the ETL contract and flow (single responsibility)
class ETLBase(ABC):
    def run(self):
        """Fixed skeleton: every ETL runs in this order"""
        data = self.extract()
        data = self.transform(data)
        self.load(data)

    @abstractmethod
    def extract(self): ...

    @abstractmethod
    def transform(self, data): ...

    @abstractmethod
    def load(self, data): ...

# Layer 2: Adds HTTP extraction capability (single responsibility)
class HTTPExtractor(ETLBase):
    def __init__(self, base_url: str):
        self.base_url = base_url

    def extract(self):
        return requests.get(self.base_url).json()

# Layer 3: Adds caching on top of HTTP extraction (single responsibility)
class CachedHTTPExtractor(HTTPExtractor):
    def __init__(self, base_url: str, cache):
        super().__init__(base_url)
        self.cache = cache

    def extract(self):
        cached = self.cache.get(self.base_url)
        if cached:
            return cached
        data = super().extract()
        self.cache.set(self.base_url, data)
        return data

# Layer 4: Adds database persistence (single responsibility)
class DBPersistedExtractor(CachedHTTPExtractor):
    def __init__(self, base_url: str, cache, db):
        super().__init__(base_url, cache)
        self.db = db

    def load(self, data):
        self.db.bulk_save(data)

# --- Concrete implementations choose their entry point ---

class SimpleSync(HTTPExtractor):
    """Only needs HTTP - no cache, no DB"""
    def transform(self, data): return data
    def load(self, data): print(data)

class CachedSync(CachedHTTPExtractor):
    """Needs HTTP + cache, but writes to a file"""
    def transform(self, data): return [clean(r) for r in data]
    def load(self, data): write_to_file(data)

class FullSync(DBPersistedExtractor):
    """Full stack: HTTP + cache + DB"""
    def transform(self, data): return [enrich(r) for r in data]
Why this works:
  • Each layer adds exactly one responsibility
  • Concretes pick the level that matches their needs (ISP)
  • Adding a new layer never touches existing concretes
  • The flow contract is enforced once, at the top
This stops working when:
  • A layer adds more than one responsibility
  • A layer exists for convenience, not a distinct concern
  • Concretes are forced to inherit capabilities they never use
When to Use Composition

Composition shines when you need to assemble behavior from independent parts, swap implementations at runtime, or combine capabilities that don't fit a clean hierarchy.

Use composition when:
  • A "has-a" relationship fits (Order HAS-A PaymentMethod)
  • Behavior needs to be swappable at runtime
  • You need to mix behaviors from multiple sources
  • The component parts are useful on their own
  • You want classes to stay small and focused
Watch out for:
  • Too many tiny objects making flow hard to follow
  • Forwarding many methods just to delegate behavior
  • Over-abstracting simple, one-off behavior into components
  • Losing the clarity of a straightforward class hierarchy
class Logger:
    def log(self, message: str):
        print(f"[LOG] {message}")

class PaymentValidator:
    def validate(self, payment: dict) -> bool:
        return payment.get("amount", 0) > 0

class FileStorage:
    def save(self, data: dict):
        pass  # Write to disk

class CloudStorage:
    def save(self, data: dict):
        pass  # Upload to S3

# OrderService HAS-A storage, HAS-A logger, HAS-A validator
# Each dependency is injected - easy to swap implementations
class OrderService:
    def __init__(self, storage, logger: Logger, validator: PaymentValidator):
        self.storage = storage      # FileStorage or CloudStorage - doesn't matter
        self.logger = logger
        self.validator = validator

    def process(self, order: dict) -> bool:
        if not self.validator.validate(order["payment"]):
            self.logger.log("Invalid payment")
            return False
        self.storage.save(order)
        self.logger.log(f"Order {order['id']} saved")
        return True

# Swap CloudStorage for FileStorage with zero changes to OrderService
service = OrderService(CloudStorage(), Logger(), PaymentValidator())
Where Each Truly Belongs

Rather than asking "composition or inheritance?", ask "what domain am I in?" Both patterns have natural homes, and forcing the wrong one creates friction that compounds over time.

Inheritance-first domains
  • ETL and data pipelines - layered capabilities, fixed flow contract
  • Framework extension - Django views, SQLAlchemy models, Celery tasks
  • Protocol hierarchies - abstract base classes defining contracts
  • Game entities - Character → Enemy → Boss (stable is-a tree)
Composition-first domains
  • UI components - Button HAS-A icon, HAS-A style, HAS-A handler
  • Service layers - OrderService HAS-A storage, HAS-A validator
  • Physical entities - Car HAS-A Engine, HAS-A Transmission
  • Runtime behavior injection - strategy, decorator, plugin patterns
UI Components - composition is the natural fit

A Button is not a subtype of Icon or Label. It has an icon, a label, a style, and a handler. Inheriting from any of those creates a false semantic relationship and forces every Button variant to carry all ancestor state. Composition keeps each concern independent and reusable.

# Bad: inheritance forces false relationships
class Icon: ...
class Label(Icon): ...      # Label IS-A Icon? No.
class Button(Label): ...    # Button IS-A Label? No.
# Button now drags in all of Icon's and Label's internals

# Good: composition - each part stays independent and reusable
class Icon:
    def __init__(self, name: str):
        self.name = name

class Label:
    def __init__(self, text: str):
        self.text = text

class ClickHandler:
    def __init__(self, callback):
        self.callback = callback

    def on_click(self, event):
        self.callback(event)

class Button:
    """Button HAS-A icon, HAS-A label, HAS-A handler"""
    def __init__(self, label: Label, icon: Icon = None, handler: ClickHandler = None):
        self.label = label
        self.icon = icon
        self.handler = handler

# Build any Button variant without touching the class hierarchy
save_btn   = Button(Label("Save"),   Icon("floppy-disk"), ClickHandler(save_record))
cancel_btn = Button(Label("Cancel"),                      ClickHandler(dismiss))
icon_only  = Button(Label(""),       Icon("trash"),       ClickHandler(delete_record))
Physical entities - Car HAS-A Engine

A Car is not a type of Engine or Transmission. Modeling it with inheritance would be semantically wrong and would make swapping components impossible. Composition lets you replace a GasEngine with an ElectricMotor without touching the Car class at all.

from abc import ABC, abstractmethod

class Engine(ABC):
    @abstractmethod
    def start(self) -> str: ...

    @abstractmethod
    def stop(self) -> str: ...

class GasEngine(Engine):
    def start(self) -> str: return "Vroom"
    def stop(self) -> str: return "Engine off"

class ElectricMotor(Engine):
    def start(self) -> str: return "Whirr"
    def stop(self) -> str: return "Silent"

class Transmission:
    def shift(self, gear: int): ...

class Car:
    """Car HAS-A engine, HAS-A transmission - not IS-A either of them"""
    def __init__(self, engine: Engine, transmission: Transmission):
        self.engine = engine
        self.transmission = transmission

    def drive(self):
        self.engine.start()
        self.transmission.shift(1)

# Swap engine type with zero changes to Car
gas_car      = Car(GasEngine(),     Transmission())
electric_car = Car(ElectricMotor(), Transmission())
Quick Decision Guide
Ask yourself:
  1. Is it a true "is-a" relationship? (No - lean toward composition)
  2. Is the domain pipeline or flow-based? (Yes - favor inheritance)
  3. Will behavior change or be injected at runtime? (Yes - use composition)
  4. Does each layer in the hierarchy add one clear responsibility? (No - use composition)
  5. Are the parts independently useful and reusable? (Yes - use composition)
The practical rule:

Match the tool to the domain. In ETL and pipeline architectures, inheritance with layered responsibilities is almost always the cleaner design. In UI, services, and entity modeling, composition is the natural fit.

Neither is a default. Ask: does this domain have a stable, sequential contract (inherit), or does it assemble independent parts at runtime (compose)?

Common Inheritance Anti-Patterns
The Banana-Gorilla Problem

"You wanted a banana but got a gorilla holding the banana and the entire jungle." Deep hierarchies pull in far more than you need.

Inheriting to Reuse Code

Extending a class just to access its utility methods creates false semantic relationships. Use a utility module or composition instead.

The Yo-Yo Problem

Tracing logic requires jumping up and down the hierarchy constantly. This is a symptom of layers without clear responsibilities, not simply of depth. A well-layered ETL hierarchy can be deep without causing this problem.

When Composition Works Against You

Composition is not always the cleaner answer. Two patterns signal it's the wrong tool:

1. All responsibilities collapse into one class

When composition requires injecting HTTP, cache, and DB dependencies into every flow regardless of whether that flow needs them, you have violated Interface Segregation. Worse, every update to the shared class propagates risk to all flows, even unrelated ones.

# Anti-pattern: composition forces one class to carry everything
class ETLService:
    def __init__(self, http_client, cache, db, transformer, loader):
        self.http_client = http_client  # What if this flow doesn't need cache?
        self.cache = cache              # What if this flow doesn't write to DB?
        self.db = db                    # Every flow carries all of this
        self.transformer = transformer
        self.loader = loader

    def run(self, url: str):
        data = self.http_client.get(url)
        data = self.transformer.transform(data)
        self.loader.load(data)
        # cache and db always injected, sometimes unused
        # A bug fix in cache logic triggers re-testing ALL flows
2. Per-record classes - the granularity trap

If your design forces you to instantiate a class per record - one object per row, one object per event - composition has driven the granularity to the wrong level. A class should represent a capability or a service, not a single unit of work. Processing 10 million records means 10 million object allocations, and all the dependency overhead that comes with each one.

# Anti-pattern: class instantiated per record - does not scale
class RecordProcessor:
    def __init__(self, record: dict, validator, transformer, loader):
        self.record = record        # One instance per record
        self.validator = validator
        self.transformer = transformer
        self.loader = loader

    def process(self):
        if self.validator.validate(self.record):
            self.loader.load(self.transformer.transform(self.record))

# 10 million records = 10 million object allocations with full dependency overhead
for record in load_from_source():
    RecordProcessor(record, validator, transformer, loader).process()

# Better: the processor IS the service, records are just data passed through
class RecordProcessor:
    def __init__(self, validator, transformer, loader):
        self.validator = validator   # Set once
        self.transformer = transformer
        self.loader = loader

    def process_batch(self, records: list[dict]):
        for record in records:
            if self.validator.validate(record):
                self.loader.load(self.transformer.transform(record))

When OOP Shines

  • Domain modeling: When your code mirrors real-world entities (users, orders, products)
  • Large systems: Managing complexity through encapsulation and modularity
  • Frameworks: Providing extension points through inheritance and interfaces
  • GUI applications: Widgets and components naturally map to objects
  • Stateful systems: When entities maintain state over time

Functional Programming: Transformation Over Mutation

Functional programming treats computation as the evaluation of mathematical functions. It emphasizes immutability, pure functions, and declarative transformations.

Core Principles

1. Pure Functions
A function that always produces the same output for the same input and has no side effects (doesn't modify external state, perform I/O, or depend on mutable data).
# Impure (bad) - has side effects
total = 0
def add_to_total(x):
    global total
    total += x  # Side effect: modifies external state
    return total

# Pure (good) - deterministic, no side effects
def add(x, y):
    return x + y  # Same inputs always give same output
2. Immutability
Data that cannot be changed after creation. Instead of modifying existing data, functional programming creates new data structures with the desired changes, leaving originals intact.
# Mutable approach (modifies original)
def update_user(user, new_email):
    user['email'] = new_email  # Modifies original
    return user

# Immutable approach (creates new data)
def update_user(user, new_email):
    return {**user, 'email': new_email}  # New dict, original unchanged
3. Higher-Order Functions
Functions that take other functions as arguments or return functions as results, enabling powerful abstractions and code reuse patterns like decorators, callbacks, and function composition.
def retry(times, delay=1):
    """Decorator that retries a function on failure"""
    def decorator(func):
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == times - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(times=3, delay=2)
def fetch_data(url):
    return requests.get(url).json()
4. Declarative Transformations
Expressing what you want to achieve rather than how to achieve it step-by-step, using operations like map, filter, and reduce to transform data through clear, composable pipelines.
# Imperative (how) - explicit steps
def get_adult_names(users):
    result = []
    for user in users:
        if user['age'] >= 18:
            result.append(user['name'])
    return result

# Declarative (what) - expresses intent
def get_adult_names(users):
    return [user['name'] for user in users if user['age'] >= 18]

# Functional style with map/filter
get_adult_names = lambda users: list(map(
    lambda u: u['name'],
    filter(lambda u: u['age'] >= 18, users)
))

Functional Patterns in Practice

Function Composition & Data Pipelines
Combining simple functions to build complex transformations by chaining them together, where the output of one function becomes the input of the next, creating clear data processing pipelines.
from functools import reduce

# Individual transformation functions
def parse_csv(data): pass
def filter_valid_transactions(data): pass
def calculate_totals(data): pass
def group_by_region(data): pass
def format_report(data): pass

# Compose pattern: build complex pipelines
def compose(*functions):
    """Compose functions right to left"""
    return reduce(lambda f, g: lambda x: f(g(x)), functions)

# Build reusable pipeline
process_pipeline = compose(
    format_report,
    group_by_region,
    calculate_totals,
    filter_valid_transactions,
    parse_csv
)

# Execute pipeline
result = process_pipeline(raw_data)
Map-Reduce Pattern
A pattern for parallel data processing where map transforms each element independently, and reduce aggregates results into a single value, enabling scalable computation.
from functools import reduce

orders = [
    {'id': 1, 'amount': 100, 'region': 'US'},
    {'id': 2, 'amount': 150, 'region': 'EU'},
    {'id': 3, 'amount': 200, 'region': 'US'},
]

# Map: Transform each item independently
def get_amount(order):
    return order['amount']

# Reduce: Aggregate all results
def sum_amounts(acc, amount):
    return acc + amount

# Combine: map then reduce
total = reduce(sum_amounts, map(get_amount, orders), 0)
print(total)  # 450
Currying & Partial Application
Transforming a function that takes multiple arguments into a sequence of functions each taking a single argument, enabling partial application where you can "pre-configure" functions with some arguments.
# Simple currying example
def multiply(x):
    return lambda y: x * y

double = multiply(2)
triple = multiply(3)

print(double(5))  # 10
print(triple(5))  # 15

# Practical use: Pre-configured functions
def create_logger(level):
    """Returns a logging function configured for specific level"""
    def log(message):
        if level == 'DEBUG':
            print(f"[DEBUG] {message}")
        elif level == 'INFO':
            print(f"[INFO] {message}")
    return log

# Create specialized loggers
debug = create_logger('DEBUG')
info = create_logger('INFO')

debug("Starting process")  # [DEBUG] Starting process
info("Process complete")   # [INFO] Process complete
Monads for Error Handling
A design pattern for chaining operations that might fail, wrapping values in a container (like Result) that handles errors gracefully without exceptions, enabling clean functional error handling.
class Result:
    """Result monad: wraps success value or error"""
    def __init__(self, value=None, error=None):
        self.value = value
        self.error = error

    def is_ok(self):
        return self.error is None

    def map(self, func):
        """Transform value if ok, pass error through"""
        if self.is_ok():
            try:
                return Result(value=func(self.value))
            except Exception as e:
                return Result(error=str(e))
        return self

    def flat_map(self, func):
        """Like map but func returns Result"""
        if self.is_ok():
            return func(self.value)
        return self

# Usage: Chain operations that might fail
def divide(x, y):
    if y == 0:
        return Result(error="Division by zero")
    return Result(value=x / y)

def square(x):
    return x * x

# Chain operations - errors propagate automatically
result = (
    divide(10, 2)                      # Result(5)
    .map(square)                       # Result(25)
    .flat_map(lambda x: divide(x, 5))  # Result(5.0)
)

print(result.value if result.is_ok() else result.error)
Power of Immutability: Immutable data makes concurrent programming much easier because you don't need locks, no shared mutable state means no race conditions.

When Functional Programming Shines

  • Data transformations: ETL pipelines, data processing, analytics
  • Concurrent systems: Immutability eliminates race conditions
  • Event processing: Streaming data, reactive systems
  • Mathematical computations: When operations are naturally functional
  • Testing: Pure functions are trivial to test

Comparing Paradigms: The Same Problem

Let's solve the same problem using all three paradigms: Calculate total revenue from orders, applying discounts and filtering invalid orders.

# STRUCTURED APPROACH
def calculate_revenue_structured(orders):
    total = 0
    for order in orders:
        if order['status'] == 'valid':
            subtotal = order['amount']
            if order['discount']:
                subtotal = subtotal * (1 - order['discount'])
            total += subtotal
    return total

# OBJECT-ORIENTED APPROACH
class Order:
    def __init__(self, amount, discount=0, status='valid'):
        self.amount = amount
        self.discount = discount
        self.status = status

    def is_valid(self):
        return self.status == 'valid'

    def calculate_total(self):
        return self.amount * (1 - self.discount)

class RevenueCalculator:
    def __init__(self, orders):
        self.orders = orders

    def calculate(self):
        return sum(
            order.calculate_total()
            for order in self.orders
            if order.is_valid()
        )

# FUNCTIONAL APPROACH - Pythonic
def calculate_revenue_functional(orders):
    return sum(
        order['amount'] * (1 - order.get('discount', 0))
        for order in orders
        if order['status'] == 'valid'
    )
Key Insight: The structured approach is straightforward but couples logic together. The OO approach encapsulates behavior and is extensible but more verbose. The functional approach is concise and composable but may be harder to debug.

Multi-Paradigm Programming: The Pragmatic Approach

Modern languages like Python support multiple paradigms. The best code uses the right paradigm for each problem rather than forcing everything into one model.

# Mixing paradigms effectively
class DataProcessor:
    """OOP for state management and interface"""
    def __init__(self, config):
        self.config = config
        self.cache = {}

    def process(self, data_path):
        """Process data through functional pipeline"""
        data = self._load_data(data_path)
        validated = self._validate(data)
        transformed = self._transform(validated)
        return self._aggregate(transformed)

    # Structured for step-by-step logic
    def _load_data(self, data_path):
        if data_path in self.cache:
            return self.cache[data_path]

        result = []
        with open(data_path) as f:
            for line in f:
                result.append(self._parse_line(line))

        self.cache[data_path] = result
        return result

    # Functional for transformations
    def _validate(self, records):
        return [r for r in records if self._is_valid(r)]

    def _transform(self, records):
        return [self._apply_business_rules(r) for r in records]

    # Functional utilities as pure functions
    @staticmethod
    def _is_valid(record):
        return record.get('amount', 0) > 0

    @staticmethod
    def _apply_business_rules(record):
        return {
            **record,
            'processed': True,
            'tax': record['amount'] * 0.1
        }

    def _aggregate(self, records):
        """Structured aggregation"""
        totals = {}
        for record in records:
            region = record['region']
            totals[region] = totals.get(region, 0) + record['amount']
        return totals

Guidelines for Mixing Paradigms

  • Use OOP for: System structure, managing state, defining interfaces
  • Use FP for: Data transformations, business logic, validation
  • Use structured for: Algorithms, I/O operations, glue code
  • Keep paradigms separated: Don't mix within a single function
  • Be consistent: Similar problems should use similar approaches

Real-World Trade-offs

AspectStructuredObject-OrientedFunctional
Learning CurveEasy - linear flowModerate - requires OOP thinkingSteep - different mental model
MaintainabilityGood for small programsExcellent for large systemsExcellent when understood
TestabilityModerate - side effectsGood - can mock objectsExcellent - pure functions
ConcurrencyDifficult - shared stateModerate - need lockingEasy - immutable data
PerformanceFast - directGood - some overheadVariable - depends on language
Code ReuseFunctions onlyInheritance & compositionHigher-order functions

Decision Framework: Which Paradigm?

Ask yourself these questions:

1. "Is this modeling a real-world (business) entity with behavior?"
   → YES: Use OOP (User, Order, PaymentProcessor)
   → NO: Continue...

2. "Is this primarily a data transformation?"
   → YES: Use Functional (parsing, filtering, aggregating)
   → NO: Continue...

3. "Is this a step-by-step algorithm or I/O operation?"
   → YES: Use Structured (file processing, API calls)

4. "Does this need to maintain state over time?"
   → YES: Use OOP (database connection, cache, session)
   → NO: Prefer Functional or Structured

5. "Will this be used concurrently?"
   → YES: Prefer Functional (immutability = thread-safe)
   → NO: Any paradigm works

Example Decisions

  • E-commerce checkout flow: OOP (models User, Cart, Order with state and behavior)
  • ETL data pipeline: Functional (chains of transformations on immutable data)
  • Deployment script: Structured (sequential steps: backup, deploy, verify)
  • Web framework: OOP (routes, middleware, controllers with extension points)
  • Analytics query: Functional (map-reduce over datasets)
  • Game engine: Multi-paradigm (OOP for entities, functional for physics calculations, structured for game loop)

Common Anti-Patterns to Avoid

❌ OOP Overuse: The "Everything is a Class" Disease

Not every piece of functionality needs to be wrapped in a class. Use functions for stateless operations.

# BAD: Unnecessary abstraction
class MathOperations:
    @staticmethod
    def add(a, b):
        return a + b

result = MathOperations.add(2, 3)  # Just use: 2 + 3

# BAD: Manager/Helper/Util classes
class UserManager:
    def get_user(self, id): pass
    def save_user(self, user): pass
    def delete_user(self, id): pass
# This is just a namespace, not an object with state!

# GOOD: Use functions when there's no state
def add(a, b):
    return a + b
❌ Functional Overuse: Unreadable One-Liners

Clever one-liners might be impressive, but code is read far more often than it's written. Prioritize clarity.

# BAD: Clever but incomprehensible
result = reduce(lambda a, x: a + [x] if x not in a else a,
                filter(lambda x: x > 0,
                       map(lambda x: x['value'], data)), [])

# GOOD: Clear and maintainable
def get_positive_unique_values(data):
    values = [item['value'] for item in data]
    positive = [v for v in values if v > 0]
    return list(set(positive))  # Remove duplicates
❌ Mixing Paradigms Chaotically

Keep paradigms separated within functions. Don't mix side effects with functional style.

# BAD: Mixing mutation with functional style
def process_items(items):
    result = []
    return [result.append(x * 2) or x * 2 for x in items if x > 0]
# Side effects in list comprehension!

# GOOD: Choose one approach per function
def process_items(items):
    return [x * 2 for x in items if x > 0]

Key Takeaways

  • No paradigm is universally superior - each excels in different contexts
  • Structured: Best for algorithms, scripts, and sequential operations
  • OOP: Best for modeling entities, managing state, and large systems
  • Functional: Best for transformations, concurrency, and testability
  • Multi-paradigm is pragmatic - use the right tool for each job
  • Consistency matters - keep similar problems solved similarly
  • Avoid over-engineering - simple solutions beat clever ones
  • Understanding all three paradigms makes you a better architect who can choose the right approach for each problem rather than forcing every problem into one mental model
Architecture EssentialsLesson 1