Error handling skill

Comprehensive guide to writing clean error handling that keeps business logic readable.

by wondelai·MIT license·★ 2,235 Stars on the repo·GitHub ↗

Use now

Files of Error handling

wondelai/main1 file
error-handling.md
Show the full text358 lines

Error Handling

Comprehensive guide to writing clean error handling that keeps business logic readable. Based on Robert C. Martin's Clean Code, Chapter 7.

Table of Contents

  1. The Core Problem
  2. Use Exceptions, Not Return Codes
  3. Write Your Try-Catch-Finally Statement First
  4. Use Unchecked Exceptions
  5. Provide Context with Exceptions
  6. Define Exception Classes in Terms of the Caller's Needs
  7. Don't Return Null
  8. Don't Pass Null
  9. Error Handling Patterns Summary
  10. Common Error Handling Anti-Patterns

The Core Problem

Error handling is important, but if it obscures logic, it's wrong. Code that mixes business logic with error handling is hard to read, test, and maintain. The goal is to write code where the happy path reads cleanly and error handling is a separate, well-organized concern.


Use Exceptions, Not Return Codes

Return codes force the caller to check immediately after the call, cluttering the calling code with error-checking logic.

Before: Return Codes
// BAD: Error checking clutters the logic
public class DeviceController {
    public void sendShutDown() {
        DeviceHandle handle = getHandle(DEV1);
        if (handle != DeviceHandle.INVALID) {
            DeviceRecord record = retrieveDeviceRecord(handle);
            if (record.getStatus() != DEVICE_SUSPENDED) {
                pauseDevice(handle);
                clearDeviceWorkQueue(handle);
                closeDevice(handle);
            } else {
                logger.log("Device suspended. Unable to shut down.");
            }
        } else {
            logger.log("Invalid handle for: " + DEV1.toString());
        }
    }
}
After: Exceptions
// GOOD: Business logic is clean; errors handled separately
public class DeviceController {
    public void sendShutDown() {
        try {
            tryToShutDown();
        } catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }

    private void tryToShutDown() throws DeviceShutDownError {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }
}

The business logic (shut down sequence) is now visible without wading through error checks.


Write Your Try-Catch-Finally Statement First

Try-catch blocks define a scope within your program. The code in the try block can abort at any point and resume in the catch. This makes try blocks like transactions: the catch must leave the program in a consistent state.

Practice: When writing code that could throw exceptions, start with the try-catch-finally. This helps you define what the caller can expect, regardless of what goes wrong.

# Start with the structure
def load_configuration(path):
    try:
        content = read_file(path)
        config = parse_yaml(content)
        validate_config(config)
        return config
    except FileNotFoundError:
        return default_configuration()
    except ParseError as e:
        raise ConfigurationError(f"Invalid config at {path}: {e}")
    finally:
        log_config_load_attempt(path)

Use Unchecked Exceptions

