Error handling

What should I keep in mind when designing the error handling architecture for my application?

There are a bunch of best practices that are all good guidelines. My favorite 4 are:

  1. Isolate application business code from exception handling.

  2. Handle the exception as soon as you have sufficient context (which might mean that you need to rethrow the exception).

  3. Avoid silent failure at all costs (and add some extra debug logs while you’re at it).

  4. Use custom exceptions containing extra context.

Some examples can be good to show the point.

1 - Isolate application business code from exception handling.

I think that error handling code looks ugly. At least the try-catch block, I never liked it. And the other versions, I like even less. But they are necessary, so the idea is to have it, but in a separate part of the code. Let’s see 2 examples.

This version is mixing business code and exception handling:

fun fetchMySuperHeroName(dateOfBirth: String): String {
    // Parse the dateOfBirth string to extract the month
    val month = try {
        dateOfBirth.split("-")[1].toInt()
    } catch (e: Exception) {
        throw IllegalArgumentException("Invalid date format. Please use 'YYYY-MM-DD'")
    }

    // Determine the superhero name based on the month
    return if (month in 1..6) {
        "Captain Justice" // Hero for the first half of the year
    } else if (month in 7..12) {
        "Shadow Avenger" // Hero for the second half of the year
    } else {
        throw IllegalArgumentException("Invalid month in dateOfBirth")
    }
}

This other version has a better separation of concerns:

fun fetchMySuperHeroName(dateOfBirth: String): String {
    val month = extractMonth(dateOfBirth)
    require(month in 1..12) { "Invalid month: $month. Month must be between 1 and 12." }
    // require will throw IllegalArgumentException as well

    // Determine the superhero name based on the month
    return if (month in 1..6) {
        "Captain Justice" // Hero for the first half of the year
    } else {
        "Shadow Avenger" // Hero for the second half of the year
    }
}

fun extractMonth(dateOfBirth: String): Int {
    val month = try {
        dateOfBirth.split("-")[1].toInt()
    } catch (e: Exception) {
        throw IllegalArgumentException("Invalid date format. Please use 'YYYY-MM-DD'")
    }
    return month
}

I still think both versions are ugly, but at least the second version is a bit better to read in my opinion.

2 - Handle the exception as soon as you have sufficient context.

This principle is good to force you to have the big picture of your application. What do you want to happen in case things don’t go right? Is your service answering to someone? Can you provide sufficient information for whoever needs it?

Here’s one example where the exception is not handled at the appropriate place and instead just rethrown:

class CarDiagnosticsProcessor {

    fun processFile(filePath: String) {
        try {
            val fileContent = readFile(filePath)
            parseDiagnostics(fileContent)
        } catch (e: Exception) {
            // Bad: Rethrowing the generic exception without any additional context
            throw e
        }
    }

    private fun parseDiagnostics(content: String) {
        if (content.contains("MALFORMED")) {
            throw Exception("The file content is malformed.")
        }
    }
}

fun main() {
    val processor = CarDiagnosticsProcessor()
    try {
        processor.processFile("diagnostics_2024.json")
    } catch (e: Exception) {
        println("Error: ${e.message}") // Lacks any mention of the file path
    }
}

And now a better scenario, where a well-built exception is thrown and caught, and extra context is added (file path):

class FileParsingException(message: String, cause: Throwable? = null) : Exception(message, cause)

class CarDiagnosticsProcessor {
    fun processFile(filePath: String) {
        try {
            val fileContent = readFile(filePath)
            parseDiagnostics(fileContent)
        } catch (e: FileParsingException) {
            // Add context about the file and rethrow
            throw FileParsingException("Failed to parse diagnostics file: $filePath", e)
        }
    }

    private fun parseDiagnostics(content: String) {
        if (content.contains("MALFORMED")) {
            throw FileParsingException("The file content is malformed.")
        }
    }
}

3 - Avoid silent failure at all costs

