avatarLuís Soares

Summary

The provided content outlines a pragmatic approach to error handling in software development, emphasizing the avoidance of exception hierarchies, centralization of errors, and the use of exceptions for flow control, while advocating for guards, assertions, and the operation result pattern.

Abstract

The article discusses best practices for managing errors in software applications, advocating for a minimalist and practical approach to exception handling. It suggests avoiding the creation of complex exception hierarchies and centralized error files, as these introduce unnecessary complexity and coupling. Instead, the author recommends using guards and assertions to validate inputs and states, ensuring that errors are caught early and handled appropriately. The text also advises against using exceptions for flow control, proposing the operation result pattern as a more effective alternative for managing expected errors without relying on exceptions. The author emphasizes handling exceptions in the outer layers of the application, such as web handlers for domain exceptions and an unhandled exception handler for runtime errors. Additionally, the article discourages the inclusion of error codes and messages within custom exceptions, suggesting that these should be managed in the application's outer layers instead. The overall message is to create and handle only the necessary exceptions, focusing on current needs rather than future-proofing with upfront design.

Opinions

  • Exception hierarchies and centralized error handling (e.g., errors.py or Exceptions.java) are considered anti-patterns due to their tendency to create tight coupling and complexity.
  • The use of guards and assertions is highly recommended for input validation and maintaining an always-valid domain model, aligning with the principle of "fail fast; fail early."
  • Custom exceptions should be raised in the domain but caught and handled in the application's outer layers, such as web handlers or the unhandled exception handler.
  • The author opposes the use of exceptions for flow control, suggesting that runtime exceptions should bubble up to be handled globally, while business domain errors should be managed through the operation result pattern.
  • Error codes and messages should not be part of the domain exceptions but instead be handled in the application's outer layers to facilitate internationalization and avoid exposing sensitive information.
  • The operation result pattern is favored over custom exceptions for handling expected errors, as it provides a clear mechanism for dealing with success and error scenarios without relying on exceptions.
  • The article promotes a lean approach to error handling, focusing on simplicity and addressing only the errors that need specific handling, rather than designing for hypothetical future issues.

Pragmatic error handling

A simple and practical approach to error management.

Photo by Sigmund on Unsplash

These are the most common problems I found in my career. I’ll explore some patterns and antipatterns about creating, throwing, and handling exceptions.

Don’t centralize — Avoid exception hierarchies

Inheritance is often abused in the object-oriented paradigm. This also happens in exception modeling. However, there’s no advantage in resorting to inheritance to model exceptions. This is worsened when people create hierarchies thinking about the future… Grouping exceptions by type creates unnecessary coupling and complexity.

Unnecessary exception hierarchy:

Exception
   ↖ MyAppException
      ↖ InputError
      ↖ CheckoutException
         ↖ CartException
            ↖ EmptyCart
            ↖ CartNotFound
         ↖ CantProcessOrder
            ↖ OrderNotFound
   ...

Instead, inherit directly from Exception and catch specific exceptions; probably, the handling is different per exception anyway, depending on the use case; if it’s the same, maybe you just needed one exception in the first place (a “multi-catch” also hints that).

Don’t centralize — Beware of technical hotspots

errors.py or Exceptions.java are code hotspots: places that change significantly due to unrelated reasons. More specifically, they’re technical hotspots because they have nothing in common besides being exceptions, which is a technical aspect.

In a domain-centric architecture, custom exceptions usually belong to the domain (i.e., core), but how would you organize them? Context provides meaning, so the solution is to put exceptions where they belong. My advice is to follow a use case approach, where you split the domain by use cases. In each use case, place its possible exceptions (some exceptions can also belong to ports). This helps to document the exceptions and how they can happen — it improves self-documentation and code cohesion.

Sharing exceptions across your app is abusing DRY — defining an exception takes one or two lines of code, so what’s the advantage of sharing it? It only creates coupling between unrelated features. For example, an OrderInWrongState error has a different meaning and handling depending on where it happens — the use case. Again, this aligns with my proposal to follow a use case approach, where use cases are autonomous, owning their errors as part of their API.

Add guards and checks

It’s a good practice to check parameters regarding nullability, types, ranges, sizes, etc. These are called guards (preconditions). In functions, they ensure immediate halting; in objects, they ensure an always-valid domain model (especially relevant in domain-centric architectures). This is known as “fail fast; fail early” as it prevents invalid input from propagating further, potentially causing more damage.

// Java:
if (n <= 0) throw IllegalArgumentException("n should be positive")

// Kotlin:
require(n > 0) { "n should be positive" }

// Python:
assert n > 0, "n should be positive"

// Go:
if n <= 0 {
   return 0, errors.New("n should be positive")
}

// beware that JavaScript's console.assert is only for debugging purposes as it does NOT halt program execution

While guards are focused on input sanity, assertions (postconditions) verify outcomes (function outputs) and system state (side effects). Assertions are not to be used solely in tests. You should assert your code with checks for “things that can’t happen” (sanity checks).

