Concepts / Designing Modular Program Structure

Designing Modular Program Structure

Develop an error-handling strategy early in a design, as part of deciding the program's structure.

  • Programming

Why Error Handling Is a Design Decision

Every non-trivial program will eventually meet a situation it did not expect: a value out of range, a resource that is missing, a request that cannot be completed. How a program responds to these situations is a design decision, not an afterthought. This article looks at three principles from the C++ modularity guidance on error handling. Design an error-handling strategy early. Use purpose-designed user-defined types as exceptions. Do not try to catch every exception in every function.

Before reading the principles, it helps to test the instinct that most beginners bring to this problem. The instinct is easy to predict, and the example in the next section is designed so you can check your own reasoning against the principles as you go.

The Catch-Everything Instinct

A common first reaction to errors is to wrap every function in a protective handler, so that nothing can ever escape unnoticed. It feels safe and thorough. The guidance treats this as a misconception: the idea that the right way to handle errors is to catch every exception in every function. The misconception is attractive because it looks like diligence, but it moves the handling decision away from the place where the program actually knows what to do about a problem.

What do you think happens?

A validation function finds a bad quantity and raises an error. It is called by process_order, which is called by main. Which function should catch the error?

  • validate_quantity, the function that detected the problem

  • process_order, the function in the middle

  • main, the top-level caller that decides what to do

Reveal answer
Answer:

main, the top-level caller that decides what to do

The validation function cannot recover on its own, and the middle function has no meaningful response yet. Only the top-level caller knows what to do, so the catch belongs there. Catching in the validation function or the middle function would only move the decision away from the place that can act on it.

Designing the Strategy Early

The first principle is timing. An error-handling strategy is something you develop early in a design, not something you bolt onto a finished program. Deciding how errors will be represented, where they will be detected, and where they will be handled is part of shaping the program's structure.

The reason timing matters is what happens when the strategy is left until the end. Handling code tends to appear wherever a problem happens to surface. Each function then makes its own ad hoc choice, and the overall approach becomes hard to see. The resulting pattern is scattered and inconsistent, and it is difficult to change later without touching many parts of the program.

Naming the Error with Your Own Type

The second principle says to use purpose-designed user-defined types as exceptions, and explicitly not built-in types. A dedicated type names the error. A handler that catches an exception of a type called for this exact problem can tell what went wrong from the type alone. A generic built-in value carries little meaning about the situation that produced it.

A purpose-designed type also gives the program a place to store the details that a particular handler needs. The rejected value, for example, can travel with the error itself, so the handler that finally acts on it does not have to reconstruct what happened.

ValueErrorgeneric built-in typeInvalidQuantityErrorpurpose-designeduser-defined typeMeaning from the typeonly a general categoryMeaning from the typenames this exact problemDetails carriedmust be added by conventionDetails carriedthe rejected valuetells the callerlimitedtells the callercarries
How does a purpose-designed exception type carry meaning about the failure that a generic built-in type cannot, and how do the two compare when a caller needs to decide what to do?

Following One Error Through the Call Chain

The source example follows one error through a chain of function calls. A validation function detects a bad quantity and throws a purpose-designed exception. The function that calls it does not catch the error, because it has no meaningful response yet. Only the top-level caller decides what to do. The diagram below shows the calling chain the error travels through, and the arrows showing propagation run back up the chain from the point of detection.

maincatchesInvalidQuantityErrorprocess_orderno handler, propagatesvalidate_quantityraises InvalidQuantityErrorcallscallspropagates uppropagates up
When a deeply nested function raises an error, which caller in the chain should catch it, and how does control travel back up the stack to get there?
Worked example

Tracing a rejected order quantity

A customer submits an order with quantity -3. Trace where the error is raised, which functions are passed through, and where it is finally handled.

  1. 1
    Detect

    validate_quantity checks the quantity, finds it is not greater than zero, and throws InvalidQuantityError carrying the value -3.

  2. 2
    Pass through without catching

    process_order calls validate_quantity but has no meaningful response to a rejected quantity yet, so it does not catch the error. The exception leaves process_order and continues up the chain.

  3. 3
    Handle at the boundary

    main catches InvalidQuantityError. It has the authority to tell the user to enter a valid quantity, and it reads the rejected value from the exception.

Result

