In the realm of software development, especially when using C#, it is critical to have a strong grasp on two key concepts: exceptions and results. Understanding how to properly implement and handle these two can greatly impact the efficiency, readability, and reliability of your code.

Exceptions and Their Importance

Exceptions are the standard way of signaling errors or exceptional cases in many programming languages, including C#. They offer a way to break the flow of execution when an unusual or unexpected condition is met. This disruption allows the program to handle the situation, possibly even recover, or at least, gracefully terminate the program, preventing it from falling into an inconsistent state.

Let’s illustrate this with a basic division method:

C#
public double Divide(double numerator, double denominator)
{
    if(denominator == 0)
        throw new DivideByZeroException("Denominator cannot be zero.");

    return numerator / denominator;
}
C#

In this scenario, the Divide method throws an exception if the denominator is zero. Using exceptions in this way makes it clear that this is an exceptional situation that should not normally occur, and if it does, it requires immediate attention.

Results and Their Value

While exceptions are powerful and indispensable, using them for control flow is not recommended due to their performance overhead and readability issues.

Instead of throwing an exception, methods can return a “result” type that encapsulates either a success with the resulting value or a failure with the reason for the failure. This approach encourages checking for and dealing with problems as part of the normal control flow, rather than as an exceptional situation.

Let’s reimagine the Divide method using a result approach:

C#
public Result<double> Divide(double numerator, double denominator)
{
    if (denominator == 0)
        return Result.Fail<double>("Denominator cannot be zero.");

    return Result.Ok(numerator / denominator);
}
C#

In this version, the Divide method returns a Result<double> type, which represents either a successful division or a failure reason. The Result type could be a custom struct or class in your codebase that encapsulates this logic. Here’s an example of what it could look like:

C#
public struct Result<T>
{
    public T Value { get; }
    public bool IsSuccess { get; }
    public string Error { get; }

    private Result(T value, bool isSuccess, string error)
    {
        Value = value;
        IsSuccess = isSuccess;
        Error = error;
    }

    public static Result<T> Ok(T value) => new Result<T>(value, true, null);

    public static Result<T> Fail(string error) => new Result<T>(default(T), false, error);
}
C#

Balancing Exceptions and Results

There’s no one-size-fits-all answer when choosing between exceptions and results. The appropriate technique depends on the situation.

When dealing with truly exceptional cases that are rare and indicate a serious issue, such as the system running out of memory, throwing exceptions makes sense. Exceptions provide a clear signal that something has gone seriously wrong and that the application cannot proceed normally.

Exceptions also make perfect sense when you want to validate the contract of a method. If for example we are passing a string as a parameter to a method that is used in domain layer as an abstraction, but the implementation is in infrastructure layer for which we require an infrastructure specific type, which the string is suppose to be converted into, if the format is invalid we can in fact throw an exception, because we can not fulfil the contract of the method. Same goes with the return value of such a method, if we can not for some reason fulfil the contract of the method (something that it is suppose to do) then we throw because it’s unexpected.

For error conditions that are expected as part of the normal operation of the program, returning result types tends to be a more suitable approach. It encourages the caller to explicitly handle these cases, makes the code more readable by making the error handling part of the main code flow, and avoids the performance overhead of exceptions.

Overuse of results & exceptions

Whether it’s exceptions or results, they can be used in the wrong way. And they when they do, they lose their purpose.

Here is an example of badly used results:

C#
class Program
{
    static void Main()
    {
        var operation1Result = Operation1();
        if (operation1Result.IsSuccess)
        {
            var operation2Result = Operation2();
            if (operation2Result.IsSuccess)
            {
                var operation3Result = Operation3();
                if (operation3Result.IsSuccess)
                {
                    Console.WriteLine(operation3Result.Value);
                }
            }
        }
    }

    static Result<string> Operation1()
    {
        // Some operation
        // Returns a Result
    }

    static Result<string> Operation2()
    {
        // Some operation
        // Returns a Result
    }

    static Result<string> Operation3()
    {
        // Some operation
        // Returns a Result
    }
}
C#

The biggest problem here is that when we have a complicated nested control flow, the operations are coupled to each other based on the outcome of the successful result, in turn we have nested if statements, which can get out of hand pretty quickly. Another issue is that we don’t actually do anything, when the operations failed, so in fact there is no error handling here. Cyclomatic complexity increases, which in turn also tools like sonarcloud or codescene will most likely complain.

How do we fix it?

We can invert the flow to log error message, and return when an error occurred, and proceed with operations only if we are successful.

C#
class Program
{
    static void Main()
    {
        var operation1Result = Operation1();
        if (!operation1Result.IsSuccess)
        {
            Console.WriteLine(operation1Result.Error);
            return;
        }

        var operation2Result = Operation2();
        if (!operation2Result.IsSuccess)
        {
            Console.WriteLine(operation2Result.Error);
            return;
        }

        var operation3Result = Operation3();
        if (!operation3Result.IsSuccess)
        {
            Console.WriteLine(operation3Result.Error);
            return;
        }

        Console.WriteLine(operation3Result.Value);
    }

    static Result<string> Operation1()
    {
        // Some operation
        // Returns a Result
    }

    static Result<string> Operation2()
    {
        // Some operation
        // Returns a Result
    }

    static Result<string> Operation3()
    {
        // Some operation
        // Returns a Result
    }
}
C#

This is now much better, although still, we have a lot of if statements, which isn’t always the easiest thing to read.

We can run into the same issue when using exceptions:

