The narrative of a codebase starts with how we handle the inevitable: failure. Whether a request fails a guard clause, violates a validation rule, or triggers a handled exception, the Result Pattern provides a unified way to communicate these outcomes. It makes failure a first-class citizen, ensuring code remains predictable and expressive.
Quick Start: The Generic Result
The most common use case for this pattern is attempting to retrieve or calculate a value that might not exist. Here is a basic, .NET 4.8 compatible implementation.
public class Result<T>
{
public bool IsSuccess { get; private set; }
public T Value { get; private set; }
public string ErrorMessage { get; private set; }
protected Result(bool success, T value, string errorMessage)
{
this.IsSuccess = success;
this.Value = value;
this.ErrorMessage = errorMessage;
}
public static Result<T> Success(T value)
{
return new Result<T>(true, value, null);
}
public static Result<T> Failure(string errorMessage)
{
return new Result<T>(false, default(T), errorMessage);
}
}
Usage Example
public Result<double> Divide(double dividend, double divisor)
{
if (divisor == 0)
{
return Result<double>.Failure("Cannot divide by zero.");
}
return Result<double>.Success(dividend / divisor);
}
// Handling the result
var result = Divide(10, 0);
if (result.IsSuccess)
{
Console.WriteLine("Result: " + result.Value);
}
else
{
Console.WriteLine("Error: " + result.ErrorMessage);
}
Handling Methods without Return Values
When an action does not return a specific value, such as updating a record, a non-generic version of the class tracks the outcome without requiring a placeholder value.
public class Result
{
public bool IsSuccess { get; private set; }
public string ErrorMessage { get; private set; }
protected Result(bool success, string errorMessage)
{
this.IsSuccess = success;
this.ErrorMessage = errorMessage;
}
public static Result Success()
{
return new Result(true, null);
}
public static Result Failure(string errorMessage)
{
return new Result(false, errorMessage);
}
}
When to Use (and Avoid) the Pattern
Not every function needs a Result wrapper. Overusing this pattern can lead to unnecessary complexity in simple logic.
Use the Result Pattern when:
- External Boundaries (I/O): Wrap try-catch blocks at the database or API boundary to prevent technical exceptions from leaking into your business logic.
- Domain Scenarios: Use it for valid business states that are not errors in the technical sense, such as “Insufficient Funds” or “Invalid Coupon.”
- Public Service Contracts: Use it when the caller needs a clear, descriptive reason for failure without the overhead of catching exceptions.
Avoid the Result Pattern when:
- Internal Logic Flow: If a simple bool, null, or an empty collection naturally represents the state, stick to native returns. An empty list is usually clearer than a Result containing an empty list.
- Developer Errors: Do not use Result to report bugs. If a developer passes a null to a method that requires a value, use a standard ArgumentNullException. We want the application to fail fast during development.
- Catastrophic Failures: If a database connection is lost or a disk is full, let the exception throw. These errors are often unrecoverable by the caller and are best handled by a global error handler.
The Context-Aware Result
As systems grow, the model can expand to include exception tracking and metadata. This allows functions to pass back diagnostic data or UI hints without cluttering the primary value.
using System;
using System.Collections.Generic;
public class Result<T>
{
public T Value { get; private set; }
public string ErrorMessage { get; private set; }
public bool IsSuccess { get; private set; }
public Exception CapturedException { get; private set; }
public IDictionary<string, object> Metadata { get; private set; }
protected Result(T value, bool success, string error, Exception ex, IDictionary<string, object> metadata)
{
this.Value = value;
this.IsSuccess = success;
this.ErrorMessage = error;
this.CapturedException = ex;
this.Metadata = metadata != null ? metadata : new Dictionary<string, object>();
}
public static Result<T> Success(T value, IDictionary<string, object> metadata = null)
{
return new Result<T>(value, true, null, null, metadata);
}
public static Result<T> Failure(string error, Exception ex = null, IDictionary<string, object> metadata = null)
{
return new Result<T>(default(T), false, error, ex, metadata);
}
}
Try-Catch Harmony: The Database Boundary
Database access is inherently volatile, but we must distinguish between a technical failure (database offline) and a logical failure (record not found). We use try-catch at the infrastructure layer to transform technical volatility into a managed Result.
public Result<User> GetUser(int userId)
{
try
{
User user = _dbContext.Users.Find(userId);
if (user == null)
{
return Result<User>.Failure("User not found in the database.");
}
return Result<User>.Success(user);
}
catch (Exception ex)
{
// Transform a technical crash into a managed Result for the Service layer
return Result<User>.Failure("A database connectivity error occurred.", ex);
}
}
The Architectural Impact
This pattern fundamentally changes the developer experience by moving away from guessing what a function might throw:
- Honest APIs: Function signatures no longer lie. A return type of Result of User tells the caller explicitly that the user might not be found, removing the need for defensive coding against undocumented exceptions.
- Simplified Testing: Testing for failure becomes a property check rather than setting up complex assertions for thrown exceptions. This treats the unsuccessful path as a standard, verifiable piece of data.
- Efficiency: Returning a lightweight object is faster than throwing an exception. Exceptions force the runtime to stop and capture a full call stack, whereas a Result is just another piece of data passed back through the stack.
- Clean Guard Clauses: By checking IsSuccess early, you prevent deep nesting and ensure that invalid data never reaches your core business logic.
Summary
Adopting the Result Pattern creates a codebase where every line is intentional and every outcome is predicted. It is a fundamental shift toward building wonderful products together.