Skip to main content

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 radon library
  • 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/else blocks
  • 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.

tip

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)
note

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.

warning

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.

tip

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_request has cyclomatic complexity 5 (down from 11)
  • handle_post_request has 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)

Testageincomecredit_scoreExpected
117anyany"rejected_age"
22520000any"rejected_income"
32550000650"approved_with_conditions"
42550000750"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 False at the if amount > 0 level

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:

  1. Guard clauses (Technique 1): process_webhook now rejects invalid payloads at the top. Each extracted handler rejects missing IDs and missing DB records at the top.
  2. Extract to function (Technique 3): Each event type's logic lives in its own function, independently testable.
  3. Dispatch dictionary (Technique 4): The if/elif/elif/else chain on event_type becomes a single dictionary lookup.
  4. Combined conditions (Technique 2): The auto_fulfill branch 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

TechniqueWhen to UseComplexity Impact
Guard clausesPreconditions, validation, null checksReduces nesting depth
Combine conditionsTwo nested ifs with no independent elseReduces nesting depth
Extract to functionComplex block with nameable purposeHides complexity; enables testing
Dispatch dictionaryif/elif on equality of one variable, 3+ casesReduces CC from O(n) to O(1)
PolymorphismType-based branching, extensible familyEliminates branching entirely
SignalMeaning
Nesting depth > 3Consider guard clauses or extraction
if/elif with 4+ cases on one variableConsider dispatch dictionary
Same nested structure in two placesExtract to function
if type == X repeatedConsider polymorphism
Cyclomatic complexity > 10Refactoring required
O(n²) nested loopReplace 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 radon lets 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 if statements into compound and/or conditions works when the nested ifs have no independent else branches; 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/elif chains 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.
© 2026 EngineersOfAI. All rights reserved.