Clean Code Series: The Art of Clean Error Handling


Why elegant error handling is non-negotiable in professional software development
Hey dev, ever been jolted awake by a critical production alert? Or lost hours of data because an error was silently ignored? If you shivered just thinking about it, you know what I mean.
Many developers treat error handling as a boring chore—a noisy mix of if err != nil
or try/catch
blocks cluttering our "perfect" logic. But that mindset couldn’t be more wrong. Poor error handling is a recipe for fragile, unpredictable, and dangerous software.
Here’s the good news: writing robust, clear error-handling code isn't about avoiding errors—it's about managing them with elegance and transparency. This is such a crucial topic that Uncle Bob dedicated an entire chapter to it in Clean Code (Chapter 7: Error Handling).
Let’s dive into how we can treat errors as first-class citizens in our codebases. Ready?
1. The Most Common (and Dangerous) Error Handling Mistakes
Before we fix anything, let’s confess a few sins. Which of these have you been guilty of? (No shame—we’ve all been there!)
Silently Swallowing Exceptions: The infamous
catch (Exception e) {}
. This is like seeing your house on fire, closing your bedroom door, and pretending nothing is wrong. Ignored problems always come back to haunt you.Returning
null
or Using Magic Values: Forcing callers to guess what went wrong. This leads to chains ofif (result != null)
and bloated defensive code.Throwing Context-Free or Generic Exceptions:
throw new Exception("Something went wrong!")
is about as helpful as a blank map. What happened? Where? Why?Using Exceptions for Normal Control Flow: If you're using
try/catch
to break out of a loop, you're hammering screws. Exceptions are for exceptional events.
2. Pillars of Elegant Error Handling
So how do we handle errors the right way? Uncle Bob gives us several principles:
Prefer Exceptions Over Return Codes: The main advantage is separation of concerns. The "happy path" stays clean and readable, while error logic is delegated to
catch
blocks or conditional handlers.Provide Context With Your Exceptions: A good exception tells a story. When catching a low-level error (e.g., DB connection), don’t just throw it upward. Wrap it in a higher-level error that explains what you were trying to do. For example:
return nil, fmt.Errorf("failed to process payment for order #%d: %w", orderID, dbErr)
Define Domain-Specific Exceptions: Instead of relying on generic language exceptions, create custom ones that reflect your business logic.
InsufficientFundsError
is more informative thanInvalidOperationException
.Eliminate
null
from APIs Whenever Possible: MostNullPointerException
s are self-inflicted. Eliminatingnull
from your APIs removes an entire class of bugs and simplifies logic.
3. Refactoring Error Handling in Go: From Generic to Graceful
Go’s explicit error handling via if err != nil
is a great playground for writing clear error paths.
Before: Obscure and generic error handling
// Returns nil and a generic error, losing all context
func findUserConfig(userID int) (*Config, error) {
db, err := connectToDatabase()
if err != nil {
log.Println("Database error") // Swallows the original error
return nil, errors.New("internal server error")
}
var config Config
// ... fetch config logic ...
if err == sql.ErrNoRows {
return nil, nil // Uses nil to mean "not found"
}
if err != nil {
return nil, err // Returns raw error without context
}
return &config, nil
}
Problems:
- The real database error is logged and lost.
- Returns a generic error that reveals nothing.
- Uses
nil, nil
to mean “not found”, forcing ambiguous checks. - Passes through raw errors with no additional context.
After: Context-rich, expressive error handling
var ErrConfigNotFound = errors.New("user configuration not found")
func findUserConfig(userID int) (*Config, error) {
db, err := connectToDatabase()
if err != nil {
return nil, fmt.Errorf("failed to connect to database when retrieving config for user %d: %w", userID, err)
}
var config Config
// ... execute query logic ...
if err != nil {
// You can now handle errors precisely:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConfigNotFound
}
return nil, fmt.Errorf("query execution failed for user config %d: %w", userID, err)
}
return &config, nil
}
Why it’s better:
- The error includes what failed and for whom.
- “Not found” becomes a clear, domain-specific error—not a silent
nil
. - Wrapping with
%w
allows inspection witherrors.Is
orerrors.Unwrap
.
Conclusion: Treat Errors as First-Class Citizens
This brings us to the end of our Clean Code series. And the final lesson is clear: great software doesn’t avoid errors—it embraces them.
Errors are not exceptions to reality—they are part of it. In complex systems, they’re inevitable, and the way we handle them defines our system's resilience.
So here’s your final challenge: in your next code review, pay extra attention to error-handling blocks. Are they telling a clear, useful story? Or just deferring disaster?
Robust code doesn’t fear its own failures—it anticipates and manages them.
📣 Let’s Keep the Conversation Going!
What are your golden rules for handling errors? Got a horror story (or a victory!) about an exception that saved the day? Share it in the comments!
👉 Follow me on LinkedIn and Hashnode to keep the conversation alive.
Subscribe to my newsletter
Read articles from Phelipe Rodovalho directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
