Error Handling with Middleware
Error handling, along with unit testing and security, is one of those topics that, while super important, you hardly see anyone talking about it or taking it seriously. It is often completely ignored in applications or is not a well-designed process.
The common approaches
Most of the articles about error handling talks about 4 approaches:
- Throw exceptions;
- Return null;
- Result class;
- Either monad.
They all have their pros and cons that can be summarized in a few sentences.
Starting with Throwing exceptions, the main advantage is probably that it's the standard modus operandi adopted by most popular OOP languages today, although being able to stop the code before it's too late is an amazing feature. The main downside: you end up with lots of try/catch blocks on your code if you are willing to and can deal with the exceptions.
Returning null is the quickest method, but it fills your code with "if !== null" everywhere. If that does not bother you, great! You can stop reading the article here. 🙃
The Result class is somewhat considered by many as an optimized approach. As the name suggests, it carries the result of an operation. You usually use an "isSuccess()" method to make sure the value you are going to transmit is or is not a failure. FMPOV, it's a returning null that carries error messages as well. Still too many ifs for my taste.
Last, the Either monad. It consists on returning Right (good) and Left (bad) results. It came from functional programming, so it must be a blessing, right? (no pun intended). Your code ends up with lines such as "if (usernameOrError.isLeft())". I kid you not. The code is covered with that and it's not even to speed things up. It is the perfect blend of the disadvantages of the other methods. The reason this is not considered an anti-pattern is beyond me. 🤡
Only if we could improve Throwing exceptions so that we don't end up with tons of try/catch blocks in our controllers...
The solution
After searching on Google, I was glad to find out that I wasn't the first one to ask that question. The actual useful articles are dated to 2 years ago. Astonishing. We have been dealing with errors the wrong way for a long long time. 😰
Here at goinfinite.net, we use PHP on ours APIs. Luckily, the HTTP microframework we decided to use (Slim4) supports Error Middlwares. Therefore creating an implementation was a piece of cake, take a look on the next snippet.
First thing you see is an "isStaff()" method. Nothing outstanding here, the code decides if the access is coming from our trusted networks, so we can decide how much info to show in the error response later.
Then comes to the fun part. We use Clean Architecture and DDD on our APIs, therefore our exceptions classes will vary depending on which layer threw the exception.
We know for a fact that most of the exceptions will come from the Infrastructure cause it's the layer we have the least control of. Thus, the standard $responseCode will be 500 as it was not the user's fault (line 32).
Value Objects and Entities live at the Domain layer on our APIs. In that layer, guess what, only exceptions of the DomainException type is thrown. 😁
Right of the bat, we convert the user input into Value Objects on the controllers. If you're not familiar with Clean Architecture, the controllers lives at the Presentation layer. Therefore if the user sent the wrong info, a DomainException will be thrown.
The controllers will also check if the required parameters were sent and throw BadMethodCallExceptions if not. That will indicate the user forgot to sent a required param.
In both cases we're dealing with a bad request cause it's either missing a param or the param is invalid. That's exactly why we set the $responseCode to 400 when the Error Handler Middleware is dealing with these kinds of exceptions (line 37).
With that approach, we didn't have to use a single try/catch block in the API controllers. They are still on the API, just not everywhere. 🧐
Where are the try/catch blocks?
Most, if not all of the try/catch blocks lives on our Infrastructure layer. That's because when the API receives the outputs from external sources such as databases and external APIs, it'll also try to convert them into Value Objects or Entities. Everything on the APIs are objects with strict validation rules thanks to DDD.
If the VOs/Entities creation process fails, the API received an unexpected output. There we'll have a try/catch block to try to deal with the exception, say attempt a second source, add the request into a queue or whatever is possible to mitigate the error, when it's possible.
When it's not possible to deal with the exception, the try/catch block will then convert the DomainException into a more suitable exception. Not disclosing too much, but enough so that anyone that reads have an idea of what could be going on.
There is more!
This approach's magic does not stop there. We also decide how much info we will provide to the user during the error (check line 43), avoiding accidentally leaking too much information to the public.
If the access came from the trusted networks, we can provide a full disclosure which speeds the debugging. There is also an error_log method to log the exceptions and all its details so we don't need to reproduce the issue in order to debug it.
You can continue expanding on that. You could send the exception to an e-mail, to Slack, to an Elasticsearch cluster. The sky is the limit. There is even an API which we use the Error Handler Middleware to translate the exception message. 🇧🇷
Comments