Understanding IResult and IResult T - ulfbou/Zentient.Results GitHub Wiki
At the heart of Zentient.Results
are the IResult
and IResult<T>
interfaces, along with their concrete Result
and Result<T>
struct
implementations. These are the fundamental building blocks that enable explicit, predictable, and robust outcome handling in your .NET applications.
This page will help you grasp what these types are, why they exist, and how they function.
Traditionally, C# methods that perform an action might return void
(implying success) or a direct value T
(implying success), and communicate failures by throwing exceptions. While exceptions are vital for truly exceptional and unrecoverable errors, they can lead to tangled control flow when used for expected business outcomes (e.g., "user not found," "invalid input").
Zentient.Results
introduces IResult
and IResult<T>
as explicit return types that declare: "This operation might succeed, or it might fail, and here's all the context if it does."
This approach provides:
- Predictability: The method signature immediately tells you that you need to handle both success and failure.
- Clarity: It's always clear whether an operation completed successfully or with an issue.
- Structured Information: Failures carry rich, categorized error details, not just a stack trace.
The generic IResult<T>
interface (and its concrete implementation, Result<T>
) is used for operations that, upon success, produce a specific value of type T
.
Purpose: Use IResult<T>
when your method computes or retrieves something concrete.
Example Use Cases:
IResult<User> GetUserById(Guid id)
IResult<Order> CreateOrder(OrderRequest request)
IResult<decimal> CalculateDiscount(decimal amount)
Property | Type | Description |
---|---|---|
IsSuccess |
bool |
true if the operation succeeded; false otherwise. |
IsFailure |
bool |
true if the operation failed; false otherwise. (Convenient inverse of IsSuccess ) |
Value |
T? |
The successful result value. Only access if IsSuccess is true . Will be default(T) if IsFailure is true (or if explicitly passed to Failure methods). |
Errors |
IReadOnlyList<ErrorInfo> |
A collection of detailed ErrorInfo objects, present only if IsFailure is true . Can contain multiple errors (e.g., from validation). |
Error |
string? |
A convenience property that returns the Message of the first ErrorInfo in the Errors collection if available; otherwise null . |
Messages |
IReadOnlyList<string> |
A collection of non-error informational messages associated with the result. Useful for warnings or additional context. |
Status |
IResultStatus |
A high-level status object (e.g., ResultStatuses.Success , ResultStatuses.NotFound ), often mapping to HTTP status codes. Provides an int Code and string Description . |
Result<T>
provides numerous static factory methods to create instances, ensuring correct state initialization:
-
Success:
return Result<User>.Success(newUser); // Common success with value return Result<Order>.Created(newOrder); // Success indicating resource creation (201)
-
Failure:
// Generic failure: return Result<Product>.Failure( default, // Often default(T) for failure, but can pass a partial value if sensible new ErrorInfo(ErrorCategory.Validation, "InvalidSku", "SKU format is incorrect."), ResultStatuses.BadRequest ); // Convenience methods for common failures: return Result<User>.NotFound( new ErrorInfo(ErrorCategory.NotFound, "UserNotFound", $"User with ID {userId} was not found.") ); return Result<string>.Unauthorized( new ErrorInfo(ErrorCategory.Authentication, "TokenExpired", "Authentication token is expired.") ); return Result<Customer>.Conflict( new ErrorInfo(ErrorCategory.Conflict, "EmailTaken", "Email already registered.", new { emailAddress }) );
The non-generic IResult
interface (and its concrete implementation, Result
) is used for operations whose primary outcome is a state change or an action, rather than producing a specific return value. Think of void
methods in traditional C#.
Purpose: Use IResult
for "command" methods that might succeed or fail.
Example Use Cases:
IResult DeleteProduct(string productId)
IResult SendEmail(EmailMessage message)
IResult UpdateOrderStatus(Guid orderId, OrderStatus newStatus)
IResult
shares most properties with IResult<T>
, excluding the Value
property.
Property | Type | Description |
---|---|---|
IsSuccess |
bool |
true if the operation succeeded; false otherwise. |
IsFailure |
bool |
true if the operation failed; false otherwise. |
Errors |
IReadOnlyList<ErrorInfo> |
A collection of detailed ErrorInfo objects. |
Error |
string? |
The Message of the first ErrorInfo if available. |
Messages |
IReadOnlyList<string> |
A collection of non-error informational messages. |
Status |
IResultStatus |
A high-level status object (e.g., ResultStatuses.Success , ResultStatuses.Forbidden ). |
Result
also provides static factory methods:
-
Success:
return Result.Success(); // Simple success (often maps to HTTP 200 OK) return Result.Success(ResultStatuses.NoContent); // Success indicating no content (HTTP 204)
-
Failure:
// Generic failure: return Result.Failure( new ErrorInfo(ErrorCategory.Network, "ConnectionLost", "Failed to connect to external service."), ResultStatuses.InternalServerError ); // Convenience methods for common failures: return Result.Validation( // Specifically for validation errors (often maps to HTTP 400) new ErrorInfo(ErrorCategory.Validation, "InvalidInput", "Input field 'x' is required.") ); return Result.Forbidden( new ErrorInfo(ErrorCategory.Authorization, "AccessDenied", "User does not have permission to delete this resource.") );
Both Result
and Result<T>
are implemented as readonly struct
in C#. This is a deliberate design choice with several benefits:
-
Immutability: Once a
Result
orResult<T>
instance is created, its internal state (whether it's a success or failure, its value, errors, and status) cannot be changed. This makes reasoning about your code easier, reduces potential bugs from unexpected side effects, and is crucial for thread-safe operations. -
Performance:
struct
types are typically allocated on the stack (or inline within other objects) rather than the heap. This reduces pressure on the garbage collector, leading to better performance, especially in high-throughput applications where many result objects might be created and discarded. -
Value Semantics: They behave like simple values (e.g., an
int
or abool
). When you pass aResult
around, you are passing a copy of its immutable state.
The Status
property (of type IResultStatus
) provides a high-level classification of the outcome. It's primarily intended to align with well-understood codes, such as HTTP status codes, but can also be customized for domain-specific contexts.
Key Distinction: Status
vs. Errors
-
Status
(What happened?): A general categorization (e.g.,404 Not Found
,200 OK
,400 Bad Request
). It tells you the type of overall outcome. -
Errors
(Why it happened?): Provides granular, detailedErrorInfo
objects that explain the specific reasons for a failure. AnIResultStatus
ofBadRequest
might correspond to multipleErrorInfo
instances detailing which input fields were invalid.
The Errors
property holds a collection of ErrorInfo
objects.
-
Multiple Errors: It's a list (
IReadOnlyList<ErrorInfo>
) because an operation can fail for multiple reasons simultaneously. For example, a validation process might identify several invalid input fields. -
Structured Details: Each
ErrorInfo
provides aCategory
,Code
,Message
, and optionalData
, offering rich context for diagnostics, logging, and user feedback.
Feature | IResult<T> |
IResult |
---|---|---|
Primary Use | Operations that produce a specific output value. | Operations that primarily cause a side effect/state change. |
Success Value | Contains a Value of type T . |
Does not contain a value. |
Example |
GetUserById() , CalculateTax()
|
DeleteUser() , SendEmail()
|
Factory Methods |
Success(T value) , Failure(T? value, ErrorInfo...)
|
Success() , Failure(ErrorInfo...)
|
Functional Ops | Supports Map , Bind (to transform T ). |
Supports OnSuccess , OnFailure (for side effects). |
When to Choose Which:
-
Choose
IResult<T>
when your method's main purpose is to return data that the caller needs to proceed. -
Choose
IResult
when your method's main purpose is to perform an action, and the caller only needs to know if the action succeeded or failed.
using Zentient.Results;
using System;
using System.Collections.Generic;
public class UserProfileService
{
private static Dictionary<Guid, UserProfile> _userProfiles = new Dictionary<Guid, UserProfile>();
// IResult<T>: Get a user profile (produces a value)
public IResult<UserProfile> GetProfile(Guid userId)
{
if (!_userProfiles.TryGetValue(userId, out var profile))
{
return Result<UserProfile>.NotFound(
new ErrorInfo(ErrorCategory.NotFound, "UserProfileNotFound", $"Profile for user {userId} not found.")
);
}
return Result<UserProfile>.Success(profile);
}
// IResult: Update a user profile (performs an action, no value returned)
public IResult UpdateProfile(Guid userId, string newEmail)
{
if (string.IsNullOrWhiteSpace(newEmail) || !newEmail.Contains("@"))
{
return Result.BadRequest(
new ErrorInfo(ErrorCategory.Validation, "InvalidEmail", "Invalid email format.")
);
}
if (!_userProfiles.ContainsKey(userId))
{
return Result.NotFound(
new ErrorInfo(ErrorCategory.NotFound, "UserProfileNotFound", $"Profile for user {userId} not found.")
);
}
// Simulate update
_userProfiles[userId].Email = newEmail;
Console.WriteLine($"Profile for {userId} updated with new email: {newEmail}");
return Result.Success();
}
// Helper for initial data
public void AddProfile(UserProfile profile)
{
_userProfiles[profile.UserId] = profile;
}
}
public class UserProfile
{
public Guid UserId { get; set; }
public string Username { get; set; }
public string Email { get; set; }
}
public class Application
{
public static void Main(string[] args)
{
var service = new UserProfileService();
var userAId = Guid.NewGuid();
var userBId = Guid.NewGuid();
service.AddProfile(new UserProfile { UserId = userAId, Username = "Alice", Email = "[email protected]" });
// GetProfile - Success
var profileResult = service.GetProfile(userAId);
if (profileResult.IsSuccess)
{
Console.WriteLine($"Found profile for: {profileResult.Value.Username} ({profileResult.Value.Email})");
}
// GetProfile - Failure (Not Found)
var notFoundResult = service.GetProfile(userBId);
if (notFoundResult.IsFailure)
{
Console.WriteLine($"Error getting profile ({notFoundResult.Status.Code}): {notFoundResult.Error}");
}
Console.WriteLine("\n--- Updating Profiles ---");
// UpdateProfile - Success
var updateSuccessResult = service.UpdateProfile(userAId, "[email protected]");
if (updateSuccessResult.IsSuccess)
{
Console.WriteLine("Profile updated successfully!");
}
// UpdateProfile - Failure (Invalid Email)
var updateInvalidEmailResult = service.UpdateProfile(userAId, "invalid-email");
if (updateInvalidEmailResult.IsFailure)
{
Console.WriteLine($"Error updating profile ({updateInvalidEmailResult.Status.Code}): {updateInvalidEmailResult.Error}");
}
// UpdateProfile - Failure (Profile Not Found)
var updateNotFoundResult = service.UpdateProfile(userBId, "[email protected]");
if (updateNotFoundResult.IsFailure)
{
Console.WriteLine($"Error updating profile ({updateNotFoundResult.Status.Code}): {updateNotFoundResult.Error}");
}
}
}
Now that you understand the core IResult
and IResult<T>
types, explore these related topics:
- Basic Usage Patterns: More practical examples of creating and consuming results.
-
Structured Error Handling with
ErrorInfo
andErrorCategory
: A deeper dive into how errors are represented. -
Chaining and Composing Operations: Learn how to build powerful workflows using
Map
,Bind
, and other fluent methods. -
Managing Operation Status with
IResultStatus
andResultStatuses
: Understand how status codes work.