Smelly Exceptions
3 bad practices to avoid when programming with exceptions
Here's some structured programming 101.
The 3 basic control structures are sequencing, repetition, branching, and exceptions.
No, not exceptions. Exceptions are not meant for control flow. Here are some code smells that show that you may be leveraging exceptions for control flow.
Pass-through exceptions
This occurs when you defer handling an exception to a handler that is outside your current scope.
It is often done to "simplify error handling" by placing handlers in the outermost layer of the application.
Your code may look neater, no try-catch
litter. But this is deserving of exception.
Here's how such code may look.
class Invalid_program_state extends Error {}
const domain_service = {
async can_throw() {
throw new Invalid_program_state("boom!");
},
};
const application = {
async handle_foo() {
await domain_service.can_throw();
},
};
const infrastructure = {
async handle_http(req, res) {
try {
application.handle_foo();
} catch (error) {
res.status(500).json({ error }).end();
}
},
};
The can_throw
method is in an inner system layer, in a domain service.
Up in the application service layer we call it without handling its exceptions.
"This looks fine, what can go wrong?", you ask. I assume you follow the clean architecture. What's wrong with this pattern is that it exposes your infrastructure layer to core domain. The core domain assumes that there's a handler it can defer to 2 or more layers outside it. That violates modularity.
Control flow goes outside-in, and the outer layers interpret the results from the inner layers to the outside world. Your domain layer may compute a result for a request, and your infrastructure layer decides how to present that result to the client, whether over HTTP or console I/O. Passing exceptions through forces your infrastructure layer has to interpret them. This violates separation of concerns and couples your infrastructure layer to your core domain. All that business should be in your application layer.
Your domain layer also gets coupled to the infrastructure layer. Your infrastructure layer doesn't know what each exception in your domain layer means, because each one may be thrown in different use cases. This means that your infrastructure layer cannot provide good error messages to the clients. To remedy that, you may modify your domain exceptions to extend exceptions from your infrastructure layer. You may write something like the following.
declare class Http_exception extends Error {}
class Invalid_program_state extends Http_exception {}
Monster! You just coupled your domain exceptions to your framework's way of representing HTTP error codes. What happens when you need to communicate this error over gRPC in addition to HTTP? What if you want to return different HTTP errors in different use cases? Now you will have a hard time refactoring away from this representation.
Don't pass domain exceptions through to outer layers, handle them in their callers.
Exceptions with makeup
If you notice your domain exceptions being decorated with too much information, it's probaly a smell you should check. It is usually caused by the previous smell, pass-through exceptions.
Because your infrastructure layer is handling error reporting, you have to decorate your exception with friendly messages intended for the client. But, again, that's wrong. It is wrong because error reporting is contextual, and the context is the use case (handled by the application layer), not the core domain.
Here's an example of an exception with makeup.
throw Invalid_program_state(`
Dear user, we cannot process this request owing to insufficient balance.
We wrote you this note so that you won't receive a generic error message.
Bye.`);
The error message lacks sufficient context: "we cannot process this request" is not as good as "you cannot buy 'purple goose feathers' because you're broke". But we can't know that because the domain objects don't know about the specific use cases. And what happens when you need to change the copy for just one use case? Ouch.
The solution to this is the same as the solution to the previous smell: handle domain exceptions in the context of the use case; that is, in the application services.
Exception volley
Sometimes you don't really catch the exception, you just volley it around until it bubbles up to a global handler.
try {
if (typeof user !== "string") {
throw new Invalid_program_state("I'll catch this");
}
} catch (error) {
throw error;
}
Using exceptions in this manner makes your code harder to read.
You have to go through two steps to simply return a result to the caller. Why do that?
Generally, using exceptions for control flow is like calling GOTO
, but without
specifying where to go.
Exceptions create implicit paths through your program, from every operation that can throw, back through every exception handler on the call stack. Reasoning about all those paths is hard enough. It is best to limit them to states in which your program cannot return reasonable result. That is what exceptions are for.
Throwing and catching your own exceptions is like smelling your own fart.
Your functions should not catch their own exceptions. They're signals to the caller, so there's no need to catch them.
Key takeaways
- Avoid passing exceptions through layers. Passing an exception from the domain layer to the infrastructure layer can cause both layers to become undesirably coupled. Only pass exceptions through if you intend to let the program crash.
- Handle exceptions at the closest point where you can provide enough context for the failure. For programs following the clean architecture, that's usually at the application services layer. This lets you generate helpful, contextual messages specific to the use case.
- Throw exceptions only when the program is in an invalid state and so cannot continue. And when you do, don't catch your own exceptions in the same function where you throw them.