C#
Using the Result Pattern in C# Instead of Returning Null
The Result Pattern makes expected success and failure states explicit, providing a clearer alternative to returning null or throwing exceptions for routine business outcomes.
I learned the Result Pattern while working with .NET, and it changed how I think about methods that can fail. Instead of returning null and leaving the caller to interpret it, the method returns an object that describes either success or failure.
This does not mean that every nullable value or exception should disappear. The pattern is most useful when failure is an expected outcome and the caller needs to understand what happened.
The limitation of returning null
Consider a repository method that returns null when it cannot find a user. The caller can check for null, but the value does not explain the reason. Was the identifier invalid? Was the user missing? Did another validation fail?
Nullable reference types improve this situation by letting us express whether a reference is intended to accept null. The C# compiler then uses static analysis to warn about possible null assignments and dereferences. However, this is a compile-time feature; it does not create a separate runtime type or add runtime validation. (learn.microsoft.com)
A Result<T> adds meaning to the operation’s outcome. Instead of returning User?, a method can return Result<User> containing either the user or a specific error.
Representing errors
The first part is a small error type:
public record Error(string Code, string Message)
{
public static Error None = new(string.Empty, string.Empty);
public static Error NullValue = new("Error.NullValue", "Um valor nulo foi fornecido.");
}
The code provides a stable identifier for programmatic decisions and a message that can be logged or displayed. Error.None represents a successful operation, where no error exists.
Creating the Result type
The non-generic Result represents operations that succeed without returning a value:
public class Result
{
protected Result(bool isSuccess, Error error)
{
switch (isSuccess)
{
case true when error != Error.None:
throw new InvalidOperationException();
case false when error == Error.None:
throw new InvalidOperationException();
default:
IsSuccess = isSuccess;
Error = error;
break;
}
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success() => new(true, Error.None);
public static Result Failure(Error error) => new(false, error);
public static Result<T> Success<T>(T value) => new(value, true, Error.None);
public static Result<T> Failure<T>(Error error) => new(default, false, error);
public static Result<T> Create<T>(T? value) =>
value is not null ? Success(value) : Failure<T>(Error.NullValue);
}
The constructor protects two important rules: a successful result cannot contain an error, and a failed result must contain one. Keeping those states valid prevents contradictory objects such as a successful result with an error message.
For operations that return data, the generic version stores the successful value:
public class Result<T> : Result
{
private readonly T? _value;
protected internal Result(T? value, bool isSuccess, Error error) : base(isSuccess, error)
=> _value = value;
[NotNull]
public T Value => _value! ?? throw new InvalidOperationException("Result has no value");
public static implicit operator Result<T>(T? value) => Create(value);
}
The Value property is available for successful results. Accessing it after a failure throws an InvalidOperationException, so consumers should inspect the result state first. The pattern makes the contract clearer, but it still depends on correct usage at the call site.
Returning a user without returning null
A lookup method can now communicate different expected failures explicitly:
public Result<User> GetUserById(int userId)
{
if (userId <= 0)
return Result.Failure<User>(Error.InvalidUserId);
var user = _userRepository.FindById(userId);
if (user == null)
return Result.Failure<User>(Error.UserNotFound);
return Result.Success(user);
}
Assuming InvalidUserId and UserNotFound are defined in the error catalog, the return type tells consumers that the method has two possible states. The caller can handle them directly:
var result = GetUserById(123);
if (result.IsSuccess)
{
Console.WriteLine($"User found: {result.Value.Name}");
}
else
{
Console.WriteLine($"Failed to retrieve user: {result.Error.Message}");
}
This is more descriptive than checking whether a returned User is null. It also keeps expected business outcomes visible in the normal control flow.
Result does not replace exceptions
Exceptions remain appropriate for unexpected conditions that cannot be handled as part of the normal operation. The .NET guidance recommends avoiding exceptions for routine conditions while using exception handling for genuinely exceptional events. (learn.microsoft.com)
A practical distinction is:
- Return a result for validation failures, missing records, conflicts, and other expected outcomes.
- Throw exceptions for programming errors, invalid object state, or unexpected infrastructure failures that the current layer cannot meaningfully handle.
The Result Pattern is not primarily about eliminating null or exceptions everywhere. It is about giving expected failures an explicit type and making method contracts easier to understand.
Two additional practical treatments of the pattern are available at https://www.red-gate.com/simple-talk/development/dotnet-development/the-result-pattern-in-asp-net-core-minimal-apis/ and https://medium.com/@emrecantopaloglu/the-result-pattern-in-net-a-simple-guide-73a8f1b89d73.
References
- Nullable reference types - C# reference | Microsoft Learn — learn.microsoft.com
- Best practices for exceptions - .NET | Microsoft Learn — learn.microsoft.com
- The Result Pattern In Asp Net Core Minimal Apis — red-gate.com
- The Result Pattern In Net A Simple Guide 73A8f1b89d73 — medium.com