Error Handling in Programming: Best Practices and Techniques


Introduction

Error handling is one of those topics every developer uses daily, but rarely stops to think about systematically. Different languages promote very different styles, and those styles deeply influence how we design systems.

In this post, I want to step back and answer three questions:

  • What are the fundamental models of error handling?
  • How do those models affect system architecture?
  • What are practical patterns you can apply when writing real services?

This is not a language-specific tutorial. Instead, it’s about how to think about errors.

What Is Error Handling?

At a high level, error handling is how software reacts to unexpected conditions:

  • invalid user input
  • I/O failures
  • unavailable resources
  • violated assumptions

Some people think errors are bugs, but:

  • Errors are expected events in an imperfect world.
  • Bugs are mistakes in the code.

Good error handling makes failures:

  • explicit
  • observable
  • diagnosable
  • recoverable (when appropriate)

Four Fundamental Error-Handling Models

Across programming languages, most error-handling mechanisms fall into 4 core models.

1. Exception-based Error Handling

Errors are control flow. They interrupt normal execution and jump up the call stack.

try {
    articleService.findById(id);
} catch (ArticleNotFound e) {
    // Handle ArticleNotFound here
}

Characteristics:

  • Non-local control flow
  • Stack unwinding
  • Centralized handlers possible

Typical languages

  • Java, C#, Scala
  • Python, JavaScript, Ruby

Strengths

  • Clean happy-path code
  • Easy global handling

Weaknesses

  • Hidden exits
  • Harder reasoning in async / concurrent code
  • Easy to over-catch or swallow errors

This model works well when frameworks control the lifecycle (e.g. Spring, Quarkus, …).

2. Error-as-Values

Errors are returned explicitly as values, alongside results.

article, err := service.Find(id)
if err != nil {
    return err
}

Characteristics

  • Explicit, linear control flow
  • No hidden stack jumps
  • Caller decides how to handle

Typical languages

  • Go
  • C (return codes)
  • Zig (partially)

Strengths

  • Easy to reason about
  • Predictable behavior
  • Excellent for concurrent systems

Weaknesses

  • Verbose
  • No compile-time enforcement

This model is very common in system software and distributed services.

3. Result / Algebraic Error Types

A function returns either a success value or a typed error.

fn find(id: Id) -> Result<Article, FindError>
def findArticleById(id: String): Either[ArticleNotFoundException, Article]

Characteristics

  • Explicit like error values
  • Errors are typed
  • Compiler forces handling

Typical languages

  • Rust
  • Scala (Either, Try)
  • Haskell
  • Swift

Strengths

  • Very strong correctness guarantees
  • Excellent for domain modeling
  • Great for libraries

Weaknesses

  • Type noise
  • Can “infect” APIs

You can think of this as checked exceptions done right.

4. Effect-based Error Handling

Errors are modeled as effects in the type system, handled separately from logic.

findArticle : Id -> Article ! { ArticleError }

Typical languages/frameworks

  • OCaml (algebraic effects)
  • Koka
  • Scala ZIO
  • TypeScript (Effect library)

Strengths

  • Extreme composability
  • Minimal boilerplate
  • Very powerful abstractions

Weaknesses

  • Steep learning curve
  • Overkill for most production systems

This model is still mostly academic or research. Usually, I don’t use this model.

Checked vs Unchecked vs Value Errors

This distinction mainly applies to exception-based systems.

  • Checked exceptions (Java): The compiler forces you to catch or declare them. Usually used by library writer.
  • Unchecked exceptions: No compiler enforcement.
  • Error values / Result types: Explicit by construction.

Many modern languages moved away from checked exceptions because:

  • They leak implementation details
  • They force meaningless boilerplate
  • They don’t scale well in large systems

Instead, they prefer explicit return channels.

Error Handling as an Architectural Decision

Error handling is not just a language feature — it’s an architectural choice.

Architecture Type Suitable Model
Framework-centric Exceptions
Microservices Error values
Domain-driven design Result / Either
System tools / daemons Error values
Research / Language design Effects

If failures are expected and frequent, make them explicit.

Practical Error Handling Patterns

1. Handle Errors Where You Add Value

If you can’t improve the error, don’t handle it.

Bad:

if err != nil {
    log.Println(err)
    return err
}

Good:

if err != nil {
    return fmt.Errorf("find article %s: %w", id, err)
}

2. Translate Errors at Boundaries

Lower layers deal with technical errors. Higher layers deal with business meaning.

if errors.Is(err, sql.ErrNoRows) {
    return ArticleNotFound
}

This keeps domain logic clean and decoupled from infrastructure.

3. Log Errors Only Once

If you return an error, don’t log it. If you log an error, don’t return it.

Log at system boundaries:

  • HTTP handlers
  • CLI entry points
  • goroutine roots
  • message consumers

This avoids duplicated logs and preserves context.

4. Panic Is Not Error Handling

In Go and Rust, panic is not an exception system.

Use panic only for:

  • programmer bugs
  • violated invariants
  • impossible states

Expected failures should never panic.

Testing Error Paths

Good error handling is testable.

  • Unit tests should assert error types
  • Integration tests should assert error mapping
  • Observability (logs, metrics) should expose failure modes

Errors are part of your API, so test them like features.

My Practial Recommendation

Do:

  • Fail fast, handle later.
  • Use specific error type for specific business.
  • Meaningful error messages.
  • Wrap and re-throw when adding context.
  • Standardize error responses (e.g. RFC-9457 ProblemDetails).
  • Document the error (e.g. Javadoc).
  • Catch the most specific error first.

Don’t:

  • Log too much but hard to understand.
  • Swallow error.
  • Catch all as the same (e.g. Throwable).

Conclusion

In this article, I have shown you an overview about the error handling and best practices.

  • There are four fundamental error-handling models
  • Each model fits different system architectures
  • Explicit error handling scales better in distributed systems
  • Log once, at boundaries
  • Treat errors as first-class design elements

Good error handling doesn’t eliminate failures, it makes failures boring, visible, and understandable.

Feel free to leave your comments if you have any suggestion or question!

If you enjoy my posts, consider supporting ☕

👋 Are you in Vietnam? Click here to see local support options.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • ZRAM and how I deal with the memory usage in my Linux system
  • Generate password with command line in Linux or macOS
  • P2P - UDP Hole Punching
  • Hub and Spoke VPN, how it solve my working problem
  • Build a Root and Intermediate Certificate Authority with Easy-RSA