// Java:
if (client == null) throw IllegalStateException("Client not found")

// Kotlin:
checkNotNull(client) { "Client not found" }

// Python:
assert client, "Client not found"

// Go:
if client == nil {
   panic("Client not found")
}

📝 Check if your language/runtime has native asserts (e.g., Kotlin, Node.js, Python, …). If not, find a library for that purpose.

Failed assertions bubble up until the unhandled exception handler, where they are monitored (which is why they should include the contextual data to help developers if they happen). They can end up in Sentry or similar, which would most likely mean a bug (e.g., a race condition) that would need addressing.

An assertion is code that’s used during development — usually a routine or macro — that allows a program to check itself as it runs. When a assertion is true, that means everything is operating as expected. When it’s false, that means it has detected an unexpected error in the code. Code Complete

Guards and assertions should be used mainly to nail down programmer errors (guards can also be used for validation in simple apps). They help to pinpoint the culprit to the exact line of code when issues arise, bringing the contextual stack trace behind it. They create a strong safety net (defensive programming). As a great side-effect, they contribute to code as self-documentation because developers can easily read the rules contextually.

Let it bubble — The unhandled exception handler

It’s a well-known fact that you should not indiscriminately catch all exceptions (don’t “try/catch(Exception)”). Don’t handle generic exceptions in specific places (e.g., a JSON parsing exception in a concrete endpoint handler). You should only handle contextual exceptions in each handler.

business domain exceptions
specific exception A ➡ specific handler 1
specific exception B ➡ specific handler 1
...
specific exception K ➡ specific handler 2
...
runtime/technical exceptions:
generic exception X  ➡ unhandled exception handler
generic exception Y  ➡ unhandled exception handler
remaining exceptions ➡ unhandled exception handler

However, the remaining exceptions must be handled somewhere, or the users will see their stack traces, which harms security and UX. This is done in the “unhandled exception handler”: the last responsible moment to handle exceptions — or just a place where unhandled exceptions die. Often, the handling is to inform the user, but it can also be a retry of the operation. As a generic example:

fun main() {
   val app = App()
   while (true) {
      try {
         app.handle(readCommand())
      } catch (e: Exception) {
          System.err.println(e.message)
      }
   }
}

If you’re creating a web app, you can configure the web framework accordingly:

// Unhandled exception handling using Javalin.io (Kotlin):

fun main() {
    Javalin
        .create()
        .get("/") { it.result("Hello World") }
        .exception(Exception::class.java) { e, ctx ->
            // TODO: log, monitor
            ctx.status(500).result("unexpected error")
        }
        .start(7070)
}
# Unhandled exception handling using FastAPI (Python):

app = FastAPI()

@app.exception_handler(Exception)
def handle_unhandled_exceptions(request, exception):
    # TODO: log, monitor
    return Response(status_code=500, content="unexpected error")

The typical behavior on an unexpected exception is to log the issue, inform the monitor (e.g., Sentry), and return 500. This ensures that you are quickly alerted and can thus fix the underlying bug. Ideally, these exceptions are runtime/technical exceptions only since domain/custom exceptions should be handled in specific handlers.

Let it bubble — Handle it outside

By default, you should avoid handling exceptions from the inner layers of the software unless you can enrich them with higher-level information or you want to decouple from technical exceptions (e.g., converting a database constraint into a domain exception). If you need to handle and throw, never lose track of the original exception:

try:
   # do stuff
except SomeExceprion some_ex:
   raise HigherLevelException(more_info) from some_ex

The log-and-throw antipattern is an example of unneeded handling. Logging everywhere clutters the domain code despite being a technical detail. Indeed, logs are important, but you should push them out of the domain to the app’s outer layers.

When you follow a domain-centric architecture (e.g., clean architecture), your custom exceptions are mostly raised in the domain. However, catching them is generally done in the outer layers of the app — the adapters. For example:

  • In a web handler (i.e., router), you could catch domain exceptions and convert them to adequate HTTP responses.
  • In the unhandled exception handler, you catch and handle the non-caught exceptions (ideally, only the runtime ones).
  • In a data repository, you could catch a database constraint violation and convert it to a domain concept (e.g. UserAlreadyExists).
  • In an adapter of a third-party API that wraps a client library, you could also convert a library exception into your domain exception (only if you need to handle it), to avoid library concepts leakage.
  • In a gateway, you could catch a timeout exception and retry.

No codes/messages in custom exceptions

Error codes and error messages are representations of errors — they’re equivalent to toString. In a domain-centric architecture, the domain should not know how exceptions will be represented. Therefore, error codes/messages are not in the domain (where your custom exceptions belong). Make them part of the exception handling in the outer layers of the app — in a web app, that would be in the web handlers.

# handling exceptions in a web handler:

@router.put(path="/order/close")
async def force_close_asn(
    id_: str,
    close_order: CloseOrder = Depends(inject_close_order),
):
    try:
        close_order(id_)
        return Response(status_code=204)
    except OrderNotFound ex:
        return JSONResponse(status_code=404, content={"id": ex.id_})