The error is raised in validate_quantity, passes through process_order, and is handled only in main, the caller that knows how to respond.

python
Output (expected)
Please enter a quantity greater than zero. Rejected: -3
Notes on the code
  • InvalidQuantityError is a purpose-designed type that carries the rejected value
  • validate_quantity detects the problem and throws; it does not try to recover
  • process_order has no handler, so the error travels past it
  • main is the only function with a catch, because it is the only one with a meaningful response

Scattered Handling Versus One Boundary

The contrast below shows what the code looks like when every function handles its own errors, compared with handling concentrated at one boundary. In the first version, each function carries its own copy of the handling logic. In the second, the intermediate functions contain no handlers at all and only the top-level caller responds.

Before
def validate_quantity(quantity):
    try:
        if quantity <= 0:
            raise InvalidQuantityError(quantity)
        return quantity
    except InvalidQuantityError:
        print('bad quantity')
        return None

def process_order(quantity):
    try:
        validate_quantity(quantity)
        return 'accepted'
    except InvalidQuantityError:
        print('bad quantity')
        return None

Every function catches the error and repeats the same reporting logic, so the handling is spread across the program and the reporting is inconsistent.

After
def validate_quantity(quantity):
    if quantity <= 0:
        raise InvalidQuantityError(quantity)
    return quantity

def process_order(quantity):
    validate_quantity(quantity)
    return 'accepted'

def main():
    try:
        process_order(-3)
    except InvalidQuantityError as err:
        print('Please enter a quantity greater than zero. Rejected:', err.quantity)

Only main catches the error, the wording of the response lives in one place, and the functions below it simply describe what went wrong by raising the exception.

Choosing Where Catching Belongs

The third principle is to not try to catch every exception in every function. The guidance does not tell you to never catch exceptions. It tells you not to catch them everywhere. Catching is appropriate at a point where the code knows enough to respond sensibly. A meaningful response may mean recovering, reporting to the user, or cleaning up. Where no such response is available, letting the exception travel to a function that has one is the cleaner choice.

Best practice

Before adding a catch, ask what this function would actually do with the error. If the honest answer is nothing, let the exception propagate to a caller that can recover, report, or clean up.

Mistakes Beginners Make

  • Common mistake

    Wrapping every function in a handler so nothing can escape

    What it looks like

    Each function in the chain has its own try block that catches and prints the same message.

    Why it fails

    It looks diligent, but it moves the handling decision away from the place that knows what to do, and it spreads the logic across the program.

    Do this instead

    Catch only at a point with a meaningful response, such as the top-level caller, and let other exceptions propagate.

  • Common mistake

    Using a generic built-in type for every error

    What it looks like

    Raising a general value error for every bad input in the program.

    Why it fails

    A generic type says little about the situation that produced it, so a handler cannot tell what went wrong from the type alone.

    Do this instead

    Define a purpose-designed user-defined exception type for each kind of problem, and store the details the handler needs inside it.

  • Common mistake

    Deciding the error strategy at the end of the project

    Why it fails

    Handling code then appears wherever a problem happens to surface, and the overall strategy becomes hard to see.

    Do this instead

    Decide how errors are represented, detected, and handled as part of shaping the program's structure, before the modules are written.

  • Common mistake

    Catching an exception only to discard it silently when no response exists

Practice: Locate the Catch

EASY

A program has a parse_record function, which calls read_field, which detects a missing field and throws MissingFieldError. The program's main function is responsible for showing an error message and continuing with the next record. Which functions should catch MissingFieldError, and which should let it propagate? Justify each choice using the principle of catching only where a meaningful response exists.

Hints
  • Ask which function detects the problem and whether it can respond to it meaningfully.
  • Ask which function is responsible for the user-facing response.
  • A function that cannot recover or report should let the exception travel to a caller that can.

Key Takeaways

  • An error-handling strategy is part of the program's structure and should be developed early, deciding how errors are represented, detected, and handled.
  • Use purpose-designed user-defined exception types rather than generic built-in types, so each error names its problem and can carry the details a handler needs.
  • Do not catch every exception in every function. Catch where the code has a meaningful response, such as recovering, reporting, or cleaning up, and let other exceptions propagate to a caller that can act on them.
  • Catching everywhere spreads handling logic across the program and makes the overall strategy hard to see.

Related concepts

More concepts starting with D