Checked exceptions (Java's throws clause) violate the Open/Closed Principle. If you throw a checked exception from a low-level function, every function in the call chain between the throw and the catch must declare that exception. A single change at a low level forces signature changes all the way up.

Aspect Checked exceptions Unchecked exceptions
Coupling Every caller must declare or catch Only relevant callers catch
Encapsulation Low-level details leak to high-level Abstraction layers maintained
Refactoring Adding new exception type cascades changes New exceptions don't affect existing callers
When appropriate Critical library APIs where caller MUST handle Application code, most library code

In practice: Use unchecked exceptions for application code. The cost of checked exceptions in dependency management outweighs their documentary benefit.


Provide Context with Exceptions

Each exception should provide enough context to determine the source and location of the error.

What to Include
Context element Why Example
Operation that failed Identifies what was attempted "Failed to save invoice"
Input that caused failure Enables reproduction "Invoice #1234 for customer 'Acme'"
Constraint that was violated Explains why it failed "Total amount exceeds maximum of $1,000,000"
Suggested recovery Helps caller respond "Retry after 30 seconds" or "Check network connection"
Implementation Pattern
# BAD: No context
raise ValueError("Invalid input")

# BAD: Raw technical error
raise Exception(str(e))

# GOOD: Contextual error message
raise InvoiceValidationError(
    f"Cannot create invoice for customer '{customer.name}': "
    f"requested amount ${amount:.2f} exceeds credit limit "
    f"of ${customer.credit_limit:.2f}"
)
Custom Exception Classes
class OrderError(Exception):
    """Base exception for order processing."""
    pass

class InsufficientInventoryError(OrderError):
    def __init__(self, product, requested, available):
        self.product = product
        self.requested = requested
        self.available = available
        super().__init__(
            f"Cannot fulfill order: {product.name} has "
            f"{available} units available, {requested} requested"
        )

class PaymentDeclinedError(OrderError):
    def __init__(self, order, reason):
        self.order = order
        self.reason = reason
        super().__init__(
            f"Payment declined for order #{order.id}: {reason}"
        )

Define Exception Classes in Terms of the Caller's Needs

When wrapping a third-party API, define exception classes based on how the caller will handle them, not based on the types of errors the API throws.

Before: Mirroring Third-Party Exceptions
// BAD: Caller must handle every possible vendor exception
try {
    port.open();
} catch (DeviceResponseException e) {
    reportPortError(e);
    logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
    reportPortError(e);
    logger.log("Unlock exception", e);
} catch (GMXError e) {
    reportPortError(e);
    logger.log("Device response exception");
}
After: Wrapping by Caller's Needs
// GOOD: Single exception class wraps all vendor exceptions
public class LocalPort {
    private ACMEPort innerPort;

    public void open() {
        try {
            innerPort.open();
        } catch (DeviceResponseException e) {
            throw new PortDeviceFailure(e);
        } catch (ATM1212UnlockedException e) {
            throw new PortDeviceFailure(e);
        } catch (GMXError e) {
            throw new PortDeviceFailure(e);
        }
    }
}

// Caller only handles one type
try {
    port.open();
} catch (PortDeviceFailure e) {
    reportError(e);
    logger.log(e.getMessage(), e);
}

Benefits of wrapping:

  • Minimizes dependencies on the third-party API
  • Makes it easy to swap vendors
  • Simplifies testing with mocks
  • Keeps caller code clean

Don't Return Null

Returning null from a method is an invitation for NullPointerExceptions. Every null return forces every caller to add a null check, and a single missed check crashes the application.

Alternatives to Returning Null
Instead of null Return this When
Null collection Empty collection Method returns a list, set, or map
Null string Empty string "" Method returns text
Null object Special case object Object has default behavior
Null optional value Optional.empty() Value may legitimately be absent
Null on error Throw exception Absence indicates a problem
The Special Case Pattern

Instead of checking for null to handle a special case, create an object that handles the special case.

# BAD: Null checks everywhere
def get_expenses(employee):
    expenses = db.find_expenses(employee)
    if expenses is None:
        return 0
    total = 0
    for expense in expenses:
        if expense is not None:
            total += expense.amount if expense.amount is not None else 0
    return total

# GOOD: No null checks needed
def get_expenses(employee):
    expenses = db.find_expenses(employee)  # Returns empty list, never None
    return sum(expense.amount for expense in expenses)
Null Object Pattern
class RealUser:
    def __init__(self, name, permissions):
        self.name = name
        self.permissions = permissions

    def has_permission(self, action):
        return action in self.permissions

class GuestUser:
    """Null Object -- behaves like a user with no permissions."""
    name = "Guest"
    permissions = frozenset()

    def has_permission(self, action):
        return False

def find_user(user_id):
    user = db.find(user_id)
    return user if user else GuestUser()

# Caller never needs to check for null
user = find_user(request.user_id)
if user.has_permission("edit"):
    allow_edit()

Don't Pass Null

Returning null is bad. Passing null is worse. When you pass null as an argument, you are creating a requirement for the callee to check for null, and if they don't, you get a runtime error.

// BAD: What should this do with null?
public double calculateMetric(Point p1, Point p2) {
    return Math.sqrt(
        Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)
    );
}

// If someone calls calculateMetric(null, new Point(1, 2)) -- NullPointerException

// GOOD: Fail fast with clear message
public double calculateMetric(Point p1, Point p2) {
    Objects.requireNonNull(p1, "p1 must not be null");
    Objects.requireNonNull(p2, "p2 must not be null");
    return Math.sqrt(
        Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)
    );
}

The best policy: Forbid passing null by default. Use static analysis tools (@NonNull, @Nullable annotations) and code review to enforce this.


Error Handling Patterns Summary

Pattern When to use Benefit
Try-catch-finally first Any code that could fail Defines transaction boundary upfront
Wrap third-party APIs Calling external libraries Isolates vendor dependencies
Special Case pattern Default behavior for missing data Eliminates null/error checks in callers
Null Object pattern Polymorphic default behavior No null checks in calling code
Guard clauses Input validation Fail fast with clear messages
Custom exception hierarchy Domain-specific errors Caller handles by intent, not implementation
Exception with context All thrown exceptions Enables diagnosis without debugging
Empty over null Collections, strings, optionals Eliminates NullPointerException risk

