Nested Logic Patterns - Engineering Readable, Scalable Conditionals
Reading time: ~24 minutes | Level: Foundation → Engineering
Read this function and ask yourself: what does it do?
def process(u, o):
if u:
if u.active:
if o:
if o.status == "pending":
if u.balance >= o.total:
if not u.flagged:
o.status = "approved"
u.balance -= o.total
else:
o.status = "review"
else:
o.status = "insufficient_funds"
else:
raise ValueError("Order not pending")
else:
raise ValueError("No order")
else:
raise ValueError("User inactive")
else:
raise ValueError("No user")
It approves orders. But you needed several seconds to figure that out, and you likely missed at least one edge case on first reading. This is the nesting problem. The logic is correct; the structure makes it unmaintainable. This lesson teaches you five concrete techniques to eliminate this kind of code, when to measure complexity formally, and when nesting is actually the right choice.
What You Will Learn
- Why cognitive complexity grows roughly exponentially with nesting depth - the mathematical intuition behind McCabe's cyclomatic complexity metric
- How to read and draw the "pyramid of doom" as a decision tree to understand what you're actually dealing with
- Five refactoring techniques: early return / guard clauses, combining conditions, extract to function, lookup tables / dispatch dictionaries, and polymorphism
- When nesting is the right choice (and it sometimes is)
- How to measure complexity objectively using the
radonlibrary - How nested loops create O(n²) complexity and how sets/dicts restore O(n) performance
- Pitfalls of over-refactoring - the danger of creating too many tiny functions
Prerequisites
- Comfortable writing nested
if/elif/elseblocks - Basic understanding of Python functions
- Familiarity with Python dictionaries
McCabe's Cyclomatic Complexity - Putting a Number on Mess
In 1976, Thomas McCabe defined a metric called cyclomatic complexity to measure how many independent execution paths exist through a piece of code. The formula for a function is:
Cyclomatic Complexity = Number of decision points + 1
Every if, elif, for, while, and, or, except, and case adds 1 to the count. A function with no branches has complexity 1. Each branch adds 1.
The industry benchmarks:
Complexity 1–5 : Simple, easy to test
Complexity 6–10 : Moderate, some test effort required
Complexity 11–15: High, hard to fully test
Complexity >15 : Very high, strong refactoring signal
The function at the top of this lesson has a cyclomatic complexity of 8 (six if statements, one else-equivalent path, plus 1). That is in the "moderate" range - but the cognitive complexity is much higher than the number suggests because the branches are deeply nested rather than sequential.
Nesting multiplies cognitive load faster than cyclomatic complexity captures. Sequential if statements are easy: you read them one at a time. Nested if statements are hard: you must hold the entire outer context in working memory while reading the inner one.
The Pyramid of Doom - Visualising the Problem
The term "pyramid of doom" describes code where every condition nests inside the previous one, creating a right-pointing triangle of indentation:
Width of indentation is proportional to depth of nesting. Number of states to hold in working memory is proportional to depth squared.
When you read the innermost line do_actual_work(), you must simultaneously remember:
- condition_1 is True
- condition_2 is True (given condition_1)
- condition_3 is True (given conditions 1 and 2)
- condition_4 is True (given conditions 1, 2, and 3)
- condition_5 is True (given conditions 1, 2, 3, and 4)
That is five simultaneous boolean constraints in working memory. Human short-term memory handles about four items comfortably. At five or more, comprehension errors begin appearing - and those errors become bugs in code review.
The same function as a decision tree:
This tree has 6 leaf nodes (6 distinct outcomes) reachable through 6 paths. To test completely, you need at minimum 6 test cases - one per leaf. With 5 levels of nesting, finding the right test data for each leaf requires understanding all conditions on the path from root to that leaf.
Refactoring Technique 1 - Early Return / Guard Clauses
The most universally applicable technique: handle failure cases first, then write the happy path at the minimum indentation level. Each failure case becomes a guard clause - a short, independent check that exits immediately when its condition is not met.
Before:
def process_order(user, order):
if user:
if user.active:
if order:
if order.status == "pending":
if user.balance >= order.total:
if not user.flagged:
order.status = "approved"
user.balance -= order.total
else:
order.status = "review"
else:
order.status = "insufficient_funds"
else:
raise ValueError("Order not pending")
else:
raise ValueError("No order")
else:
raise ValueError("User inactive")
else:
raise ValueError("No user")
After - guard clauses:
def process_order(user, order):
if user is None:
raise ValueError("No user")
if not user.active:
raise ValueError("User inactive")
if order is None:
raise ValueError("No order")
if order.status != "pending":
raise ValueError("Order not pending")
# Happy path: all preconditions satisfied
if user.balance < order.total:
order.status = "insufficient_funds"
return
if user.flagged:
order.status = "review"
return
order.status = "approved"
user.balance -= order.total
The cyclomatic complexity is the same (same number of decision points), but the cognitive complexity dropped significantly. Each guard is readable in isolation. The happy path is flat. The error cases are handled close to their guard condition, not buried inside a pyramid.
The guard clause pattern follows a single rule: reject the invalid, then do the valid. Write every check that should cause early exit first, at the top of the function. What remains after all the guards is the code that actually does the work.
Refactoring Technique 2 - Combining Conditions
When two nested if statements are always evaluated together and neither has meaningful else logic, merge them into a single compound condition:
# Before: two nested ifs
if user.is_authenticated:
if user.is_active:
grant_access(user)
# After: single compound condition
if user.is_authenticated and user.is_active:
grant_access(user)
This reduces nesting without changing semantics. Python's and short-circuits: if user.is_authenticated is False, user.is_active is never evaluated - the same behavior as the nested version.
For complex multi-line conditions, use a named boolean:
# Named boolean: self-documenting and easy to test
is_eligible = (
user.is_authenticated
and user.is_active
and user.subscription_tier in ("pro", "enterprise")
and not user.has_outstanding_payment
)
if is_eligible:
grant_premium_access(user)
Combining conditions works when the nested if statements have no independent else branches. If if condition_a has its own else block, and inside the if block there is if condition_b with its own else, the two conditions are not independently separable and this technique does not apply cleanly.
Refactoring Technique 3 - Extract to Function
The single most powerful refactoring tool. When a block of nested logic is complex enough to cause confusion, give it a name. The complexity does not disappear, but it is contained, independently testable, and hidden behind a readable interface.
# Before: logic inline, reader must parse everything
def handle_request(request):
if request.method == "POST":
if "Authorization" in request.headers:
token = request.headers["Authorization"].split(" ")[-1]
if len(token) == 64:
if is_valid_token(token):
if request.user.has_permission("write"):
save_data(request.body)
# After: complex sub-logic extracted into named functions
def handle_request(request):
if request.method != "POST":
return
if not is_authorized(request):
return
if not request.user.has_permission("write"):
return
save_data(request.body)
def is_authorized(request):
"""Return True if the request carries a valid bearer token."""
auth_header = request.headers.get("Authorization", "")
parts = auth_header.split(" ")
if len(parts) != 2 or parts[0] != "Bearer":
return False
token = parts[1]
return len(token) == 64 and is_valid_token(token)
The main function is now five lines. Each line states a clear precondition. is_authorized encapsulates the token validation logic and can be unit-tested with direct calls - no need to construct a full request object. A future developer can read the main function without understanding the token format, and can read is_authorized without understanding the full request handling pipeline.
Over-extraction is a real failure mode. Do not create a function for every two lines of code. Extract to a function when: the block has a clear, nameable purpose; the block is used (or could be used) in more than one place; or the block is complex enough that a reader benefits from being able to skip it while reading the outer function.
Refactoring Technique 4 - Lookup Tables and Dispatch Dictionaries
When branching is driven by the value of a variable (especially a string or enum), a dictionary that maps values to functions can replace the entire if/elif chain:
# Before: if/elif chain grows with every new payment type
def process_payment(payment_type, amount, currency):
if payment_type == "card":
return process_card(amount, currency)
elif payment_type == "paypal":
return process_paypal(amount, currency)
elif payment_type == "bank_transfer":
return process_bank_transfer(amount, currency)
elif payment_type == "crypto":
return process_crypto(amount, currency)
else:
raise ValueError(f"Unknown payment type: {payment_type}")
# After: dispatch dictionary - adding a new type is one line
PAYMENT_HANDLERS = {
"card": process_card,
"paypal": process_paypal,
"bank_transfer": process_bank_transfer,
"crypto": process_crypto,
}
def process_payment(payment_type, amount, currency):
handler = PAYMENT_HANDLERS.get(payment_type)
if handler is None:
raise ValueError(f"Unknown payment type: {payment_type}")
return handler(amount, currency)
The dispatch dictionary version has a cyclomatic complexity of 2 (one if for the unknown-type check), regardless of how many payment types exist. Adding support for a new payment type means adding one line to the dictionary. The if/elif version requires modifying the function body and re-testing the entire chain.
For two-dimensional dispatch (branching on two values simultaneously):
# Dispatching on (role, region) combinations
HANDLERS = {
("admin", "EU"): handle_admin_eu,
("admin", "US"): handle_admin_us,
("user", "EU"): handle_user_eu,
("user", "US"): handle_user_us,
}
def route_request(role, region, request):
handler = HANDLERS.get((role, region))
if handler is None:
raise PermissionError(f"No handler for role={role}, region={region}")
return handler(request)
This pattern eliminates nested if/elif structures entirely for value-dispatch cases.
Use the dispatch dictionary pattern when: the branch condition is a equality check on a single value (or a tuple of values); the number of cases is three or more; and new cases are likely to be added in the future. For two or fewer cases, a simple if/else is clearer.
Refactoring Technique 5 - Polymorphism
When the branching is driven by the type of an object, and each branch does conceptually the same thing for a different kind of object, that is a signal for polymorphism. Instead of asking if type == X: do_x_thing(), each object type knows how to do its thing:
# Before: branching on type
def calculate_area(shape):
if shape["type"] == "circle":
return 3.14159 * shape["radius"] ** 2
elif shape["type"] == "rectangle":
return shape["width"] * shape["height"]
elif shape["type"] == "triangle":
return 0.5 * shape["base"] * shape["height"]
else:
raise ValueError(f"Unknown shape: {shape['type']}")
# After: each shape knows its own area calculation
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Triangle:
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# Caller: zero branching
def calculate_area(shape):
return shape.area()
The calculate_area function is now one line. Adding a new shape type means creating a new class - no modification to calculate_area (or any existing code) required. This is the Open/Closed Principle: open for extension, closed for modification.
Polymorphism is the right tool when you have a family of related objects that need to respond differently to the same operation. It is overkill for simple, non-extensible branching.
When Nesting IS the Right Choice
Refactoring everything into guards, functions, and dispatch tables is not always correct. Nesting is appropriate in several legitimate cases:
Case 1: Truly interdependent conditions that share state
# The inner check is only meaningful when the outer is True
# AND the inner check uses data only available inside the outer block
with open(filepath) as f:
content = f.read()
if content:
first_line = content.split("\n")[0]
if first_line.startswith("#!"):
shebang = first_line
Extracting this to guard clauses would require passing content and first_line as parameters, making the code longer and the data flow less obvious.
Case 2: Short, self-contained logic
A two-level nest with straightforward logic often reads better than an early-return version:
# Nested - reads naturally for two levels
for key, value in config.items():
if isinstance(value, dict):
for sub_key, sub_value in value.items():
merged[f"{key}.{sub_key}"] = sub_value
else:
merged[key] = value
Case 3: State machines with compound states
When code models a state machine where state depends on multiple simultaneous boolean conditions, nested structure mirrors the state space directly and a flat version would obscure the relationships.
The heuristic: if nesting depth exceeds 3 levels (4 indentation levels from the def statement), treat it as a refactoring signal. At 5 or more levels, it is a near-certain smell.
Measuring Complexity with radon
Rather than relying on intuition, measure objectively. The radon library computes McCabe's cyclomatic complexity for Python functions:
pip install radon
radon cc mymodule.py -s # -s shows the complexity score per function
mymodule.py
F 12:0 process_order - D (9)
F 28:0 validate_user - B (6)
F 45:0 route_request - A (2)
Grades: A (1–5), B (6–10), C (11–15), D (16–20), E (21–25), F (26+). Target A or B for all production functions. A D-grade function almost always has bugs hiding in its unexercised paths.
Integrate radon into your CI pipeline to fail builds when any function exceeds complexity 10:
radon cc mymodule.py --min C --show-complexity
# Exits non-zero if any function has complexity >= C (11)
Nested Loops and O(n²) Complexity
Nested loops are the algorithmic equivalent of nested conditionals - they multiply cost rather than adding it:
# O(n²): for each of n items, scan all n items again
def find_duplicates_slow(items):
duplicates = []
for i in range(len(items)):
for j in range(i + 1, len(items)):
if items[i] == items[j]:
duplicates.append(items[i])
return duplicates
For a list of 10,000 items, this performs ~50 million comparisons. A set-based approach achieves the same result in O(n):
# O(n): one pass, O(1) lookup in set
def find_duplicates_fast(items):
seen = set()
duplicates = []
for item in items:
if item in seen:
duplicates.append(item)
else:
seen.add(item)
return duplicates
The general pattern: when the inner loop is a lookup or membership check, replace it with a set or dictionary. Sets and dictionaries offer O(1) average-case lookup. The transformation converts O(n²) to O(n) at the cost of O(n) extra space - almost always a good trade.
# O(n²): find all items in list_a that are also in list_b
common = [x for x in list_a for y in list_b if x == y]
# O(n): use a set for O(1) membership test
set_b = set(list_b)
common = [x for x in list_a if x in set_b]
Before and After - Complete Refactoring Example
Here is a realistic six-level nested function and its fully refactored equivalent using all five techniques:
Before - six levels deep, cyclomatic complexity 11:
def handle_api_request(user, request, db):
if user is not None:
if user["active"]:
if request["method"] in ("GET", "POST"):
if request["method"] == "POST":
if "body" in request and request["body"]:
resource_type = request.get("resource_type")
if resource_type == "order":
handler_fn = create_order
elif resource_type == "product":
handler_fn = create_product
elif resource_type == "review":
handler_fn = create_review
else:
return {"error": "Unknown resource type"}, 400
try:
result = handler_fn(request["body"], db)
return {"data": result}, 201
except Exception as e:
return {"error": str(e)}, 500
else:
return {"error": "Empty body"}, 400
else:
return list_resources(request, db), 200
else:
return {"error": "Method not allowed"}, 405
else:
return {"error": "User inactive"}, 403
else:
return {"error": "Unauthenticated"}, 401
After - refactored using all five techniques:
# Technique 4: Dispatch dictionary (eliminates resource_type if/elif)
RESOURCE_HANDLERS = {
"order": create_order,
"product": create_product,
"review": create_review,
}
def handle_api_request(user, request, db):
# Technique 1: Guard clauses (invert and return early)
if user is None:
return {"error": "Unauthenticated"}, 401
if not user["active"]:
return {"error": "User inactive"}, 403
if request["method"] not in ("GET", "POST"):
return {"error": "Method not allowed"}, 405
if request["method"] == "GET":
return list_resources(request, db), 200
return handle_post_request(request, db)
def handle_post_request(request, db):
# Technique 3: Extracted to function - POST logic in its own scope
# Technique 1: Guard clauses
if not request.get("body"):
return {"error": "Empty body"}, 400
# Technique 4: Dispatch dictionary
handler_fn = RESOURCE_HANDLERS.get(request.get("resource_type"))
if handler_fn is None:
return {"error": "Unknown resource type"}, 400
# Technique 2: Combined error handling (no additional nesting)
try:
result = handler_fn(request["body"], db)
return {"data": result}, 201
except Exception as e:
return {"error": str(e)}, 500
The refactored version:
handle_api_requesthas cyclomatic complexity 5 (down from 11)handle_post_requesthas cyclomatic complexity 4- Both functions are independently testable
- Adding a new resource type is one line in
RESOURCE_HANDLERS - Any developer can read either function in under 30 seconds
Common Pitfalls
Pitfall 1: Over-Extracting Into Too Many Tiny Functions
When every two lines become their own function, the code becomes hard to follow for a different reason: the reader must jump between dozens of small functions to understand a single operation. The sweet spot is functions with a clear, nameable purpose that are long enough to justify the extraction.
Pitfall 2: Using Dict Dispatch When Simple if/elif Is Clearer
For two cases, a dictionary adds ceremony without benefit:
# Over-engineered for two cases
handlers = {True: handle_active, False: handle_inactive}
handlers[user.is_active](user)
# Clearer
if user.is_active:
handle_active(user)
else:
handle_inactive(user)
Pitfall 3: Premature Abstraction
Refactoring to eliminate nesting is only valuable when the nesting is actually causing problems. If a nested block is 10 lines, touched once, and understood immediately, refactoring it is cost without benefit. Apply refactoring techniques to code that has demonstrably high complexity or is causing actual confusion.
Interview Questions and Answers
Q1: What is cyclomatic complexity, and what does it measure?
Cyclomatic complexity is a software metric introduced by Thomas McCabe in 1976 that counts the number of linearly independent paths through a function's control flow graph. The formula is: the number of binary decision points (each if, elif, for, while, and, or, except, case) plus 1. It provides a lower bound on the number of test cases needed to cover all paths. Industry conventions treat 1–5 as simple, 6–10 as moderate, and above 10 as a refactoring signal. Cyclomatic complexity is a structural measure; cognitive complexity (how hard code is to understand) grows faster with nesting than the cyclomatic number captures.
Q2: What are guard clauses, and why do they reduce perceived complexity?
Guard clauses are early-exit statements (typically return, raise, or continue) placed at the top of a function or loop body that handle the failure cases before the main logic. Instead of wrapping the happy path inside a nested if, you check each precondition independently and exit if it fails. This technique reduces nesting depth (the happy path is always at the minimum indentation level), makes each failure case independently readable, and eliminates the mental burden of tracking all outer conditions while reading the inner logic. It also makes failures easy to find - they are always at the top of the function.
Q3: When should you use a dispatch dictionary instead of if/elif?
Use a dispatch dictionary when: the branching is driven by equality checks on a single variable; there are three or more cases; new cases are likely to be added in the future; and each case maps to a callable (function or class). The dictionary reduces cyclomatic complexity from O(n) to O(1) regardless of how many cases exist, and adding a new case is a single-line change to the dictionary rather than a structural modification of the function. Avoid dispatch dictionaries for two-case branching (use if/else), or when the branch conditions are complex expressions rather than simple equality checks.
Q4: When should you extract nested logic into a separate function?
Extract to a function when: the block has a clear, nameable purpose (you can state what it does in a short sentence); the block is long enough that collapsing it in the calling function meaningfully reduces the reader's cognitive load; the block is used in more than one place; or the block needs to be tested independently. Avoid extraction when the block is short (2–3 lines) and its context is essential to understanding it, or when extracting would require passing many parameters, suggesting the extraction boundary is wrong.
Q5: How does a nested loop create O(n²) complexity, and how do you fix it?
A nested loop where the inner loop scans all n items for each of the n outer items performs O(n²) work - quadratic time. For n=10,000 items, that is 100 million operations. The fix is to replace the inner loop's lookup with a set or dictionary: build the lookup structure from the inner iterable in O(n), then perform O(1) membership tests for each outer item. The total cost becomes O(n) for building the set plus O(n) for the outer loop = O(n). The tradeoff is O(n) extra memory for the set, which is almost always acceptable given the time savings.
Q6: What is the role of polymorphism in eliminating conditional branching?
Polymorphism moves type-specific behavior into the object itself. Instead of a function that asks if type == "A": do_a() elif type == "B": do_b(), each class implements the method do() differently. The caller invokes obj.do() without knowing the type. This eliminates the branching entirely from the calling code. New types are added by creating new classes - no modification to existing code. This follows the Open/Closed Principle. Polymorphism is the correct tool when you have a family of objects that respond differently to the same operation, and when you expect the set of types to grow over time.
Graded Practice Challenges
Level 1 - Predict the Output
What is the cyclomatic complexity of this function, and what is the minimum number of test cases needed to cover all paths?
def classify_loan(applicant):
if applicant["age"] >= 18:
if applicant["income"] > 30000:
if applicant["credit_score"] >= 700:
return "approved"
else:
return "approved_with_conditions"
else:
return "rejected_income"
else:
return "rejected_age"
Show Answer
Cyclomatic complexity: 4
There are 3 binary decision points (if age >= 18, if income > 30000, if credit_score >= 700), so complexity = 3 + 1 = 4.
Minimum test cases: 4 (one per leaf outcome)
| Test | age | income | credit_score | Expected |
|---|---|---|---|---|
| 1 | 17 | any | any | "rejected_age" |
| 2 | 25 | 20000 | any | "rejected_income" |
| 3 | 25 | 50000 | 650 | "approved_with_conditions" |
| 4 | 25 | 50000 | 750 | "approved" |
Note: Test 1 only needs age < 18; the income and credit_score values are irrelevant because those checks are never reached.
Level 2 - Debug the Refactoring
A junior developer attempted to refactor a nested function using guard clauses but introduced a bug. Find the bug and correct it.
# Original (correct behavior)
def transfer_funds(sender, receiver, amount):
if sender is not None:
if receiver is not None:
if amount > 0:
if sender.balance >= amount:
sender.balance -= amount
receiver.balance += amount
return True
return False
return False
return False
# Refactored attempt (has a bug)
def transfer_funds(sender, receiver, amount):
if sender is None:
return False
if receiver is None:
return False
if amount <= 0:
return False
if sender.balance < amount:
return False
sender.balance -= amount
receiver.balance += amount
return True
Show Answer
The refactored version is actually correct. There is no bug - the behavior is identical to the original.
The trick in this challenge is to notice that the original's indented return False statements are sometimes reached via the else paths of conditions, and the refactored version handles all of them with explicit guard clauses. Let's trace the original for amount <= 0:
sender is not None→ True (enter block)receiver is not None→ True (enter block)amount > 0→ False (skip inner block)- Falls through to
return Falseat theif amount > 0level
The refactored version: if amount <= 0: return False - same outcome.
The lesson here is that reading refactored code requires verifying all original paths are preserved, not just the happy path. One common real bug in such refactoring is accidentally converting if a: if b: return X; return Y to if not a: return Y; if not b: return Y; return X - which changes behavior when a is False (the original returns Y from the outer else, the refactored version also returns Y, so actually this would be fine too). Always trace all paths when verifying a refactoring.
Level 3 - Design Challenge
The following function has a cyclomatic complexity of 12 and is deeply nested. Refactor it using at least three of the five techniques covered in this lesson. Document which technique you applied at each step.
def process_webhook(payload, db, config):
if payload:
event_type = payload.get("event")
if event_type:
if event_type == "payment.completed":
order_id = payload.get("order_id")
if order_id:
order = db.get_order(order_id)
if order:
if order.status == "pending":
if config.get("auto_fulfill"):
order.status = "fulfilled"
db.save(order)
send_fulfillment_email(order)
else:
order.status = "paid"
db.save(order)
elif event_type == "payment.failed":
order_id = payload.get("order_id")
if order_id:
order = db.get_order(order_id)
if order:
order.status = "payment_failed"
db.save(order)
send_failure_notification(order)
elif event_type == "refund.issued":
refund_id = payload.get("refund_id")
if refund_id:
refund = db.get_refund(refund_id)
if refund:
refund.status = "processed"
db.save(refund)
send_refund_confirmation(refund)
else:
log_unknown_event(event_type)
else:
raise ValueError("Missing event type")
else:
raise ValueError("Empty payload")
Show Answer
# === REFACTORED VERSION ===
# TECHNIQUE 4: Dispatch dictionary for event_type branching
# Eliminates the if/elif/elif/else chain entirely
def handle_payment_completed(payload, db, config):
"""TECHNIQUE 3: Extracted to function - payment.completed logic."""
# TECHNIQUE 1: Guard clauses
order_id = payload.get("order_id")
if not order_id:
return
order = db.get_order(order_id)
if not order:
return
if order.status != "pending":
return
# TECHNIQUE 2: Combined condition → no extra nesting
if config.get("auto_fulfill"):
order.status = "fulfilled"
send_fulfillment_email(order)
else:
order.status = "paid"
db.save(order)
def handle_payment_failed(payload, db, config):
"""TECHNIQUE 3: Extracted to function."""
order_id = payload.get("order_id")
if not order_id:
return
order = db.get_order(order_id)
if not order:
return
order.status = "payment_failed"
db.save(order)
send_failure_notification(order)
def handle_refund_issued(payload, db, config):
"""TECHNIQUE 3: Extracted to function."""
refund_id = payload.get("refund_id")
if not refund_id:
return
refund = db.get_refund(refund_id)
if not refund:
return
refund.status = "processed"
db.save(refund)
send_refund_confirmation(refund)
# TECHNIQUE 4: Dispatch dictionary
EVENT_HANDLERS = {
"payment.completed": handle_payment_completed,
"payment.failed": handle_payment_failed,
"refund.issued": handle_refund_issued,
}
def process_webhook(payload, db, config):
"""TECHNIQUE 1: Guard clauses at the top."""
if not payload:
raise ValueError("Empty payload")
event_type = payload.get("event")
if not event_type:
raise ValueError("Missing event type")
# TECHNIQUE 4: Dispatch dictionary lookup
handler = EVENT_HANDLERS.get(event_type)
if handler is None:
log_unknown_event(event_type)
return
handler(payload, db, config)
Techniques applied:
- Guard clauses (Technique 1):
process_webhooknow rejects invalid payloads at the top. Each extracted handler rejects missing IDs and missing DB records at the top. - Extract to function (Technique 3): Each event type's logic lives in its own function, independently testable.
- Dispatch dictionary (Technique 4): The
if/elif/elif/elsechain onevent_typebecomes a single dictionary lookup. - Combined conditions (Technique 2): The
auto_fulfillbranch has no nested validation - all validation was handled by guards above it.
Complexity reduction: process_webhook drops from complexity 12 to complexity 3. Each handler is complexity 3–4. Total complexity is distributed and manageable. Adding a new event type requires writing one new function and adding one entry to EVENT_HANDLERS.
Quick Reference Cheatsheet
| Technique | When to Use | Complexity Impact |
|---|---|---|
| Guard clauses | Preconditions, validation, null checks | Reduces nesting depth |
| Combine conditions | Two nested ifs with no independent else | Reduces nesting depth |
| Extract to function | Complex block with nameable purpose | Hides complexity; enables testing |
| Dispatch dictionary | if/elif on equality of one variable, 3+ cases | Reduces CC from O(n) to O(1) |
| Polymorphism | Type-based branching, extensible family | Eliminates branching entirely |
| Signal | Meaning |
|---|---|
| Nesting depth > 3 | Consider guard clauses or extraction |
| if/elif with 4+ cases on one variable | Consider dispatch dictionary |
| Same nested structure in two places | Extract to function |
if type == X repeated | Consider polymorphism |
| Cyclomatic complexity > 10 | Refactoring required |
| O(n²) nested loop | Replace inner loop with set/dict |
Key Takeaways
- Cognitive complexity grows faster than cyclomatic complexity in nested code - each nesting level requires holding all outer conditions in working memory simultaneously.
- McCabe's cyclomatic complexity gives you an objective measurement: above 10 per function is a refactoring signal, and
radonlets you enforce this in CI. - Guard clauses (early returns for invalid cases) are the most universally applicable refactoring technique - they invert conditionals, push the happy path to minimum indentation, and make failure cases readable in isolation.
- Combining nested
ifstatements into compoundand/orconditions works when the nested ifs have no independentelsebranches; use named booleans for readability. - Extracting to a function is the most powerful technique - it gives complex logic a name, makes it independently testable, and lets the caller read a high-level summary without being forced to understand the implementation.
- Dispatch dictionaries replace
if/elifchains driven by value equality - adding a new case is one line, and cyclomatic complexity stays at 2 regardless of how many cases exist. - Polymorphism eliminates type-based branching by putting the behavior inside the object - callers invoke a uniform interface without knowing the type.
- Nested loops are O(n²); replacing the inner loop's lookup with a set or dictionary converts the algorithm to O(n) at the cost of O(n) space.
- Nesting is sometimes correct: deeply interdependent conditions, short self-contained blocks, and state machines can be clearer when nested - the 3-level heuristic is a signal, not an absolute rule.
