Chapter 7: Error Handling
clean-code error-handling exceptions null-safety
Status: Notes complete
Difficulty: Medium
Time to complete: ~45 min read
Overview
Error handling is a necessary part of programming — things can go wrong at runtime, and code must respond correctly. But error handling done poorly is one of the most powerful forces that obscures the logic of a program.
The core principle of this chapter: error handling is important, but if it obscures logic, it is wrong. The goal is to separate the concern of error handling from the concern of the main algorithm, and to provide enough context to understand what went wrong and why.
This chapter is about the discipline of writing error-handling code that is itself clean:
- Expressive about what went wrong
- Decoupled from normal-path logic
- Honest about the domain it lives in
- Safe — meaning it does not let the system continue in an inconsistent state
Related principles: ch03-functions (single responsibility applies to error paths too), ch06-objects-and-data-structures (Special Case Objects), ch09-unit-tests (tests must cover exception paths).
The Problem: What Bad Code Looks Like
Error handling written without discipline produces code that:
- Scatters error checks between every line of real logic
- Forces callers to check return codes they might forget
- Hides the real intent behind a wall of
ifstatements - Propagates null values silently through the call stack
- Uses generic exception messages like
"Error occurred"that provide no diagnostic value - Throws exceptions so broadly that callers can’t distinguish what went wrong
// BAD: error-handling noise obscures the intent
public boolean processOrder(Order order) {
if (order == null) return false;
boolean validated = validateOrder(order);
if (!validated) return false;
boolean charged = chargeCustomer(order);
if (!charged) return false;
boolean shipped = shipOrder(order);
if (!shipped) return false;
return true;
}In the BAD example above, the actual intent — validate, charge, ship — is buried under if (!x) return false checks. The reader must mentally filter out all the error path to see what this method actually does.
Core Principles
Principle 1: Use Exceptions Rather Than Return Codes
Why this rule exists: Before modern exception mechanisms, languages like C required callers to check return codes after every call. This cluttered the calling code with control flow that had nothing to do with the algorithm. The caller can forget to check — and the error is silently swallowed. Exceptions separate the error-handling concern from the main logic; the calling code stays focused on what it wants to do.
Bad — Java (return codes):
// BAD: caller must check error codes; intent is buried
public class DeviceController {
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// Check the state of the device
if (handle != DeviceHandle.INVALID) {
// Save the device status to the record field
retrieveDeviceRecord(handle);
// If not suspended, shut down
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.error("Device suspended. Unable to shut down");
}
} else {
logger.error("Invalid handle for: " + DEV1.toString());
}
}
}Good — Java (exceptions):
// GOOD: the try block reads as a clear statement of intent; error handling is separate
public class DeviceController {
public void sendShutDown() {
try {
tryToShutDown();
} catch (InvalidDeviceException e) {
logger.error("Shutdown failed for device: " + e.getDeviceId(), e);
}
}
private void tryToShutDown() throws InvalidDeviceException {
DeviceHandle handle = getHandle(DEV1);
retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceId id) throws InvalidDeviceException {
DeviceHandle handle = lookupHandle(id);
if (handle == DeviceHandle.INVALID) {
throw new InvalidDeviceException("Cannot find device: " + id);
}
return handle;
}
}C++ equivalent:
// GOOD
class DeviceController {
public:
void sendShutDown() {
try {
tryToShutDown();
} catch (const InvalidDeviceException& e) {
logger.error("Shutdown failed: " + std::string(e.what()));
}
}
private:
void tryToShutDown() {
DeviceHandle handle = getHandle(DEV1);
retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
DeviceHandle getHandle(const DeviceId& id) {
DeviceHandle handle = lookupHandle(id);
if (!handle.isValid()) {
throw InvalidDeviceException("Cannot find device: " + id.toString());
}
return handle;
}
};Python equivalent:
# GOOD
class DeviceController:
def send_shut_down(self) -> None:
try:
self._try_to_shut_down()
except InvalidDeviceError as e:
logger.error(f"Shutdown failed for device: {e.device_id}", exc_info=e)
def _try_to_shut_down(self) -> None:
handle = self._get_handle(DEV1)
self._retrieve_device_record(handle)
self._pause_device(handle)
self._clear_device_work_queue(handle)
self._close_device(handle)
def _get_handle(self, device_id: DeviceId) -> DeviceHandle:
handle = self._lookup_handle(device_id)
if not handle.is_valid():
raise InvalidDeviceError(
f"Cannot find device: {device_id}", device_id=device_id
)
return handlePrinciple 2: Write Your Try-Catch-Finally Statement First
Why this rule exists: When you write code that could throw exceptions, the try-catch-finally defines the transaction scope of that operation — it declares: “if anything goes wrong in here, here is the recovery”. Writing this structure first forces you to think about what invariants must hold even after failure. It also makes it natural to write tests that verify exception behavior. TDD encourages this pattern: first write a test that expects an exception, then write the try-catch to make it pass.
Bad — Java:
// BAD: exception handling is an afterthought, added after the fact
public List<RecordedGrade> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
// ... lots of logic ...
stream.close();
} catch (Exception e) {
throw new StorageException("Retrieval error", e);
}
return new ArrayList<>();
}Good — Java (try-catch written first, then logic filled in):
// GOOD: start with try-catch; fill in the body; test that exceptions propagate correctly
public List<RecordedGrade> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
ObjectInputStream ois = new ObjectInputStream(stream);
List<RecordedGrade> grades = (List<RecordedGrade>) ois.readObject();
ois.close();
return grades;
} catch (FileNotFoundException e) {
throw new StorageException(
"Grade file not found: " + sectionName, e
);
} catch (IOException | ClassNotFoundException e) {
throw new StorageException(
"Failed to read grades from: " + sectionName, e
);
}
}C++ equivalent:
// GOOD
std::vector<RecordedGrade> retrieveSection(const std::string& sectionName) {
std::ifstream file(sectionName, std::ios::binary);
if (!file.is_open()) {
throw StorageException("Grade file not found: " + sectionName);
}
try {
return deserializeGrades(file);
} catch (const std::runtime_error& e) {
throw StorageException(
"Failed to read grades from: " + sectionName + ": " + e.what()
);
}
}Python equivalent:
# GOOD
def retrieve_section(section_name: str) -> list[RecordedGrade]:
try:
with open(section_name, "rb") as f:
return pickle.load(f)
except FileNotFoundError:
raise StorageError(f"Grade file not found: {section_name}")
except (pickle.UnpicklingError, EOFError) as e:
raise StorageError(
f"Failed to read grades from: {section_name}"
) from ePrinciple 3: Use Unchecked Exceptions
Why this rule exists: Java introduced checked exceptions as a well-intentioned feature — if a method can throw, its callers must declare that they handle it. In practice, checked exceptions violate the Open/Closed Principle: if you add a checked exception deep in the call stack, you must update every method signature between the throw site and the catch site. This cascades: a change in a low-level method forces changes in all its callers and their callers. The benefit (explicit contract) rarely outweighs the cost (fragile call chains). C# and Kotlin recognized this and did not include checked exceptions. Python has no checked exceptions at all.
Bad — Java (checked exception cascading):
// BAD: every intermediate method must declare the checked exception
// A change to IOException in readConfig() forces changes in loadSettings() and initialize()
public void initialize() throws IOException { // forced declaration
loadSettings();
}
public void loadSettings() throws IOException { // forced declaration
String config = readConfig("app.properties");
parseSettings(config);
}
public String readConfig(String path) throws IOException { // origin
return Files.readString(Paths.get(path));
}Good — Java (unchecked exception; wrap checked at the boundary):
// GOOD: wrap the checked IOException into an unchecked ConfigurationException at the source
public void initialize() {
loadSettings(); // no declaration needed
}
public void loadSettings() {
String config = readConfig("app.properties"); // no declaration needed
parseSettings(config);
}
public String readConfig(String path) {
try {
return Files.readString(Paths.get(path));
} catch (IOException e) {
throw new ConfigurationException(
"Cannot read config file: " + path, e
);
}
}C++ equivalent:
// C++ has no checked exceptions — all exceptions are unchecked by default
std::string readConfig(const std::string& path) {
std::ifstream file(path);
if (!file.is_open()) {
throw ConfigurationException("Cannot read config file: " + path);
}
return std::string(std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>());
}Python equivalent:
# Python has no checked exceptions — all are unchecked
def read_config(path: str) -> str:
try:
with open(path, "r") as f:
return f.read()
except OSError as e:
raise ConfigurationError(f"Cannot read config file: {path}") from ePrinciple 4: Provide Context with Exceptions
Why this rule exists: A stack trace tells you where an exception was thrown, but not why. Without context in the exception message, you must reproduce the failure or add logging just to understand what happened. Good error messages make post-mortem diagnosis fast. Include: the operation being performed, the entity involved, and the context (environment, IDs, state).
Bad — Java:
// BAD: exception message conveys nothing useful
throw new RuntimeException("Failed");
// BAD: slightly better but still missing context
throw new FileNotFoundException("File not found");Good — Java:
// GOOD: operation + entity + context
throw new ConfigurationException(
"Cannot read config file '" + configPath + "' " +
"in environment '" + environment + "'. " +
"Ensure the file exists and is readable."
);
// GOOD: payment processing context
throw new PaymentException(
String.format(
"Stripe charge failed for orderId=%s, customerId=%s, amount=%d cents. " +
"Stripe error: %s",
order.getId(), customer.getId(), order.getAmountCents(), stripeError.getMessage()
)
);C++ equivalent:
// GOOD
throw ConfigurationException(
"Cannot read config file '" + configPath + "' "
"in environment '" + environment + "'. "
"Ensure the file exists and is readable."
);Python equivalent:
# GOOD
raise ConfigurationError(
f"Cannot read config file '{config_path}' "
f"in environment '{environment}'. "
f"Ensure the file exists and is readable."
)
# GOOD: payment context
raise PaymentError(
f"Stripe charge failed for order_id={order.id}, "
f"customer_id={customer.id}, amount={order.amount_cents} cents. "
f"Stripe error: {stripe_error}"
)Principle 5: Define Exception Classes by Caller Needs
Why this rule exists: When you define exception hierarchies, the question is not “what type is this error at the source?” but “how will the caller handle it?” If a caller handles three different library exceptions in exactly the same way (log and retry), there is no reason for three different catch clauses. Wrapping third-party API exceptions in a single local exception class reduces coupling — if you change the third-party library, your callers are unaffected.
This is sometimes called a Port/Adapter pattern at the exception level: your application defines what errors mean in its own terms.
Bad — Java:
// BAD: three different catches that all do the same thing
// Plus: caller is coupled to the ACMEPort (third-party) exception types
ACMEPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.warn("DeviceResponseException opening port 12", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.warn("ATM1212UnlockedException opening port 12", e);
} catch (GMXError e) {
reportPortError(e);
logger.warn("GMXError opening port 12", e);
} finally {
// ...
}Good — Java (wrap third-party in a local exception):
// GOOD: LocalPort wraps ACMEPort; all exceptions become PortDeviceFailure
public class LocalPort {
private final ACMEPort innerPort;
public LocalPort(int portNumber) {
innerPort = new ACMEPort(portNumber);
}
public void open() {
try {
innerPort.open();
} catch (DeviceResponseException e) {
throw new PortDeviceFailure("Device did not respond on port " + portNumber, e);
} catch (ATM1212UnlockedException e) {
throw new PortDeviceFailure("Port " + portNumber + " is locked", e);
} catch (GMXError e) {
throw new PortDeviceFailure("GMX error on port " + portNumber, e);
}
}
}
// Caller is now clean and decoupled from the third-party library:
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportPortError(e);
logger.warn("Port failure", e);
} finally {
// ...
}C++ equivalent:
// GOOD
class LocalPort {
std::unique_ptr<ACMEPort> innerPort;
public:
explicit LocalPort(int portNumber)
: innerPort(std::make_unique<ACMEPort>(portNumber)) {}
void open() {
try {
innerPort->open();
} catch (const DeviceResponseException& e) {
throw PortDeviceFailure("Device did not respond: " + std::string(e.what()));
} catch (const ATM1212UnlockedException& e) {
throw PortDeviceFailure("Port is locked: " + std::string(e.what()));
} catch (const GMXError& e) {
throw PortDeviceFailure("GMX error: " + std::string(e.what()));
}
}
};Python equivalent:
# GOOD
class LocalPort:
def __init__(self, port_number: int) -> None:
self._inner_port = ACMEPort(port_number)
self._port_number = port_number
def open(self) -> None:
try:
self._inner_port.open()
except DeviceResponseException as e:
raise PortDeviceFailure(
f"Device did not respond on port {self._port_number}"
) from e
except ATM1212UnlockedException as e:
raise PortDeviceFailure(
f"Port {self._port_number} is locked"
) from e
except GMXError as e:
raise PortDeviceFailure(
f"GMX error on port {self._port_number}"
) from e
# Caller:
port = LocalPort(12)
try:
port.open()
except PortDeviceFailure as e:
report_port_error(e)
logger.warning("Port failure", exc_info=e)Principle 6: Define the Normal Flow (Special Case Pattern)
Why this rule exists: Sometimes exception handling is an awkward fit. If the calling code always catches an exception and does something reasonable in response, this is a sign that you’ve modeled “absence” as an exceptional case when it’s actually a normal case. The Special Case Pattern (Fowler) creates a class or configures an object so that it handles the special case on your behalf. The calling code never sees the exception — it just receives a normal result that happens to represent the special case.
Bad — Java (exception for a normal business case):
// BAD: MealExpensesNotFound is a normal case (employee didn't submit meals),
// not an error. The exception forces the caller to handle boilerplate.
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getId());
total += expenses.getTotal();
} catch (MealExpensesNotFoundException e) {
total += getMealPerDiem();
}Good — Java (Special Case Object):
// GOOD: DAO returns a PerDiemMealExpenses object when no meals found.
// The Special Case Object encapsulates the default behavior.
MealExpenses expenses = expenseReportDAO.getMeals(employee.getId());
total += expenses.getTotal(); // always works; no exception needed
// Implementation:
public class ExpenseReportDAO {
public MealExpenses getMeals(String employeeId) {
MealExpenses expenses = findByEmployeeId(employeeId);
if (expenses == null) {
return new PerDiemMealExpenses(); // Special Case Object
}
return expenses;
}
}
public class PerDiemMealExpenses implements MealExpenses {
@Override
public int getTotal() {
return PER_DIEM_ALLOWANCE; // returns the default per-diem rate
}
}C++ equivalent:
// GOOD
class PerDiemMealExpenses : public MealExpenses {
public:
int getTotal() const override {
return PER_DIEM_ALLOWANCE;
}
};
// DAO:
std::unique_ptr<MealExpenses> ExpenseReportDAO::getMeals(
const std::string& employeeId) {
auto expenses = findByEmployeeId(employeeId);
if (!expenses) {
return std::make_unique<PerDiemMealExpenses>(); // Special Case
}
return expenses;
}
// Caller — clean, no exception handling needed:
auto expenses = dao.getMeals(employee.getId());
total += expenses->getTotal();Python equivalent:
# GOOD
from abc import ABC, abstractmethod
class MealExpenses(ABC):
@abstractmethod
def get_total(self) -> int: ...
class PerDiemMealExpenses(MealExpenses):
"""Special Case: returned when no expense report is submitted."""
def get_total(self) -> int:
return PER_DIEM_ALLOWANCE
class ExpenseReportDAO:
def get_meals(self, employee_id: str) -> MealExpenses:
expenses = self._find_by_employee_id(employee_id)
if expenses is None:
return PerDiemMealExpenses() # Special Case Object
return expenses
# Caller — no exception handling needed:
expenses = dao.get_meals(employee.id)
total += expenses.get_total()Principle 7: Don’t Return Null
Why this rule exists: Returning null is an invitation to NullPointerException. Every caller of a null-returning method must remember to check — and if even one doesn’t, the program crashes (or worse, silently corrupts data). The alternatives are superior in nearly every case: throw a meaningful exception if the absence is truly exceptional, or return a Special Case Object / Optional if absence is a normal outcome.
Collections.emptyList() is a canonical example: instead of returning null when a list is empty, return an empty list. The caller can iterate, stream, or map over it without a null check.
Bad — Java:
// BAD: caller must remember to null-check; a single miss = NullPointerException
public List<Employee> getEmployees() {
if (/* no employees in DB */) {
return null; // BAD
}
return employeeList;
}
// BAD caller:
List<Employee> employees = getEmployees();
if (employees != null) {
for (Employee e : employees) {
totalPay += e.getPay();
}
}Good — Java (return empty list or Optional):
// GOOD: return empty list — caller needs no null check
public List<Employee> getEmployees() {
if (/* no employees in DB */) {
return Collections.emptyList(); // GOOD
}
return employeeList;
}
// GOOD caller — no null check needed:
List<Employee> employees = getEmployees();
for (Employee e : employees) {
totalPay += e.getPay();
}
// GOOD: use Optional<T> when a single value may be absent
public Optional<Employee> findById(String id) {
Employee emp = db.lookup(id);
return Optional.ofNullable(emp);
}
// Caller:
Optional<Employee> emp = findById("EMP-42");
emp.ifPresent(e -> totalPay += e.getPay());
// or:
Employee emp = findById("EMP-42")
.orElseThrow(() -> new EmployeeNotFoundException("EMP-42"));C++ equivalent (std::optional):
// GOOD: return empty vector instead of nullptr
std::vector<Employee> getEmployees() {
if (/* no employees */) {
return {}; // empty vector — no null check needed
}
return employeeList;
}
// GOOD: std::optional for a single nullable value (C++17)
#include <optional>
std::optional<Employee> findById(const std::string& id) {
Employee* emp = db.lookup(id);
if (emp == nullptr) {
return std::nullopt;
}
return *emp;
}
// Caller:
auto emp = findById("EMP-42");
if (emp.has_value()) {
totalPay += emp->getPay();
}
// or with value_or:
Employee defaultEmp = findById("EMP-42").value_or(Employee::unknown());Python equivalent (Optional[T]):
# GOOD: return empty list instead of None
from typing import Optional
def get_employees() -> list[Employee]:
if not employee_list:
return [] # GOOD — caller iterates directly
return employee_list
# GOOD: Optional[T] signals that absence is possible
def find_by_id(employee_id: str) -> Optional[Employee]:
emp = db.lookup(employee_id)
return emp # None if not found — but now the type signature tells callers
# Caller:
emp = find_by_id("EMP-42")
if emp is not None:
total_pay += emp.get_pay()
# Better with a default:
emp = find_by_id("EMP-42") or Employee.unknown()
total_pay += emp.get_pay()Principle 8: Don’t Pass Null
Why this rule exists: Passing null as a method argument is even more insidious than returning null. When a method receives null where it doesn’t expect it, one of three things happens: (1) it crashes with a NullPointerException far from the call site, (2) it silently computes wrong results, or (3) the method defensively adds null checks that clutter it with logic that only exists to compensate for the caller’s mistake. The cleanest approach: forbid null in method parameters by design. Use Objects.requireNonNull, @NonNull annotations, or simple precondition checks at method entry.
Bad — Java:
// BAD: null passed to a calculator; error is silent or confusing
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x - p1.x) * 1.5; // NullPointerException if p1 or p2 is null
}
}
// Somewhere in the codebase:
calculator.xProjection(null, new Point(12, 13)); // crashes with NullPointerExceptionGood — Java (assert invariants at the boundary):
// GOOD: reject null explicitly at the entry point
import java.util.Objects;
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
Objects.requireNonNull(p1, "p1 must not be null");
Objects.requireNonNull(p2, "p2 must not be null");
return (p2.x - p1.x) * 1.5;
}
}
// Even better: use @NonNull or @NotNull annotations (Lombok, JetBrains, etc.)
public double xProjection(@NonNull Point p1, @NonNull Point p2) {
return (p2.x - p1.x) * 1.5;
}C++ equivalent:
// GOOD: use references instead of pointers to make null impossible
class MetricsCalculator {
public:
// References cannot be null — null is structurally impossible
double xProjection(const Point& p1, const Point& p2) {
return (p2.x - p1.x) * 1.5;
}
};
// If you must use pointers, assert at entry:
double xProjection(const Point* p1, const Point* p2) {
assert(p1 != nullptr && "p1 must not be null");
assert(p2 != nullptr && "p2 must not be null");
return (p2->x - p1->x) * 1.5;
}Python equivalent:
# GOOD: type hints + runtime check
from typing import assert_never
class MetricsCalculator:
def x_projection(self, p1: Point, p2: Point) -> float:
if p1 is None or p2 is None:
raise ValueError("p1 and p2 must not be None")
return (p2.x - p1.x) * 1.5
# Even cleaner: use Pydantic or dataclasses for validated input
# or enable mypy strict null checking to catch this at type-check timeComparison / Summary Table
Error Handling Strategies
| Strategy | Language Support | Readability | Coupling | Use When |
|---|---|---|---|---|
| Return codes | All (C, C, Java, Python) | Low — caller must check after every call | High — error checking propagates through call chain | C-style APIs, extremely performance-critical inner loops |
| Checked exceptions | Java only | Medium — signatures document throws, but clutter | High — every intermediate method must declare | Rarely — avoid in new Java code; only at public API boundaries |
| Unchecked exceptions | Java, C++, Python | High — main logic is uncluttered | Low — intermediate methods are unaffected | General error handling in application code |
| Optional / Result | Java (Optional<T>), Rust (Result<T,E>), Kotlin (Result<T>) | High — type system communicates absence | Low — caller chain unchanged | When absence is a normal outcome, not an error |
| Special Case Pattern | All | Very high — caller sees no null, no exception | None — no error-handling code in caller | When a default behavior exists for the “missing” case |
Null Handling Strategies
| Approach | Language | How It Works | Null-Safety Level |
|---|---|---|---|
Collections.emptyList() | Java | Return empty collection instead of null | High |
Optional<T> | Java 8+ | Wrapper that forces callers to handle absence | High |
std::optional<T> | C++17 | Value-or-empty wrapper | High |
| References (not pointers) | C++ | References cannot be null by design | Very high |
Optional[T] from typing | Python | Type hint only — runtime is still None | Medium (with mypy) |
@NonNull / @NotNull annotations | Java | Annotation-based; enforced by tools (Lombok, IDEs) | High with tooling |
Objects.requireNonNull | Java | Runtime check at method boundary | High at boundary |
When to Apply / Common Exceptions
Apply these rules when:
- Writing any method that can fail due to external state (I/O, network, database)
- Designing a public API that will be consumed by other teams or modules
- Wrapping a third-party library (always wrap; define your own exception types)
- Returning collections or objects that may not exist
Common exceptions and nuances:
- Performance-critical inner loops: Return codes can be appropriate in tight loops where exceptions carry unacceptable overhead (e.g., parsing 10M records). Use them deliberately, not by default.
- Frameworks with checked exception contracts: Some Java frameworks (Spring Data, JDBC) use checked exceptions as part of their contracts. Follow the framework’s conventions within that context; wrap at the service layer boundary.
- Python’s
Noneas “not found”: Python idiom often usesNoneto indicate absence, and this is acceptable when the function name makes the possibility explicit (e.g.,dict.get(key)returnsNoneif not found — well-known convention). The rule is: don’t surprise the caller. - Optional is not always better than null:
Optional<T>in Java adds overhead; don’t use it for every field. Use it for method return values that may be absent; don’t use it as a field type in a class. - Special Case Pattern adds classes: Each special case is a new class. In a large codebase this adds up. Use it when the default behavior is genuinely stable and well-defined.
Checklist
When reviewing error handling in your code, ask:
- Does every method with a try-catch have the try-catch written before the logic was filled in?
- Are return codes used anywhere that could be replaced with exceptions?
- Are checked Java exceptions declared across more than 2 stack frames? If so, convert to unchecked.
- Do all exception messages include: the operation, the entity, and the context?
- Are third-party library exceptions wrapped in application-level exception types?
- Is there a
try { ... } catch (Exception e) {}that swallows exceptions silently? - Does any method return null where it could return an empty collection or Optional?
- Does any call site pass null to a method? Are those parameters rejected at entry?
- Are any catch clauses handling normal business cases (absence, not-found)? If so, use Special Case Pattern.
- Are exception types named after the caller’s perspective, not the cause?
Key Takeaways
-
Separate concerns: Error-handling code belongs in separate methods and catch blocks — not interleaved with the main algorithm. The try block should read as a clear statement of intent.
-
Write the boundary first: Write try-catch-finally before the body of any method that touches I/O, network, or other fallible resources. Tests follow naturally.
-
Prefer unchecked exceptions: Checked exceptions in Java have a real cost in call-stack coupling. Wrap them at the lowest appropriate boundary and use unchecked exceptions everywhere else.
-
Context in messages: An exception message is a diagnostic tool. Include the operation, entity, and context. Never throw
new RuntimeException("Error"). -
Classify by caller, not cause: Define exception classes based on how callers handle them. If three different causes produce the same recovery action, one exception type is the right design.
-
Special Case over exception: If “not found” is a normal business outcome, use the Special Case Pattern. Reserve exceptions for genuinely exceptional conditions.
-
Never return null: Return
Optional, empty collections, or Special Case Objects. One missing null check causes a NPE that is hard to diagnose. -
Never accept null: Reject null parameters at method boundaries. Use references (C++),
Objects.requireNonNull(Java), or runtime assertions (Python). This makes the NPE occur immediately at the source of the bug.
Related Resources
- ch06-objects-and-data-structures — Special Case Pattern is related to data abstraction and encapsulation
- ch03-functions — Single responsibility applies to error paths; extract error handling into separate functions
- ch09-unit-tests — Tests must cover exception paths; write tests that verify exceptions are thrown with correct messages
- ch11-systems — System-level error handling, cross-cutting concerns like logging and monitoring
- ch17-smells-and-heuristics — G7: Base Classes Depending on Derivatives; G29: Avoid Negative Conditionals
External references:
- Martin Fowler — Patterns of Enterprise Application Architecture (Special Case Pattern, Null Object Pattern)
- Joshua Bloch — Effective Java Item 69: Use exceptions only for exceptional conditions
- Oracle Java docs:
java.util.Optional<T>andjava.util.Collections.emptyList() - CPP Reference:
std::optional(C++17)
Last Updated: 2026-04-14