📝 Exception messages are targeted at developers — they will be seen by API users and in logs, monitors, etc. Be careful with sensitive data (e.g., passwords) and GDPR compliance in general.

📝 Displaying messages originating from custom exceptions makes internationalization hard. Instead, use the error codes to decide what to show.

Lean exception handling

Following a lean mindset, create custom exceptions (and handle them) if there’s an actual need (e.g., a user error). Do not design upfront; focus on the present. In other words, create custom exceptions for issues you want to handle in specific handlers driven by immediate needs. For example, guards can be runtime exceptions. They’ll be caught and handled in the app’s outer loop (unhandled exception handler) in a generic way, which is enough until proof is contrary. Create a custom exception only if you intend to add custom handling (e.g., a custom JSON message in a specific web handler). Make sure you have tests covering these error scenarios; if you do TDD, it’s guaranteed you have them.

Also, don’t gracefully handle what can’t happen. For example, why do you need to handle user errors with a fancy JSON if the user has no way of triggering them (due to browser-side validation) or if the UI handles all validation errors similarly? Instead, resort to HTTP status.

Don’t use exceptions for flow control

Avoid catching runtime exceptions (e.g., array out of bounds, null pointer exception, casting/conversion errors). Most runtime exceptions are preventable. The ones that aren’t, probably you can’t do much about them — they’ll be handled in the unhandled exception handler, so let them bubble up.

[…] the best designs carefully prevent problems from occurring in the first place. 10 Usability Heuristics for User Interface Design

I’ve seen code where a conversion (e.g., to enum, to int) is attempted by try/error. This is bad because it’s ugly code and because exceptions are costly. In these cases, you have all it takes to properly check the input validity and convert only if it makes sense.

An exception to this rule is an unavoidable but recoverable issue. For example, you could catch and handle a network error by retrying.

📝 Unfortunately, in Python, exceptions are encouraged as a way to control flow, which is an antipattern in most other languages:

# Relying on preventable runtime exceptions for data conversion
def _convert_value(value):
    try:
        return int(value)
    except (TypeError, ValueError):
        pass
    try:
        return float(value)
    except (TypeError, ValueError):
        return value

Consider the operation result pattern

The previous sections reduced the need for exceptions to a minimum. What if I said that you don’t need to create custom exceptions? “Not so fast! What about all the errors that can naturally happen?” There are only two possibilities for an exception:

  • Runtime: It’s a bug that needs to be fixed or ignored (in which case it’ll be caught in the unhandled exception handler).
  • Business domain: using an exception is bad because you rely on it to control flow.

The alternative to exceptions is the operation result pattern. In a nutshell, the return of functions contains success or error. In its most primitive version, null means unsuccess; else success. This is enough for simple cases, but usually, you need more, especially in functional programming and asynchrony.

Some languages offer native support (e.g., Java Optional, Kotlin Result and sealed classes, Rust Result, Scala error handling, Elixir pattern matching, Go error handling, JavaScript promise, Haskell Maybe). Others may need a third-party library (e.g., Python Returns, Ruby Resonad, C# OperationResult).

// Example of the 'result pattern' using a Kotlin sealed class
class CreateUserHandler(
    private val createUser: CreateUser,
) : Handler {

    override fun handle(ctx: Context) {
        val createUserResult = createUser(
            ctx.bodyAsClass(CreateUser.CreateUserRequest::class.java)
        )
        ctx.status(
            when (createUserResult) {
                NewUser -> HttpStatus.CREATED_201
                UserAlreadyExists -> HttpStatus.CONFLICT_409
            }
        )
    }
}

In some languages (Go errors, Java checked exceptions), error handling can be inconvenient, so developers ignore errors. I prefer the Kotlin approach, which recommends the operation result pattern (with sealed classes) rather than exceptions. This forces the caller to handle all possible scenarios (with no boilerplate), but it doesn’t enforce the handling of runtime errors:

As a rule of thumb, you should not be catching exceptions in general Kotlin code. That’s a code smell. Exceptions should be handled by some top-level framework code of your application to alert developers of the bugs in the code and to restart your application or its affected operation. That’s the primary purpose of exceptions in Kotlin. […] Don’t use exceptions as a work-around to sneak a result value out of a function. Kotlin and Exceptions

Domain-centric architecture

Here’s a summary of how exception handling relates to a domain-centric architecture:

Error handling in a domain-centric architecture

Summary

Consider these as takeaways:

  • Avoid centralization — inheritance hierarchies and files full of exceptions;
  • Add guards and checks;
  • Handle “outside” (in adapters) — custom/domain exceptions in web handlers and runtime/unexpected exceptions in the unhandled exception handler;
  • Resort to runtime exceptions for non-recoverable unexpected issues rather than custom exceptions;
  • Don’t store error codes/messages in exceptions;
  • Create and handle the minimum set of exceptions required for your needs;
  • Consider the operation result pattern instead of custom exceptions.

Further reading

Exception Handling
Good Practices
Error Handling
Recommended from ReadMedium