Designing Modular Program Structure
Develop an error-handling strategy early in a design, as part of deciding the program's structure.
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?
Reveal 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.
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.
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.
- 1Detect
validate_quantitychecks the quantity, finds it is not greater than zero, and throwsInvalidQuantityErrorcarrying the value -3. - 2Pass through without catching
process_ordercallsvalidate_quantitybut has no meaningful response to a rejected quantity yet, so it does not catch the error. The exception leavesprocess_orderand continues up the chain. - 3Handle 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.
The error is raised in validate_quantity, passes through process_order, and is handled only in main, the caller that knows how to respond.
Please enter a quantity greater than zero. Rejected: -3InvalidQuantityErroris a purpose-designed type that carries the rejected valuevalidate_quantitydetects the problem and throws; it does not try to recoverprocess_orderhas 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.
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 NoneEvery function catches the error and repeats the same reporting logic, so the handling is spread across the program and the reporting is inconsistent.
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.
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 likeEach function in the chain has its own try block that catches and prints the same message.
Why it failsIt 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 insteadCatch 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 likeRaising a general value error for every bad input in the program.
Why it failsA generic type says little about the situation that produced it, so a handler cannot tell what went wrong from the type alone.
Do this insteadDefine 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 failsHandling code then appears wherever a problem happens to surface, and the overall strategy becomes hard to see.
Do this insteadDecide 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
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.