Skip to content

Modern Error Handling Is Losing the Plot (And How to Bring It Back)

TL;DR

By ditching exceptions for „errors as values,“ languages like Go and Rust gave us clearer, more predictable code. But there’s a catch: without context, a simple database hiccup becomes an operational nightmare of guessing games. This article walks through why errors-as-values can leave you blind to the real problem, and how a simple habit of wrapping errors transforms cryptic messages into a detailed map of your code’s journey.


Introduction

Picture this: you’re on a road trip, GPS guiding you, when suddenly it just says „Recalculating.“ No street names. No landmarks. Just that. Did you miss Oak Street or overshoot Pine Avenue? No idea.

Modern error handling feels exactly like that.

Languages like Go and Rust threw out class-based exceptions and said „errors are just values now.“ It’s elegant. It’s composable. There’s no hidden control flow jumping around behind your back. But strip away the context, and you’re left wandering in the dark. A simple „SQL no rows“ error could be from anywhere in your codebase, and you’re stuck playing detective.

This is the tale of missing stack traces, why errors-as-values sometimes rob you of crucial information, and how a tiny habit of wrapping your errors can turn „Recalculating“ into „Turn left at Oak Street“ in your production logs.


Why Exceptions Got the Boot (And What We Gained)

For decades, Java and C# threw exceptions like confetti. Call a method deep in your code, something goes wrong, and boom: an exception with a beautiful stack trace breadcrumb trail back to the source. Comforting, right?

Until it wasn’t.

Hidden catch blocks swallowed exceptions. Unexpected control-flow jumps caused subtle bugs. Your neatly layered application suddenly had secret backdoors that could fire off at any moment. It was chaos masquerading as convenience.

Then Go and Rust showed up and said, „Thanks, but no thanks.“

Instead of throwing exceptions, every function that might fail returns an error alongside its normal result. If you don’t want to handle it right there, you pass it up the chain. No hidden jumps. No magic. Your code reads top to bottom, every error is explicit, and nothing sneaks through the back alley of your call stack.

In theory, it’s like knowing every street you’ll drive on before leaving the driveway.

In practice? Too many errors start sounding identical when they echo back through multiple layers. Same „SQL no rows“ or „File not found“ over and over. No street names. No landmarks. Just noise.


Errors as Values: The Double-Edged Sword

When errors become just another return value, day-to-day coding feels refreshingly straightforward. In Go, you see this everywhere:

user, err := repo.GetUserByID(id)
if err != nil {
    return err
}

Repeat that pattern up through your service layer, and eventually you log at the API boundary:

if err != nil {
    log.Errorf("operation failed: %v", err)
    http.Error(w, "Internal server error", 500)
}

Nice and neat. Until you have a unit of work with several database calls in a single transaction. A single „ErrNoRows“ can pop up in a SELECT, an UPDATE, or a DELETE. But when it bubbles up? Looks exactly the same. You know it came from somewhere in your user repository, but was it the first SELECT or the second UPDATE?

You have no idea.

Rust suffers the same fate. The ? operator lets you ferry errors upward after converting them into a unified type like anyhow::Error. Elegant, sure. But context vanishes unless you consciously reattach it. Suddenly „Connection refused“ or „UTF-8 decoding error“ aren’t tied to a precise spot in your logic. They’re just floating messages, and you’re left guessing where to look.


When Context Goes Missing: A Tale of a Wandering Error

Picture a banking application. You’ve built a repository layer talking to a SQL database. You’ve wrapped several statements inside a single unit of work: SELECT account balance, UPDATE ledger, DELETE temporary holds. All atomic. Above that, a service layer orchestrates business rules: checking fraud flags, validating funds, applying interest. Finally, a REST endpoint glues it to the outside world.

One afternoon, a user reports a failed transfer.

Your logs show an „ErrNoRows“ error. That’s it. No file name. No line number. No sequence of operations. Just a vanilla SQL error.

It’s like hearing a police siren but not seeing the flashing lights or knowing which neighborhood it’s in.

Now your on-call engineer is dialing into database checks, rummaging through code, re-running unit tests just to figure out which statement failed. Every second spent is money and trust lost.

Here’s what that ghostly path looks like:

User Request → API Handler → Service Layer → Repository → Database
                                                           ↓
                                                      ErrNoRows
                                                           ↓
                              "operation failed: sql: no rows"

The error makes it all the way to production logs, but without context, it’s impossible to trace back to the culprit.


Wrapping Errors: Giving Your Errors a Voice

The fix is straightforward: treat errors like messengers that benefit from a little background information.

In Go, wrap your errors at each handoff:

user, err := repo.GetUserByID(id)
if err != nil {
    return fmt.Errorf("service.GetUserByID: %w", err)
}

Down in the repository:

row := db.QueryRow("SELECT ...")
if err := row.Scan(&user.Name); err != nil {
    return fmt.Errorf("repo.Scan user row for id %d: %w", id, err)
}

Now when that error bubbles up, your logs tell the full story:

service.GetUserByID: repo.Scan user row for id 42: sql.ErrNoRows

In Rust, the pattern is just as natural using .map_err() before the ? operator:

let user = repo.get_user(id)
    .map_err(|e| anyhow!("service get_user({}): {}", id, e))?;

And in the repository:

let row = conn.query_one("SELECT ...", &[&id])
    .map_err(|e| anyhow!("repo query_one for id {}: {}", id, e))?;

Your error trace in Rust might now read:

service get_user(42): repo query_one for id 42: sql no rows

With this breadcrumb trail, you instantly know which function, on which data, failed. You head straight to the scene. No guessing games.


Conclusion

Errors as values bring clarity and control, but only if you honor their need for context. Sending a postcard with just a picture but no address turns your error messages into unsolvable mysteries.

By embracing simple error-wrapping patterns in Go and Rust, you give your errors the address and zip code they need to find their way home. Next time you write that if err != nil or tap ?, ask yourself: „Am I telling the full story?“

Your future self (and your on-call rotations) will thank you. No GPS recalculations required.

DSGVO Cookie Consent mit Real Cookie Banner