Rethinking exceptions in .NET

.NET

269 views

Rethinking exceptions in .NET blog meme

I've recently undergone a courtship with Go and Rust, diving into each ecosystem in my spare time and even contributing to a few open source projects here and there in each language. Being a .NET developer by day, I interact primarily with code and services written by other developers that may include quite a bit of control flow via throwing exceptions when any erroneous scenario or non-desirable application state arises. While this may be the norm in .NET and quite frankly what .NET/Java developers hardened by the enterprise have been trained for years to do as a first action during a fallible process, I've been conscientiously forcing myself to properly handle said fallible situations in a manner similar to what both Go and Rust offer in regards to the error handling developer experience.

For those not familiar with Go or Rust, a fallible method (simply a function that could return an error) might look something like:

func SomeFallibleFunction(name string) (string, error) {
    if name != "Joey" {
        return "", errors.New("That's not the name I was expecting!")
    }

    return "Hello, Joey!", nil
}

in Go, or similarly in Rust:

fn some_fallible_function(name: &str) -> Result<&str, &str> {
    if name != "Joey" {
        return Err("That's a terrible name!");
    }

    Ok("Nice to meet you, Joey!")
}

Running each of these examples in an executable context, it may look something like the following:

package main

import (
  "errors"
  "log"
)

func main() {
    if result, err := SomeFallibleFunction("Joey"); err != nil {
        log.Printf("An error occurred: %v", err)
    } else {
        log.Printf(result)
    }
}

func SomeFallibleFunction(name string) (string, error) {
    if name != "Joey" {
        return "", errors.New("That's not the name I was expecting!")
    }

    return "Hello, Joey!", nil
}

with output along the lines of:

Hello, Joey!

Now, the Rust equivalent in its full glory:

fn main() {
    let result = some_fallible_function("Joey");

    if let Ok(response) = result {
        println!("{}", response);
    } else {
        println!("An error occurred: {}", result.unwrap_err());
    }
}

fn some_fallible_function(name: &str) -> Result<&str, &str> {
    if name != "Joey" {
        return Err("That's a terrible name!");
    }

    Ok("Nice to meet you, Joey!")
}

and running a simple cargo run in the command line of your choice produces the following:

Nice to meet you, Joey!

While each of these examples may seem a bit contrived, each conveys an idea that is core to either language in proper error handling: explicitly signifying to callers that the method is fallible! Fallible methods can be thought of as a contract between caller and callee - the caller understands that the callee may produce an undesirable result or state that should be handled responsibly by the caller. Unfortunately, .NET does not have a similarly equivalent error handling experience akin to Go or Rust in the base class library, and while not a fault of the language itself, it can be rather annoying to deal with codebases where the default error handling precedent set by previous developers might be throwing exceptions at every corner and littering said codebase with try/catch blocks often accompanied by some form of global exception handler to swallow any unexpected exceptions that arise outside of our error handling blocks.

What this leads to, more often than not, is a breakdown in an application or service's architecture as we're allowing control flow via exceptions (pause for audible gasps). If you've ever been exposed to languages with GOTO statements (I cut my teeth with Fortran as my first real language during my undergrad), one can make the argument that exceptions as a form of control flow is nothing more than a sophisticated GOTO statement in an application or service. There's a plethora of reasons you won't see named or GOTO statements in many modern languages, applications, and codebases but I'll primarily make the argument that it makes code difficult to read, maintain, and extend as sensible control flow is essentially thrown out the window as we're allowing the flow of execution to sporadically jump lines anytime we see fit.

Rusti-fying our .NET code

I'm not a fan of throwing exceptions, and I've been making the conscious effort to force myself to handle errors in a sane fashion as they arise rather than propagating them up the stack by throwing them anytime an undesirable state in my application code is reached. As an experiment, let's take a look at Rust' s std::result::Result type in an effort to take a monadic approach to wrap a method's outcome with a bit of metadata about the response. In a simple .NET 7 console app, let's define a result type that captures information about the desired resulting data should the method succeed, i.e. no exceptions occur, and also carries a bit of information about what types of errors we should expect in the case our processing fails:

namespace ExceptionAlternatives;

internal class Result<TData, TError>
{
    private readonly TData? _data;

    private readonly TError? _error;

    public static Result<TData, TError> Ok(TData data) => new(data);

    public static Result<TData, TError> Err(TError error) => new(error);

    private Result(TData data) => _data = data;

    private Result(TError error) => _error = error;

    public bool IsOk => _data is not null && _error is null;

    public bool IsErr => !IsOk;

    public TData Unwrap()
    {
        if (_data is null)
        {
            throw new InvalidOperationException("Result data is null and cannot be accessed.");
        }

        return _data;
    }

    public TError UnwrapErr()
    {
        if (_error is null)
        {
            throw new InvalidOperationException("Result error is null and cannot be accessed.");
        }

        return _error;
    }
}

