
Try Less
Something that I’ve encountered more and more as I have moved from academic research to enterprise software is densely filled try blocks. I think that this tendency comes from a good place:
This code might fail, so if there is an exception how I should describe some kind of behavior so that the program can exit cleanly
The problem becomes when this well-intended philosophy makes error behavior inconsistent and hidden to the end user and other programmers.
Let’s look at an example where we were writing committing an order purchase.
function submitPurchase(user, order) :
try:
order.validate() // lets make sure that the order is valid
// Use api to validate street
AddressVerificationClient(order.contact).validate()
// Use api to charge account
PaymentClient(user.info).charge(order.amount)
order.markSuccessful() // Commit changes to db
log(“Order purchased successfully”, order)
return order
catch Exception e:
log(“Unable to process order”, order)
return null
I think people underestimate how often code like this is committed to production branches. Again coming from a good place the programmer understands that something in this block may not behave as expected but any procedure that calls submitPurchase() won’t be able to understand what caused the issue. I try to think like this when I write code
This code will fail at some point. What are the points of exposure to unpredicted behavior and how will I handle them?
As a result, I come away with two takeaways:
Keep try blocks small.
- If there are more than one or two points of failure then I need to refactor the block or the internal methods.
- Let code fail. If something is internal to this codebase then don’t define an error behavior every time the code is called but rather one time globally. This leads to consistent known error behavior across the application and exposes new errors as they appear.
Let’s revisit submitPurchase(...) with these principles in mind.
function submitPurchase(user, order) :
try:
order.validate()
catch OrderValidationException e:
throw e
try:
AddressVerificationClient(user.contact).validate()
catch AddressValidationException e:
// We did everything right but the address we were given is not correct.
// Since our API offers alternative address suggestions we should surface
// those if they are available.
if (e.alternative):
throw new AlternativeAddressFound(e.alternative)
else:
throw e
catch RequestException e:
// Either the API or our network connection is down :(.
// Since this is an order we might want to define some
// custom behavior to retry after a delay
throw e
try:
PaymentClient(user.paymentInfo).charge(order.amount)
catch InvalidCardExcpetion e:
// Looks like the user made a mistake when entering the card
// details, lets update the record so we don’t try it
// again and surface a message to the user
user.paymentInfo.valid = false
throw new PaymentException(“The payment info you entered is not valid, please review the card and billing address and try again. ”)
catch InsufficientFundsException e:
// The bank has a blunt error copy that we don’t want to
// display to our users because it clashes with the “voice”
// of our brand. Let’s surface our own error // message
throw new PaymentException(“Hrm, that card is valid but does not seem to work. Do you have a different card you can try?”)
catch RequestException e:
// Due to PCI compliance we are not supposed to retry
// payment requests so we should surface this network issue
throw e
log(“Order purchased successfully”, order)
order.markSuccessful() //Db transaction
return order
Writing code like this requires a lot more effort, experimentation, and foresight but it has some obvious benefits to end users, future programmers that may want to change the behavior and operations.
In the first example, there was no granularity between error states, the user could have entered an invalid address or the payment processor could be down but there was no way to tell. Additionally, there was no error behavior being reported but rather there was a null return leading whomever implements submitPurchase(...) to go back and revalidate the order to try and determine error messaging.
In the second example not only were the different error states called out but there is defined behavior for each one. If the card is not valid we not only throw a contextual error with our own copy but also we have the opportunity to mark the method invalid so we don’t have to communicate with the payments API again to attempt any purchase on this card.
Lastly, you’ll notice that the marking of the order as successful is outside of the try block. That is because in this example we note that this method is simply a DB transaction. If we cannot persist valid data to our store we should define an exception and behavior for that scenario across our application.
There is a lot of rhetoric around happy/sad path testing but with transient or unexpected errors we need to go beyond this binary. Instead, we need to expect the unexpected and anticipate ways our code will fail so we can define individual behaviors that provide context for ourselves and our users.