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:
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.Fluent Interface and Method Chaining: The
Result
class employs a fluent interface through method chaining. The methodsOnSuccess
andOnError
return theResult
object itself, allowing for chaining multiple methods in a single statement. This design enhances code readability and conciseness, characteristic of fluent interfaces.Delegation and Higher-Order Functions:
OnSuccess
andOnError
serve as higher-order functions, takingAction<T>
andAction<E>
delegates as parameters. They exemplify delegation by passing execution based on theResult
object’s state to these provided action functions. This pattern allows for flexible and dynamic handling of different execution scenarios.Lambda Expressions and Conditional Execution: Lambda expressions like
data => HandleData(data)
anderror => LogError(error)
in C# provide concise representations of anonymous methods. The implementation ofOnSuccess
andOnError
involves conditional execution, determining action invocation based on theResult
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
.