Let's breakdown what our Result type is encapsulating for us:

  1. We provide two generic arguments in the form of TData and TError so that callers are aware of the type context a fallible function may return
  2. We internally track the state of the resulting data and any error that may occur as a result (no pun intended) of the processing that goes on in our method
  3. We'll provide some simple Result type constructing methods to assist our methods in building our Result correctly without said methods having to worry about how to instantiate our Result type correctly, i.e. correct-by-construction
  4. We provide some simple utilities to peek at the result data without having to directly deref any of our internally tracked Result state with the IsOk and IsErr auto-props
  5. Finally, should our callers do their due diligence and confirm their Result either succeeded or failed, we provide a couple of methods to expose the data or error encapsulated by our Result object with the Unwrap() and UnwrapErr() methods

Using our Result in a contrived example, it may look something like the following out in the wild:

using ExceptionAlternatives;

var successfulProcessing = await DoSomeFallibleProcessingThatSucceeds();
var failedProcessing = await DoSomeFallibleProcessingThatFails();

Console.WriteLine($"Result of {nameof(successfulProcessing)}");
Console.WriteLine($"Successful? - {successfulProcessing.IsOk}");
Console.WriteLine($"Errors? - {successfulProcessing.IsErr}");
Console.WriteLine($"Successful result: {successfulProcessing.Unwrap()}");

Console.WriteLine($"\nResult of {nameof(failedProcessing)}");
Console.WriteLine($"Successful? - {failedProcessing.IsOk}");
Console.WriteLine($"Errors? - {failedProcessing.IsErr}");
Console.WriteLine($"Error result: {failedProcessing.UnwrapErr()}");

async Task<Result<int, string>> DoSomeFallibleProcessingThatSucceeds()
{
    // Act like we're doing something...
    await Task.Delay(TimeSpan.FromMilliseconds(500));

    return Result<int, string>.Ok(42);
}

async Task<Result<int, string>> DoSomeFallibleProcessingThatFails()
{
    // Act like we're doing something again, but takes a bit longer...
    await Task.Delay(TimeSpan.FromMilliseconds(1000));

    return Result<int, string>.Err("Oh no! Processing failed :(");
}

Running our code, we see the following printed out in the console:

Result of successfulProcessing
Successful? - True
Errors? - False
Successful result: 42

Result of failedProcessing
Successful? - False
Errors? - True
Error result: Oh no! Processing failed :(

Sweet! While the first set of processing results may not be all that interesting, our second attempt at processing failed and provided our context of the failure without throwing an exception and muddying up our code with unnecessary try/catch blocks! While this feels a lot better (personally) to code against in a real-world scenario, we still need to address one bit of code in our Result type.

We still throw an exception when a user attempts to Unwrap() the Result's internal data in the case that data does not exist. Similar to Rust's std::result::Result type which panic's (Rust's version of crashing an application) in the case a caller attempts to access said data, we throw in an attempt to protect the Result object from handling up invalid or null data. In our case, our Result type expects a non-null data type to be the underlying data context in our Result object. We could extend this to allow for handling nullable values, but I'll leave that as an exercise for the reader.

Secondly, in our DoSomeFallibleProcessingThatFails() method, we're not guaranteed the ongoing processing will not panic inside that method - we expect that all the stuff that goes on in that method is itself infallible which may not be the case. If we we're talking to a database, for example, an exception outside of our code (maybe internal to the BCL) may be thrown in the case the connection string is malformed or our result set can't be mapped. We'd most likely be better off wrapping the execution context of DoSomeFallibleProcessingThatFails() in a try/catch block, returning an Ok result if all goes well while passing back an Err in the case our catch block needs to execute logic.

Throwing exceptions when it matters

While I've been touting that we should refrain from throwing exceptions in our .NET code, that does not mean that we should never throw exceptions; simply put, throw exceptions when it matters.

How should we determine when and where to throw exceptions and use try/catch blocks? For me, the age-old question is accompanied by the age-old answer - it depends.

Does our application require loading in some critical configuration to properly run without error? Probably good to throw on startup if our configuration can't be found, read properly, or loaded into application memory/cached for whatever reason.

Do we rely on calls to third-party libraries or APIs that don't necessarily share our same radical Result-based ideology? That's a great use case for surrounding that bit of connecting code with a try/catch while internally propagating those outbound results as a Result type in our application code.

Wrapping up

Exceptions have their time and place - as I've grown in my software career, I find that my personal developer growth comes in the form of identifying when and where it may be appropriate to throw and surround bits of code in try/catch blocks rather than relying on them as a crutch for ease of control flow. What this translates to, more or less, is forcing callers and callees to properly handle error cases as they arise rather than making it the next stack frame's problem. Unfortunately, humans are not perfect. Code is written by humans (most of the time), and therefore can be imperfect itself, not accounting for erroneous scenarios that might be outside our peripheral.

Until next time, friends!

Not currently listening