This is also self-explanatory. If something goes wrong, you want to know the root cause. The worst you can do is fail silently and keep wondering forever what might have happened.

Here’s an example of a very bad scenario:

class PaymentProcessingException(message: String) : Exception(message)

class CarRentalService {

    fun rentCar(carId: String, userId: String, amount: Double) {
        try {
            processPayment(userId, amount)
        } catch (e: PaymentProcessingException) {
            // Bad: The exception is logged and forgotten
            println("Error occurred: ${e.message}")
            // No rethrow or added context, so the caller doesn't know the payment failed
        }
    }

    private fun processPayment(userId: String, amount: Double) {
        throw PaymentProcessingException("Insufficient funds.")
    }
}

Here’s a somewhat better scenario:

class PaymentProcessingException(message: String, cause: Throwable? = null) : Exception(message, cause)

class CarRentalService {

    fun rentCar(carId: String, userId: String, amount: Double) {
        try {
            processPayment(userId, amount)
        } catch (e: PaymentProcessingException) {
            // Handle with sufficient context: Add carId to the exception and rethrow
            throw PaymentProcessingException(
                "Failed to process payment for Car ID: $carId and User ID: $userId",
                e
            )
        }
    }

    private fun processPayment(userId: String, amount: Double) {
        // Simulating a payment failure
        throw PaymentProcessingException("Payment gateway timed out.")
    }
}

fun main() {
    val service = CarRentalService()
    try {
        service.rentCar("CAR123", "USER456", 200.0)
    } catch (e: PaymentProcessingException) {
        println("Error: ${e.message}") // Includes additional context: carId and userId
    }
}

4 - Use custom exceptions containing extra context

This one almost didn’t make the list. For sure it is not one of my favorites, but I felt like mentioning it. The reason why I included it is because it forces you to think of the possible errors you might get, which is a good thing.

To explain in a few words, when writing code that can fail, we shouldn’t be throwing a generic exception like “Error” with some message, but rather we should throw a well thought-out and expected exception like “CarNotFoundError” with details of said car (in the context of a car handling application in case it wasn’t clear enough).

So here’s a bad example:

fun rentCar(carId: String, requestedSeats: Int, balance: Double, cost: Double) {
        if (carId.isBlank()) {
            throw Exception("An error occurred: Car not found")
        }
        if (requestedSeats > 5) {
            throw Exception("An error occurred: Exceeded capacity")
        }
        if (balance < cost) {
            throw Exception("An error occurred: Insufficient funds")
        }
        println("Car rented successfully!")
    }

And here’s a better example:

fun rentCar(carId: String, requestedSeats: Int, balance: Double, cost: Double) {
    if (carId.isBlank()) {
        throw CarError.CarNotFoundError(carId)
    }
    if (requestedSeats > 5) {
        throw CarError.CapacityExceededError(requestedSeats, maxSeats = 5)
    }
    if (balance < cost) {
        throw CarError.InsufficientFundsError(balance, cost)
    }
    println("Car rented successfully!")
}

sealed class CarError(message: String) : Exception(message) {

    // Error for when a car is not found in the system
    class CarNotFoundError(val carId: String) : 
        CarError("Car with ID $carId not found.")

    // Error for when the rental request exceeds car capacity
    class CapacityExceededError(val requestedSeats: Int, val maxSeats: Int) : 
        CarError("Requested $requestedSeats seats, but the car supports a maximum of $maxSeats seats.")

    // Error for when there are insufficient funds for a transaction
    class InsufficientFundsError(val balance: Double, val requiredAmount: Double) : 
        CarError("Insufficient funds: Balance $balance, but $requiredAmount is required.")
}

Final thoughts

Error handling can be a controversial theme, but I think most devs can agree on a few things: we want the application code to be clean (or at least easy to maintain in the future), and we want errors to be very explicit (so we know exactly what happened and react accordingly). As long as we keep these things in mind, following a few best practices will get us to a good place.