Pragmatic error handling
A simple and practical approach to error management.
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 executionWhile 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 handlerHowever, 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_exThe 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 valueConsider 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:

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.
