What to return when you don't have anything to return?

Not finding your data that you are trying to fetch via Guid Id can be handled elegantly either as a case of domain error or just as a regular case that you’re expecting to happen. We will only go in-depth with domain error in this article and do a quick overview on regular cases.

Domain error

When fetching data we can handle domain errors with our own custom Result type which also includes an error.

But what is a “domain error”?

In software development, a “domain error” happens when there’s a mistake or issue because the rules or limitations of a particular area, or situation, that the software is designed for are not followed properly. For example, if the software is made to handle banking transactions but does something against banking rules, that’s a domain error.

If you’re working with a system where each entity is uniquely identified by a GUID (Globally Unique Identifier), not finding an entity by its GUID could indicate a more serious issue. This might be because the data doesn’t exist, it’s not accessible for some reason, or there’s an inconsistency in the data integrity.

In such scenarios, using a Result type that includes an error is beneficial. The Result type, commonly found in functional programming - and increasingly in other paradigms - is a way to encapsulate either a successful outcome (with the expected data) or an error (with details about what went wrong). This approach is more informative than simply returning null or an optional object because it provides explicit information about the nature of the error, which is critical for debugging and handling exceptional cases properly.

Handling a returned Result type without resorting to branching logic (like if or switch statements) often involves leveraging higher-order functions or patterns such as monadic binding. In C#, this can be achieved through extension methods that abstract away the branching logic. It’s still there, but hidden away for the most part. You don’t get the branching logic vomited all over your classes which should be focused on other things.

Let’s consider a hypothetical Result<T, E> type, where T is the type of the value in case of success, and E is the type of the error. We will define extension methods like OnSuccess and OnError to handle each case.

Here’s an example implementation:

public class Result<T, E> {  
    public T Value { get; private set; }  
    public E Error { get; private set; }  
    public bool HasValue { get; private set; }  
  
    // Constructors  
    public Result(T value) {  
        Error = default!; // Replace with what you want to do here
        Value = value;  
        HasValue = true;  
    }  
  
    public Result(E error) {  
        Error = error;  
        Value = default!; // Replace with what you want to do here 
        HasValue = false;  
    }  
}

public static class ResultExtensions {  
  
    // Extension method for success case.  
    public static Result<T, E> OnSuccess<T, E>(this Result<T, E> result, Action<T> action) {  
        if (result.HasValue) {  
            action(result.Value);  
        }  
        return result;  
    }  
  
    // Extension method for error case.  
    public static void OnError<T, E>(this Result<T, E> result, Action<E> action) {  
        bool errorHasOccurredHasNoValue = !result.HasValue;  
        if (errorHasOccurredHasNoValue) {  
            action(result.Error);  
        }  
    }  
  
}  

public sealed class ResultTypeTest {  
  
    private readonly Guid _correctId = Guid.NewGuid();  
    private readonly Guid _wrongId = Guid.NewGuid();  
  
    private readonly Database _database;  
  
    public ResultTypeTest() {  
        _database = new (_correctId);  
    }  
  
    public void Test() {  
  
        // Database has been set up with data that we should be able to fetch with _correctId,  
        // whereas _wrongId should return an error because data by that id does not exist.        Result<SomeData, DomainError> correctResult = GetData(_correctId);  
        Result<SomeData, DomainError> wrongResult = GetData(_wrongId);  
  
        correctResult.OnSuccess(data => {  
            // Do something with data  
            Console.WriteLine($"DEBUG: correctResult: Data fetched successfully, data.Id: {data.Id}");  
        }).OnError(error => {  
            // Do something with error  
            Console.WriteLine($"DEBUG: correctResult: Error fetching data, error message: {error.Message}");  
        });  
  
        wrongResult.OnSuccess(data => {  
            // Do something with data  
            Console.WriteLine($"DEBUG: wrongResult: Data fetched successfully, data.Id: {data.Id}");  
        }).OnError(error => {  
            // Print error  
            Console.WriteLine($"DEBUG: wrongResult: Error fetching data, error message: {error.Message}");  
        });  
  
    }  
  
    private Result<SomeData, DomainError> GetData(Guid id) {  
        bool dataExists = _database.CheckDataExists(id);  
  
        if (dataExists) {  
            return new Result<SomeData, DomainError>(_database.data);  
        }  
  
        return new Result<SomeData, DomainError>(new DomainError("Domain error: Data not found."));  
    }  
  
}  
  
  
public class Database {  
    public SomeData data { get; set; }  
    public Database(Guid id) {  
        data = new SomeData(id);  
    }  
  
    public bool CheckDataExists(Guid id) {  
        return id == data.Id;  
    }  
}  
  
public class SomeData {  
    public Guid Id { get; }  
    public SomeData(Guid id) {  
        Id = id;  
    }  
}  

public class DomainError {  
    public string Message { get; }  
    public DomainError(string message) {  
        Message = message;  
    }  
}

In this example, GetData returns a Result<SomeData, DomainError>. Depending on whether GetData succeeds or fails, either HandleData or LogError will be executed. The key here is that the OnSuccess and OnError methods encapsulate the branching logic, allowing for a more fluent and declarative style of programming.

The data => HandleData(data) is a lambda expression that represents an Action<T>. It’s a method taking data as a parameter and then calling HandleData with it. Similarly, error => LogError(error) is an Action<E>, handling the error case.

This approach is quite powerful as it separates the concerns of error handling from the main business logic, leading to cleaner and more maintainable code. You can further refine this pattern by adding more methods for chaining, like Map for transforming the result, Bind for monadic operations, etc. This makes the Result type a very flexible tool for robust error handling without resorting to traditional branching logic.

The provided code snippet demonstrated several programming techniques and concepts:

  1. Encapsulation and Monadic Design Pattern: The Result<T, E> class demonstrates encapsulation by containing both success and error states along with their values. This approach, akin to a monadic design pattern, organizes state handling within a structured object, avoiding dispersed code management. While not a full monad, Result mimics monadic aspects by providing methods to process values or errors without manual state checks.

  2. Fluent Interface and Method Chaining: The Result class employs a fluent interface through method chaining. The methods OnSuccess and OnError return the Result object itself, allowing for chaining multiple methods in a single statement. This design enhances code readability and conciseness, characteristic of fluent interfaces.

  3. Delegation and Higher-Order Functions: OnSuccess and OnError serve as higher-order functions, taking Action<T> and Action<E> delegates as parameters. They exemplify delegation by passing execution based on the Result object’s state to these provided action functions. This pattern allows for flexible and dynamic handling of different execution scenarios.

  4. Lambda Expressions and Conditional Execution: Lambda expressions like data => HandleData(data) and error => LogError(error) in C# provide concise representations of anonymous methods. The implementation of OnSuccess and OnError involves conditional execution, determining action invocation based on the Result object’s success state, often utilizing these lambda expressions for inline logic definition.

Regular case

Default value such as Optional object can help to recover from not finding the Guid Id.

If the absence of data corresponding to a GUID is a regular occurrence and not indicative of a deeper issue, then using an optional object like Option<T> is a more appropriate approach. Option<T> is a container that either holds a value of type T (representing the presence of data) or is empty (representing the absence of data).

This approach is particularly useful in cases where the absence of data is a normal - an expected part of the application’s flow. For example if a user is searching for a record that doesn’t exist, it isn’t necessarily an error. It could also mean that the record hasn’t been created yet. By using Option<T>, you can handle these scenarios without resorting to null checks or throwing exceptions. It allows the calling code to handle the case of missing data, perhaps by taking alternative actions or providing a default value.

I won’t go into details about this. But Option<T> is available, for example, from the popular function library LanguageExt.