This chapter discusses high-level design principles for building good software in Python. It argues that code is the design, meaning good design isn’t separate from how you write your code. The goal of clean code is not just about formatting, but about making software as robust as possible, meaning it has fewer defects and makes any defects that do occur very obvious.

Good quality software should be built using these design principles, which act as tools for creating robust, maintainable, reusable, and efficient code. Not all principles apply everywhere, and sometimes different principles (like “Design by Contract” vs. “Defensive Programming”) offer different ways of thinking about problems.

The main goals of this chapter are to:

  • Understand robust software.
  • Learn to handle bad data.
  • Design maintainable and extensible software.
  • Design reusable software.
  • Write effective code that keeps development teams productive.

Design by Contract (DbC)

Some parts of your software are designed to be used by other parts of your code, not directly by users. When different parts (components or layers) of your application interact, they need to know what to expect from each other. This interaction is usually through an Application Programming Interface (API).

  • When you write functions, classes, or methods for a component, they often need certain conditions to be met to work correctly. If these conditions aren’t met, the code might crash.
  • Also, the code calling your component (the “client”) expects a specific result. If your component doesn’t provide it, that’s a defect.

For example, if a function expects numbers but gets text, it shouldn’t just try to run and fail later; it should stop immediately because it was called incorrectly. This kind of error shouldn’t happen silently.

While you should always document what your API expects (input, output, side effects), documentation alone can’t stop bad behavior at runtime. This is where the idea of a contract comes in.

Design by Contract (DbC) means that instead of just hoping each part of the code behaves correctly, both the calling code and the called code agree on a “contract.” If anyone breaks this contract, an exception is immediately raised, clearly stating what went wrong and why the code cannot continue.

A contract enforces rules for how software components communicate. It mainly includes:

  • Preconditions: These are checks performed before a function runs. They ensure that all necessary conditions are met for the function to proceed. This usually means checking if the input data is valid (e.g., initialized objects, non-empty values). These checks place an obligation on the caller; the caller must meet these conditions.
  • Postconditions: These are validations done after a function has finished running and returned. They check that the function has delivered what the caller expects (e.g., the returned object has certain properties, or the system state has changed as expected).
  • Invariants (optional): Things that stay constant while the function’s code is running, indicating correct logic. These are usually documented.
  • Side Effects (optional): Any additional changes your code makes (e.g., to a database or a file), usually documented.

While all these elements form the contract, only preconditions and postconditions are usually enforced directly in the code at a low level.

The main reason for using DbC is to easily find errors. If a contract is broken, you know exactly where the problem is and who is responsible.

  • If a precondition fails, it’s a defect in the client code (the caller) because it provided invalid input.
  • If a postcondition fails, it’s a problem in the component itself (the function/method that was called) because it didn’t deliver what it promised.

This helps prevent critical parts of your code from running with wrong assumptions, making error identification and fixing much faster.

Preconditions

Preconditions are all the guarantees a function or method expects to receive before it starts working. This often means checking that:

  • Data is properly formed (e.g., objects are initialized, values are not empty).
  • In Python, due to its dynamic typing, you might also check for the exact type of data or properties of objects, beyond what static type checkers like mypy can do.

When it comes to where to put these validation checks, DbC typically prefers a “demanding approach.” This means the function itself is responsible for validating all the data it receives before running its main logic. This is generally the safest and most common practice for robustness.

Important: Avoid duplicating precondition checks. Either the client checks, or the function checks, but not both (related to the DRY - Don’t Repeat Yourself principle).

Postconditions

Postconditions are responsible for checking the state after the method or function has returned. Assuming the function was called correctly (preconditions met), postconditions guarantee that certain properties are true after its execution.

The goal is to validate everything a client might expect. If the function runs successfully and postconditions pass, then any client using the returned object should have no problems, as the contract has been fulfilled.

Pythonic Contracts

While there isn’t a built-in “contract” feature in Python, you can implement DbC by:

  • Adding control mechanisms (checks) in your methods, functions, and classes.
  • If a check fails, raise an exception, typically RuntimeError or ValueError. For more specific issues, creating custom exceptions is a good idea.
  • It’s good practice to keep the code for preconditions, postconditions, and the core logic separate (e.g., using smaller helper functions or decorators).

Design by Contract – Conclusions

The main benefit of DbC is its ability to clearly identify the source of a problem when something goes wrong at runtime. It helps pinpoint exactly which part of the code is broken and what part of the contract was violated.

As a result, your code becomes more robust. Each component enforces its own rules, and the program can be considered correct as long as these rules are followed. It also clarifies the program’s structure by explicitly stating what each function expects and what it guarantees.

Implementing DbC does require extra work (writing the contract logic and potentially unit tests for them). However, the improved quality and ease of debugging often pay off significantly in the long run, especially for critical parts of your application.

Crucially, when designing contracts, make sure they add real value. Don’t just check basic data types (static analysis tools like mypy handle that better). Instead, focus on checking meaningful properties of objects, their conditions, and complex business rules.

Defensive Programming

Defensive programming is a different approach from Design by Contract (DbC). Instead of strict “contracts” that break the program if conditions aren’t met, defensive programming focuses on making each part of the code (functions, methods, objects) protect itself against invalid inputs.

Defensive programming can be combined with other design principles, even DbC. Its main ideas are:

  1. How to handle errors that you expect to happen.
  2. How to deal with errors that should never occur (impossible conditions).

The first type involves error handling, and the second type uses assertions.

Error Handling

We use error handling for situations that we anticipate might cause problems, especially with data input. The goal is to respond gracefully to these expected errors. This means trying to either continue the program or decide to stop if the error is too severe.

Some common approaches for error handling include:

  • Value Substitution
  • Error Logging
  • Exception Handling