C#
class Program
{
    static void Main()
    {
        try
        {
            Operation1();

            try
            {
                Operation2();

                try
                {
                    Operation3();
                }
                catch (Exception ex)
                {
                    Console.WriteLine(ex.Message);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex.Message);
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.Message);
        }
    }

    static void Operation1()
    {
        // Some operation
        // May throw an exception
    }

    static void Operation2()
    {
        // Some operation
        // May throw an exception
    }

    static void Operation3()
    {
        // Some operation
        // May throw an exception
    }
}
C#

This is also bad, similar to the result case.

We can fix this by applying a try/catch at the top-most level of our code, and make sure we are throwing specific exception to our use case at the time. We should avoid throwing “Exception” or any internal exceptions.

C#
class Program
{
    static void Main()
    {
        try
        {
            Operation1();
            Operation2();
            Operation3();
        }
        catch (OperationException ex)
        {
            Console.WriteLine(ex.Message);
        }
    }

    static void Operation1()
    {
        // Some operation
        // May throw an exception
    }

    static void Operation2()
    {
        // Some operation
        // May throw an exception
    }

    static void Operation3()
    {
        // Some operation
        // May throw an exception
    }
}
C#

We can observe here, that the use of exceptions here looks much cleaner than using results in the above examples, we have a single try/catch instead of all those other if statements. In the next section we will analyse the performance of both approaches, as for many other developers this is a rather important factor.

Analysing the performance of both approaches

For testing this I have used a simple order class, which will be used for pseudo order processing to be used for our benchmark.

C#
public class Order
{
    public int OrderId { get; set; }
    public int CustomerId { get; set; }
    public DateTime OrderDate { get; set; }
    public List<OrderItem> Items { get; set; }
}

public class OrderItem
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}
C#

I have setup two classes, one with exceptions, where we have at the most top level a try catch.

C#
namespace ResultsExceptionsBenchmarkTest;

public class Exceptions
{
    public void ProcessOrder(Order order)
    {
        try
        {
            ValidateOrder(order);
            decimal total = CalculateTotal(order);
            PlaceOrder(order, total);
        }
        catch (OrderProcessingException ex)
        {
            Console.WriteLine($"Order processing failed: {ex.Message}");
        }
    }

    public void ValidateOrder(Order order)
    {
        if (order == null)
        {
            throw new OrderProcessingException("Order cannot be null");
        }
        // More validation logic here
    }

    public decimal CalculateTotal(Order order)
    {
        if (order.Items == null || order.Items.Count == 0)
        {
            throw new OrderProcessingException("Order must contain at least one item");
        }
        // More calculation logic here

        return 0;
    }

    public void PlaceOrder(Order order, decimal total)
    {
        // Code to place order here
    }

}

public class OrderProcessingException : Exception
{
    public OrderProcessingException(string message) : base(message) {}
}
C#

and the other with results. We can notice here that the control flow is slightly more complicated, since we need to check in an if statement if each operation was successful or not.

C#
namespace ResultsExceptionsBenchmarkTest;

public class Results
{
    public Result<Order> ProcessOrder(Order order)
    {
        var validation = ValidateOrder(order);
        if (!validation.Success) return Result<Order>.Fail(validation.ErrorMessage);

        var calculation = CalculateTotal(order);
        if (!calculation.Success) return Result<Order>.Fail(calculation.ErrorMessage);

        var placement = PlaceOrder(order, calculation.Value);
        if (!placement.Success) return Result<Order>.Fail(placement.ErrorMessage);

        return Result<Order>.Ok(order);
    }

    public Result<Order> ValidateOrder(Order order)
    {
        if (order == null)
        {
            return Result<Order>.Fail("Order cannot be null");
        }
        // More validation logic here
        return Result<Order>.Ok(order);
    }

    public Result<decimal> CalculateTotal(Order order)
    {
        if (order.Items == null || order.Items.Count == 0)
        {
            return Result<decimal>.Fail("Order must contain at least one item");
        }
        // More calculation logic here
        // let's assume the total price is just a sum of all item prices
        decimal total = order.Items.Sum(item => item.Price * item.Quantity);
        return Result<decimal>.Ok(total);
    }

    public Result<bool> PlaceOrder(Order order, decimal total)
    {
        // Code to place order here, let's assume it was successful
        return Result<bool>.Ok(true);
    }
}

public struct Result<T>
{
    public T Value { get; private set; }
    public bool Success { get; private set; }
    public string ErrorMessage { get; private set; }

    public static Result<T> Ok(T value) 
    {
        return new Result<T> 
        { 
            Value = value, 
            Success = true 
        };
    }
    
    public static Result<T> Fail(string message) 
    {
        return new Result<T> 
        { 
            ErrorMessage = message, 
            Success = false 
        };
    }
}
C#

Then I used library BenchmarkDotNet to run the benchmark 5000 times against both exceptions and results.

Exception vs Results with BenchmarkDotNet

We can see based on the results that indeed, exceptions can be more expensive, however whether this is something that is going to affect you depends on the application you are building, probably in most cases, such as an API, this will not be noticeable for the end user. 185,877.44 ns converted is 0.00018587744 seconds. Instead of throwing exceptions away altogether, instead perhaps we can combine exceptions and results or use either, depending on the needs of the project.

Conclusion

In conclusion, understanding the nuances of exceptions and results is vital for any C# programmer. By using exceptions judiciously and leveraging result types, developers can create code that is not only efficient and reliable, but also elegant and easy to understand. It’s about using the right tool for the right job, and balancing these two techniques according to the needs of your specific context.

Related Post

Leave a Reply

Your email address will not be published. Required fields are marked *