Start With the End in Mind
Every error handling pattern I've seen fail has one thing in common: it was bolted on after the happy path was done. When you write try/catch as an afterthought, you end up with inconsistent handling, swallowed exceptions, and debugging sessions that make you question your career.
Instead, decide your error strategy before you write the first function. That doesn't mean planning every edge case upfront, but it means agreeing on the shape of errors, who handles them, and how they surface.
This proactive approach is like building a sturdy foundation for a skyscraper. You wouldn't pour concrete for the lobby and then decide where the load-bearing walls should go. Error handling needs the same foresight.
The Three Layers of Error Handling
I mentally split error handling into three layers: detection, propagation, and presentation. Each layer has its own job, and mixing them is where things get messy.
Detection is where the error happens. It's the throw or the return of a failure. Propagation is how that error travels up the call stack or through your system to a place where it can be handled. Presentation is what the end-user or system sees – the user-friendly message, the logged alert, or the graceful degradation of a feature.
When these layers are conflated, you get code that tries to present an error to the user at the exact point of detection, or propagation logic that’s tightly coupled to UI elements. This creates brittle systems that are hard to refactor and even harder to debug.
Define Error Shapes and Contracts
Before writing code, agree on the structure of your errors. This means defining a consistent error object or type that carries essential information.
At a minimum, an error object should include:
- A unique error code: For programmatic identification and routing.
- A human-readable message: For debugging and, potentially, user display.
- Contextual data: Any relevant information at the point of failure (e.g., input parameters, state).
- Severity level: (Optional but recommended) e.g., INFO, WARN, ERROR, FATAL.
This contract acts as a universal language for errors across your application. It ensures that when an error is detected, it carries the necessary baggage for effective propagation and eventual presentation, regardless of where it originated.
Centralize Error Propagation
Error propagation is often the most challenging layer. A common pitfall is letting errors bubble up haphazardly, leading to unexpected behavior or missed failure conditions. Instead, establish clear propagation paths.
Consider a global error handler or a dedicated middleware component. This central point can intercept errors as they propagate, log them, transform them into a presentation-ready format, and decide on the appropriate action (e.g., return a generic error to the user, trigger an alert, retry the operation).
This is akin to having a dedicated air traffic control system for your application's errors. Instead of planes (errors) crashing randomly, they are guided to a safe landing zone (handled). This prevents them from causing unintended chaos elsewhere in the system.
For example, in a web application, an unhandled exception in a controller might be caught by a global exception handler in your framework. This handler can then format a standardized JSON error response for an API client or render a user-friendly error page for a browser request. The controller itself doesn't need to know how to format these different responses; it just needs to throw or return the error appropriately.
Decouple Presentation from Other Layers
The presentation layer should be the last to see an error, and it should only receive a well-defined, ready-to-display error object. This separation is critical for scalability and maintainability.
If your detection logic is trying to construct UI messages, or your propagation logic is deciding whether to show a modal or a toast notification, you've created tight coupling. This means that changing how errors are displayed requires touching code deep within your application's core logic.
By keeping presentation separate, you can easily change UI elements, logging strategies, or notification mechanisms without impacting the underlying error detection and propagation code. This agility is essential as your application grows and its requirements evolve.
Leverage Domain-Specific Errors
While a consistent error shape is crucial, don't shy away from creating domain-specific error types. These errors can provide richer context and allow for more nuanced handling.
For instance, instead of a generic NotFoundError, you might have UserNotFoundError, ProductNotFoundError, or OrderNotFoundError. Each of these can carry specific data relevant to their domain (e.g., the ID of the user not found, the SKU of the product missing).
This level of detail allows your central handler or specific services to make more intelligent decisions. A UserNotFoundError might trigger a different workflow than a ProductNotFoundError. This specificity makes the system more robust and the error handling more intelligent, moving beyond generic failure states.
Testing Error Paths Rigorously
Scalable error handling isn't just about elegant code; it's about confidence. You need to test your error paths as thoroughly as your happy paths.
Write unit tests that specifically trigger expected errors and verify that:
- The correct error is detected and thrown/returned.
- The error propagates correctly to the intended handler.
- The handler processes the error as expected (e.g., logs correctly, returns the right status code).
- The presentation layer, if tested in isolation, receives and displays the error information accurately.
Integration tests are also vital to ensure that errors flow correctly through multiple components or services. Without comprehensive testing, even the most well-designed error handling strategy will eventually fail in production.
The Unanswered Question: Evolving Error Contracts
While defining error contracts upfront is critical, what happens when those contracts need to evolve? As systems grow and requirements change, the shape of an error might need to be updated. The challenge, which remains largely unaddressed in practice, is how to migrate existing systems and clients to a new error contract without breaking everything. This is particularly acute in microservices architectures where backward compatibility of error payloads is paramount.
