Exceptions and try-catch blocks considered harmful
Why you should read this:
-
You will learn the way Viventio deals with errors in software engineering projects, does not matter the technology you're using.
-
You will understand about the CTO's perspection on the matter. And maybe I can convince yourself about the topic as well.
The Context
This is more like a personal rant than a guideline, but I'll try to explain why I think exceptions and try-catch blocks are not a good idea most of the time for handling errors does not matter the technology you're using.
Programming languages, such as Java and C#, have a built-in mechanism for handling errors. In these languages, errors, or exceptions as they often call, are represented as containers that holds informations about the error, such as a message, a stack trace, and sometimes even the line number where the error occurred.
try {
// Code that may throw an exception
} catch (Exception e) {
// Handle the exception
}
In this example, the code inside the try block is executed. If an exception
is thrown, the code inside the catch block is executed. The catch block
receives the exception as a parameter, which can be used to handle the error.
If you went to college, you have probably used a lot of programming languages for your project assignments, and let me guess: most of them are object-oriented and relies heavily on exceptions for handling errors. The whole problem in it is that we grow up with the idea that exceptions are the only way to handle errors, but they are not. The same idea applies to developers who grow up in the modern world of web software development, and thinks that the only way of building a web application is by using React or any other framework.
The problem
So, okay. I've showed you exceptions and try-catch blocks. Nothing too fancy. But what's the problem with them? Well, let's take a look at an example.
public static void processOrderTraditional(String orderId) {
try {
Order order = fetchOrder(orderId);
Customer customer = fetchCustomer(order.customerId);
PaymentMethod payment = fetchPaymentMethod(customer.id);
Inventory inventory = checkInventory(order.productId);
processPayment(payment, order.amount);
updateInventory(inventory, order.quantity);
sendConfirmation(customer.email);
System.out.println("Order processed");
} catch (Exception e) {
// WHERE did this fail? Who knows!
// Was it fetchOrder? fetchCustomer? processPayment?
// The error could be from ANY of these function calls!
System.err.println("Order failed: " + e.getMessage());
}
}
As you can see, it is not that easy to figure out where the error occurred.
Also, wherever the error ocurrs, it will cause a hard jump to the catch
block, forcing you to lose the control of the program, as if every single
exception is fatal. Also, let's say you're trying to open a file and you
provide a wrong path, certainly you will get an exception related, something
like FileNotFoundException, and the control of the program will jump to the
catch block, but what if it is okay in the context of your program that the
file does not exists? You could easily handle this error if, without losing the
control of the program, you could check if the error is related to the file not
found, and if it is, you could create a file right away or do anything you
want.
However, you know, people are really smart, isn't it? And they know that they can define pretty specific exceptions classes for each function call. Error handling with exceptions sacrifices the locality of behavior (LoB), and it is not that easy to figure out where the error occurred.
Okay, okay... I admit, you are right, you really got me on this one. Exceptions are amazing, and they are really great for handling errors. Let me fix this silly mistake from my previous example.
public static void processOrder(String orderId) {
try {
Order order = fetchOrder(orderId); // throws DatabaseException
Customer customer = fetchCustomer(order.customerId); // throws DatabaseException
PaymentMethod payment = fetchPaymentMethod(customer.id); // throws DatabaseException
Inventory inventory = checkInventory(order.productId); // throws DatabaseException
// These throw different exceptions
processPayment(payment, order.amount); // throws PaymentException
updateInventory(inventory, order.quantity); // throws InventoryException
sendConfirmation(customer.email); // throws EmailException
System.out.println("Order processed successfully");
} catch (DatabaseException e) {
// PROBLEM: Which database operation failed?
// - fetchOrder?
// - fetchCustomer?
// - fetchPaymentMethod?
// - checkInventory?
// We caught it, but we STILL don't know where it came from!
System.err.println("Database error: " + e.getMessage());
} catch (PaymentException e) {
// At least this one is specific... or is it?
// In a real codebase, processPayment() might call 5 other methods
// that also throw PaymentException. Which one failed?
System.err.println("Payment error: " + e.getMessage());
} catch (InventoryException e) {
// Same problem - updateInventory() might call multiple methods
System.err.println("Inventory error: " + e.getMessage());
} catch (EmailException e) {
System.err.println("Email error: " + e.getMessage());
}
}
Jesus... That's looks so amazing, right?... Does it? Now, we have another
problem: on a real codebase, you might have a lot of functions that throw the
same exception type. Often times, you will call multiple functions that throw
the same exception type, and you will have to handle them all in the same
catch block.
I am sorry, but I guess it is not that simple.
Wait a second... What? Now you are saying that there is a way to handle them?
How? Well, I mean... just we could just wrap each function with its own
try-catch block, right?
public static void processOrder(String orderId) {
Order order = null;
try {
order = fetchOrder(orderId);
} catch (DatabaseException e) {
System.err.println("Failed at fetchOrder: " + e.getMessage());
return;
} catch (IllegalArgumentException e) {
System.err.println("Failed at fetchOrder (validation): " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at fetchOrder (runtime): " + e.getMessage());
return;
}
Customer customer = null;
try {
customer = fetchCustomer(order.customerId);
} catch (DatabaseException e) {
System.err.println("Failed at fetchCustomer: " + e.getMessage());
return;
} catch (CustomerException e) {
System.err.println("Failed at fetchCustomer (not found): " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at fetchCustomer (runtime): " + e.getMessage());
return;
}
PaymentMethod payment = null;
try {
payment = fetchPaymentMethod(customer.id);
} catch (DatabaseException e) {
System.err.println("Failed at fetchPaymentMethod: " + e.getMessage());
return;
} catch (PaymentException e) {
System.err.println("Failed at fetchPaymentMethod (expired): " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at fetchPaymentMethod (runtime): " + e.getMessage());
return;
}
Inventory inventory = null;
try {
inventory = checkInventory(order.productId);
} catch (DatabaseException e) {
System.err.println("Failed at checkInventory: " + e.getMessage());
return;
} catch (InventoryException e) {
System.err.println("Failed at checkInventory (out of stock): " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at checkInventory (runtime): " + e.getMessage());
return;
}
try {
processPayment(payment, order.amount);
} catch (PaymentException e) {
System.err.println("Failed at processPayment: " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at processPayment (runtime): " + e.getMessage());
return;
}
try {
updateInventory(inventory, order.quantity);
} catch (InventoryException e) {
System.err.println("Failed at updateInventory: " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at updateInventory (runtime): " + e.getMessage());
return;
}
try {
sendConfirmation(customer.email);
} catch (EmailException e) {
System.err.println("Failed at sendConfirmation: " + e.getMessage());
return;
} catch (RuntimeException e) {
System.err.println("Failed at sendConfirmation (runtime): " + e.getMessage());
return;
}
System.out.println("Order processed successfully");
}
WOW!! That's perfect, right? Look at the amount of try-catch blocks that you have, the amount of indentation blocks. That looks like a poem to me!
Of course, I am being pretty sarcastic here. This code does not look good at all, there are too many try-catch blocks, and the indentation is not readable at all.
The [Possible] Solution
So, what can we do about it? Try-catch blocks and exceptions are pretty much the standard, especially when it comes to object-oriented programming languages such as Java.
I am going to show you a way to handle errors in a more elegant way, and without the need of try-catch blocks, without sacrificing readability and the locality of behavior, which is truly important and underrated in software engineering when it comes to proper understading of the code you're reading for yourself.
The idea is to treat errors as simple values, just like Golang does. One of the authors of Golang, Rob Pike, has a great post about considering errors as values in the context of the Go programming language. In this article, he basically explains about how Go handles errors and the answer for that is pretty simple: errors are values, and just like any other value, they can be passed around, returned, and stored. In Golang, if you want to say that an error occurred, you just return it from a function.
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
The last line of the function is the important one. It returns the result of
the division and nil (think about it as undefined or NULL, for simplicity
purposes) if there was no error.
Another programming language that handles errors using a different style is
Rust, which is also a systems programming language. In Rust, errors are
represented as a type called Result, which is an enum that can either be Ok
or Err.
#![allow(unused)] fn main() { fn divide(a: i32, b: i32) -> Result<i32, String> { if b == 0 { return Err(String::from("division by zero")); } return Ok(a / b); } }
In this example, the function returns a Result that can either be Ok with
the result of the division or Err with a string that describes the error.
This style is heavily inspired on function programming languages like Haskell
with the Maybe data type.
Okay, I already showed you how to handle errors using a different style that does not requires try-catch blocks and exceptions. However, how does this help me to solve the problem of losing the locality of behavior or even losing the control of the program whenever an error occurs?
Considering the Go code example, the function divide returns two values: the
result of the division and an error. Let's say that the function is called with
the arguments 10 and 0, and the error is division by zero.
result, err := divide(10, 0)
In this example, the result of the division is 0, and the error is division by zero. It is totally possible to just check the error and decide what to do
with it, including letting the program continue or crash. In other words, YOU,
the PROGRAMMER, not the PROGRAM, decide if the error is fatal or not.
result, err := divide(10, 0)
if err != nil {
if err.Error() == "division by zero" {
// DECIDE WHAT TO DO HERE
}
}
Another thing: what does this have do with the locality of behavior? Well, as
you can see, the error is handled right after the function call, there is no
catch block to grap the error. You know exactly by reading this small portion
of the code what the code is doing and how the code is dealing with error (if
any). The idea of the locality of behavior is truly preversed since the
definition by Richard Gabriel in "Patterns of Software: Tales From the
Software Community" "is
that characteristic of source code that enables a programmer to understand that
source by looking at only a small portion of it".
Okay, this strategy is by far my favorite, and I am not going to lie. In order
to apply those patterns in the technologies that Viventio uses, I've decided to
introduce new packages in the Typescript and Python ecossystem. So I've created
result.ts and result.py packages that have [almost] the same API for
handling errors using the same philosophy of Rust and Golang.
Feel free to take a look:
- @hicarod0/result.ts - Typescript
- pytresult - Python
Both packages together have more than 1.5k downloads, and they are used in production in Viventio projects.