Common Error Handling Anti-Patterns

Anti-pattern Problem Fix
Catch-and-ignore catch (Exception e) { } -- swallows all errors silently At minimum log; usually re-throw or handle specifically
Catch-and-log-and-rethrow Duplicates logging at every level Catch at one level, let others propagate
Returning error codes Forces immediate checking, clutters happy path Use exceptions
Returning -1 or sentinel values Magic values that callers forget to check Use Optional or throw
Exception for control flow Using try-catch instead of if-else for expected conditions Use exceptions only for exceptional situations
Overly broad catch catch (Exception e) catches bugs too Catch specific exception types
Nested try-catch Multiple try blocks in one function Extract each try block into its own function
Throws declaration cascade Checked exceptions forcing changes up the call stack Use unchecked exceptions for application errors
1# Error Handling
2 
3Comprehensive guide to writing clean error handling that keeps business logic readable. Based on Robert C. Martin's *Clean Code*, Chapter 7.
4 
5 
6## Table of Contents
71. [The Core Problem](#the-core-problem)
82. [Use Exceptions, Not Return Codes](#use-exceptions-not-return-codes)
93. [Write Your Try-Catch-Finally Statement First](#write-your-try-catch-finally-statement-first)
104. [Use Unchecked Exceptions](#use-unchecked-exceptions)
115. [Provide Context with Exceptions](#provide-context-with-exceptions)
126. [Define Exception Classes in Terms of the Caller's Needs](#define-exception-classes-in-terms-of-the-callers-needs)
137. [Don't Return Null](#dont-return-null)
148. [Don't Pass Null](#dont-pass-null)
159. [Error Handling Patterns Summary](#error-handling-patterns-summary)
1610. [Common Error Handling Anti-Patterns](#common-error-handling-anti-patterns)
17 
18---
19 
20## The Core Problem
21 
22Error handling is important, but if it obscures logic, it's wrong. Code that mixes business logic with error handling is hard to read, test, and maintain. The goal is to write code where the happy path reads cleanly and error handling is a separate, well-organized concern.
23 
24---
25 
26## Use Exceptions, Not Return Codes
27 
28Return codes force the caller to check immediately after the call, cluttering the calling code with error-checking logic.
29 
30### Before: Return Codes
31 
32```java
33// BAD: Error checking clutters the logic
34public class DeviceController {
35 public void sendShutDown() {
36 DeviceHandle handle = getHandle(DEV1);
37 if (handle != DeviceHandle.INVALID) {
38 DeviceRecord record = retrieveDeviceRecord(handle);
39 if (record.getStatus() != DEVICE_SUSPENDED) {
40 pauseDevice(handle);
41 clearDeviceWorkQueue(handle);
42 closeDevice(handle);
43 } else {
44 logger.log("Device suspended. Unable to shut down.");
45 }
46 } else {
47 logger.log("Invalid handle for: " + DEV1.toString());
48 }
49 }
50}
51```
52 
53### After: Exceptions
54 
55```java
56// GOOD: Business logic is clean; errors handled separately
57public class DeviceController {
58 public void sendShutDown() {
59 try {
60 tryToShutDown();
61 } catch (DeviceShutDownError e) {
62 logger.log(e);
63 }
64 }
65 
66 private void tryToShutDown() throws DeviceShutDownError {
67 DeviceHandle handle = getHandle(DEV1);
68 DeviceRecord record = retrieveDeviceRecord(handle);
69 pauseDevice(handle);
70 clearDeviceWorkQueue(handle);
71 closeDevice(handle);
72 }
73}
74```
75 
76The business logic (shut down sequence) is now visible without wading through error checks.
77 
78---
79 
80## Write Your Try-Catch-Finally Statement First
81 
82Try-catch blocks define a scope within your program. The code in the `try` block can abort at any point and resume in the `catch`. This makes try blocks like transactions: the `catch` must leave the program in a consistent state.
83 
84**Practice:** When writing code that could throw exceptions, start with the try-catch-finally. This helps you define what the caller can expect, regardless of what goes wrong.
85 
86```python
87# Start with the structure
88def load_configuration(path):
89 try:
90 content = read_file(path)
91 config = parse_yaml(content)
92 validate_config(config)
93 return config
94 except FileNotFoundError:
95 return default_configuration()
96 except ParseError as e:
97 raise ConfigurationError(f"Invalid config at {path}: {e}")
98 finally:
99 log_config_load_attempt(path)
100```
101 
102---
103 
104## Use Unchecked Exceptions
105 
106Checked exceptions (Java's `throws` clause) violate the Open/Closed Principle. If you throw a checked exception from a low-level function, every function in the call chain between the throw and the catch must declare that exception. A single change at a low level forces signature changes all the way up.
107 
108| Aspect | Checked exceptions | Unchecked exceptions |
109|--------|-------------------|---------------------|
110| **Coupling** | Every caller must declare or catch | Only relevant callers catch |
111| **Encapsulation** | Low-level details leak to high-level | Abstraction layers maintained |
112| **Refactoring** | Adding new exception type cascades changes | New exceptions don't affect existing callers |
113| **When appropriate** | Critical library APIs where caller MUST handle | Application code, most library code |
114 
115**In practice:** Use unchecked exceptions for application code. The cost of checked exceptions in dependency management outweighs their documentary benefit.
116 
117---
118 
119## Provide Context with Exceptions
120 
121Each exception should provide enough context to determine the source and location of the error.
122 
123### What to Include
124 
125| Context element | Why | Example |
126|----------------|-----|---------|
127| **Operation that failed** | Identifies what was attempted | "Failed to save invoice" |
128| **Input that caused failure** | Enables reproduction | "Invoice #1234 for customer 'Acme'" |
129| **Constraint that was violated** | Explains why it failed | "Total amount exceeds maximum of $1,000,000" |
130| **Suggested recovery** | Helps caller respond | "Retry after 30 seconds" or "Check network connection" |
131 
132### Implementation Pattern
133 
134```python
135# BAD: No context
136raise ValueError("Invalid input")
137 
138# BAD: Raw technical error
139raise Exception(str(e))
140 
141# GOOD: Contextual error message
142raise InvoiceValidationError(
143 f"Cannot create invoice for customer '{customer.name}': "
144 f"requested amount ${amount:.2f} exceeds credit limit "
145 f"of ${customer.credit_limit:.2f}"
146)
147```
148 
149### Custom Exception Classes
150 
151```python
152class OrderError(Exception):
153 """Base exception for order processing."""
154 pass
155 
156class InsufficientInventoryError(OrderError):
157 def __init__(self, product, requested, available):
158 self.product = product
159 self.requested = requested
160 self.available = available
161 super().__init__(
162 f"Cannot fulfill order: {product.name} has "
163 f"{available} units available, {requested} requested"
164 )
165 
166class PaymentDeclinedError(OrderError):
167 def __init__(self, order, reason):
168 self.order = order
169 self.reason = reason
170 super().__init__(
171 f"Payment declined for order #{order.id}: {reason}"
172 )
173```
174 
175---
176 
177## Define Exception Classes in Terms of the Caller's Needs
178 
179When wrapping a third-party API, define exception classes based on how the caller will handle them, not based on the types of errors the API throws.
180 
181### Before: Mirroring Third-Party Exceptions
182 
183```java
184// BAD: Caller must handle every possible vendor exception
185try {
186 port.open();
187} catch (DeviceResponseException e) {
188 reportPortError(e);
189 logger.log("Device response exception", e);
190} catch (ATM1212UnlockedException e) {
191 reportPortError(e);
192 logger.log("Unlock exception", e);
193} catch (GMXError e) {
194 reportPortError(e);
195 logger.log("Device response exception");
196}
197```
198 
199### After: Wrapping by Caller's Needs
200 
201```java
202// GOOD: Single exception class wraps all vendor exceptions
203public class LocalPort {
204 private ACMEPort innerPort;
205 
206 public void open() {
207 try {
208 innerPort.open();
209 } catch (DeviceResponseException e) {
210 throw new PortDeviceFailure(e);
211 } catch (ATM1212UnlockedException e) {
212 throw new PortDeviceFailure(e);
213 } catch (GMXError e) {
214 throw new PortDeviceFailure(e);
215 }
216 }
217}
218 
219// Caller only handles one type
220try {
221 port.open();
222} catch (PortDeviceFailure e) {
223 reportError(e);
224 logger.log(e.getMessage(), e);
225}
226```
227 
228**Benefits of wrapping:**
229- Minimizes dependencies on the third-party API
230- Makes it easy to swap vendors
231- Simplifies testing with mocks
232- Keeps caller code clean
233 
234---
235 
236## Don't Return Null
237 
238Returning null from a method is an invitation for NullPointerExceptions. Every null return forces every caller to add a null check, and a single missed check crashes the application.
239 
240### Alternatives to Returning Null
241 
242| Instead of null | Return this | When |
243|----------------|-------------|------|
244| Null collection | Empty collection | Method returns a list, set, or map |
245| Null string | Empty string `""` | Method returns text |
246| Null object | Special case object | Object has default behavior |
247| Null optional value | `Optional.empty()` | Value may legitimately be absent |
248| Null on error | Throw exception | Absence indicates a problem |
249 
250### The Special Case Pattern
251 
252Instead of checking for null to handle a special case, create an object that handles the special case.
253 
254```python
255# BAD: Null checks everywhere
256def get_expenses(employee):
257 expenses = db.find_expenses(employee)
258 if expenses is None:
259 return 0
260 total = 0
261 for expense in expenses:
262 if expense is not None:
263 total += expense.amount if expense.amount is not None else 0
264 return total
265 
266# GOOD: No null checks needed
267def get_expenses(employee):
268 expenses = db.find_expenses(employee) # Returns empty list, never None
269 return sum(expense.amount for expense in expenses)
270```
271 
272### Null Object Pattern
273 
274```python
275class RealUser:
276 def __init__(self, name, permissions):
277 self.name = name
278 self.permissions = permissions
279 
280 def has_permission(self, action):
281 return action in self.permissions
282 
283class GuestUser:
284 """Null Object -- behaves like a user with no permissions."""
285 name = "Guest"
286 permissions = frozenset()
287 
288 def has_permission(self, action):
289 return False
290 
291def find_user(user_id):
292 user = db.find(user_id)
293 return user if user else GuestUser()
294 
295# Caller never needs to check for null
296user = find_user(request.user_id)
297if user.has_permission("edit"):
298 allow_edit()
299```
300 
301---
302 
303## Don't Pass Null
304 
305Returning null is bad. Passing null is worse. When you pass null as an argument, you are creating a requirement for the callee to check for null, and if they don't, you get a runtime error.
306 
307```java
308// BAD: What should this do with null?
309public double calculateMetric(Point p1, Point p2) {
310 return Math.sqrt(
311 Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)
312 );
313}
314 
315// If someone calls calculateMetric(null, new Point(1, 2)) -- NullPointerException
316 
317// GOOD: Fail fast with clear message
318public double calculateMetric(Point p1, Point p2) {
319 Objects.requireNonNull(p1, "p1 must not be null");
320 Objects.requireNonNull(p2, "p2 must not be null");
321 return Math.sqrt(
322 Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)
323 );
324}
325```
326 
327**The best policy:** Forbid passing null by default. Use static analysis tools (`@NonNull`, `@Nullable` annotations) and code review to enforce this.
328 
329---
330 
331## Error Handling Patterns Summary
332 
333| Pattern | When to use | Benefit |
334|---------|-------------|---------|
335| **Try-catch-finally first** | Any code that could fail | Defines transaction boundary upfront |
336| **Wrap third-party APIs** | Calling external libraries | Isolates vendor dependencies |
337| **Special Case pattern** | Default behavior for missing data | Eliminates null/error checks in callers |
338| **Null Object pattern** | Polymorphic default behavior | No null checks in calling code |
339| **Guard clauses** | Input validation | Fail fast with clear messages |
340| **Custom exception hierarchy** | Domain-specific errors | Caller handles by intent, not implementation |
341| **Exception with context** | All thrown exceptions | Enables diagnosis without debugging |
342| **Empty over null** | Collections, strings, optionals | Eliminates NullPointerException risk |
343 
344---
345 
346## Common Error Handling Anti-Patterns
347 
348| Anti-pattern | Problem | Fix |
349|-------------|---------|-----|
350| **Catch-and-ignore** | `catch (Exception e) { }` -- swallows all errors silently | At minimum log; usually re-throw or handle specifically |
351| **Catch-and-log-and-rethrow** | Duplicates logging at every level | Catch at one level, let others propagate |
352| **Returning error codes** | Forces immediate checking, clutters happy path | Use exceptions |
353| **Returning -1 or sentinel values** | Magic values that callers forget to check | Use Optional or throw |
354| **Exception for control flow** | Using try-catch instead of if-else for expected conditions | Use exceptions only for exceptional situations |
355| **Overly broad catch** | `catch (Exception e)` catches bugs too | Catch specific exception types |
356| **Nested try-catch** | Multiple try blocks in one function | Extract each try block into its own function |
357| **Throws declaration cascade** | Checked exceptions forcing changes up the call stack | Use unchecked exceptions for application errors |
358 

Discussion

Alternatives