We’ll focus on value substitution and exception handling, as they offer more interesting ways to manage errors. Error logging is always a good complementary practice, often used when other options aren’t available.

Value Substitution

In some cases, if there’s an error and the software might produce a wrong value or fail, you can replace the incorrect result with a safer value. This is called value substitution. The substitute value should be harmless (e.g., a default, a known constant, zero if the result is added to a sum).

When is it useful? Value substitution is only safe when the substituted value won’t disrupt the program. It’s a trade-off between robustness (the program doesn’t fail) and correctness (the program always gives the exact right answer). For critical or sensitive applications, providing an incorrect result (even a “safe” one) might not be acceptable; in such cases, you choose correctness over just keeping the program running.

A safer form of this is using default values for missing data. For example:

  • Python dictionaries have a get() method that allows you to specify a default value if a key isn’t found: configuration.get("dbhost", "localhost").
  • The os.getenv() function for environment variables also allows default values: os.getenv("DBHOST", "localhost").
  • You can define default values for parameters in your own functions: def connect_database(host="localhost", port=5432):.

Caution: While replacing missing parameters with defaults is generally fine, substituting erroneous data with “close” legal values can be risky because it might hide real problems.

Exception Handling

When input data is incorrect or missing, sometimes you can fix it (as with value substitution). But in other cases, it’s better to stop the program than to let it continue with bad data. This is similar to a violated precondition in DbC, where you raise an exception.

However, errors can also come from external components (like a database or network). If your function encounters a problem from an external source, it should communicate this clearly. The mechanism for this is an exception.

ImportantExceptions should be used to announce truly exceptional situations, not to control normal program flow or business logic.

  • Using exceptions for regular business logic makes your code harder to read and understand. It can create a “go-to” like flow that jumps across different parts of your code, breaking encapsulation.
  • It becomes difficult to tell between real, unexpected errors and expected scenarios you’re trying to handle with exceptions.

RuleDo not use exceptions like a “go-to” for business logic. Raise exceptions only when something genuinely goes wrong that callers need to be aware of.

Keep in mind that exceptions weaken encapsulation. The more exceptions a function can raise, the more the calling function needs to know about its internal workings. If a function raises too many different types of exceptions, it might be a sign that it’s doing too much and should be broken into smaller, more focused functions.

Handling Exceptions at the Right Level of Abstraction

Exceptions should be handled (or raised) at a level that matches the logic of the code. Avoid mixing different types of errors from different levels of abstraction in one place.

For example, in a deliver_event method that connects, decodes, and sends data:

class DataTransport:
    def deliver_event(self, event: Event):
        try:
            self.connect() # Might raise ConnectionError
            data = event.decode() # Might raise ValueError
            self.send(data)
        except ConnectionError as e: # Handled here
            logger.info("connection error detected: %s", e)
            raise
        except ValueError as e: # Handled here
            logger.error("%r contains incorrect data: %s", event, e)
            raise

Here, ValueError (related to decoding data) and ConnectionError (related to network connection) are handled in the same deliver_event method, even though they belong to different concerns.

Better approach: Separate the responsibilities:

  • ConnectionError should be handled inside the connect method (or a helper function like connect_with_retry). This way, connection-related logic (like retries) stays together.
  • ValueError should be handled where the data decoding happens, perhaps in a send method that directly decodes before sending.

By separating logic into different methods/functions, you ensure that each type of exception is handled at its appropriate level of abstraction, making the code cleaner, more focused, and easier to understand and maintain.

Do Not Expose Tracebacks to End Users

This is a security consideration. While it’s vital to log detailed exception information (including tracebacks) for debugging, never show this information directly to end-users.

  • Tracebacks contain sensitive details about your code (file paths, variable names, logic) that can be exploited by attackers.
  • They also reveal intellectual property.

If an error occurs, provide generic messages to users (e.g., “Something went wrong,” “Page not found”). Log the full details internally for your development team.

Avoid Empty except Blocks

One of the worst Python anti-patterns is an empty except block:

try:
    process_data()
except: # Catches ALL exceptions and silently passes
    pass

Why it’s bad:

  • It silently hides all errors, even serious bugs in your logic, making your code very hard to debug and maintain.
  • It goes against “The Zen of Python” which states that “Errors should never pass silently.”

Instead, do one or both of these:

  1. Catch a more specific exception: Instead of a bare except:, catch specific error types like AttributeError or KeyError. This makes your code’s intent clear and allows other unexpected exceptions (which are likely bugs) to still be raised.
  2. Perform actual error handling: Even if you catch a specific exception, do something meaningful in the exceptblock, like:
    • Logging the exception (use logger.exception for full details).
    • Returning a default value (value substitution after an error).
    • Raising a different, more appropriate exception.

If you truly intend to ignore specific exceptions, use contextlib.suppress() for clarity:

import contextlib
with contextlib.suppress(KeyError): # Explicitly ignores only KeyError
    process_data()

Again, avoid contextlib.suppress(Exception) as it has the same negative effect as a bare except:.

Include the Original Exception

When you catch an exception and decide to raise a different one (perhaps a custom exception relevant to your domain), it’s crucial to include the original exception’s information.

Use the raise <new_exception> from <original_exception> syntax (PEP-3134). This:

  • Embeds the original traceback into the new exception.
  • Sets the original exception in the __cause__ attribute of the new one.

Example:

class InternalDataError(Exception):
    """An exception with the data of our domain problem."""
 
def process(data_dictionary, record_id):
    try:
        return data_dictionary[record_id]
    except KeyError as e:
        raise InternalDataError("Record not present") from e # Includes original KeyError

This ensures that even when you “wrap” exceptions, all the valuable debugging information about the root cause is preserved.