Structured Error Handling with ErrorInfo and ErrorCategory - ulfbou/Zentient.Results GitHub Wiki
In robust applications, how you handle and communicate errors is as crucial as handling success. Zentient.Results
moves beyond simplistic boolean flags or generic exceptions for expected failures, offering a structured and comprehensive error model through the ErrorInfo
struct
and the ErrorCategory
enum
.
This page details how to leverage these components to provide rich, actionable error information throughout your application.
Traditional error handling often presents challenges:
- Vague Messages: A simple string message like "Invalid input" lacks detail. Which input? What was invalid about it?
- Exception Overhead: Throwing and catching exceptions for every expected business rule violation can be performance-intensive and obscures the normal flow of logic.
- Lack of Context: Debugging or diagnosing issues based solely on a stack trace can be time-consuming.
- Inconsistent APIs: Different services or modules might return errors in varying formats, complicating client-side consumption.
Zentient.Results
addresses these by making error details first-class citizens, encapsulated within the ErrorInfo
structure.
ErrorInfo
is a lightweight, immutable readonly struct
designed to encapsulate detailed information about a specific error. It's the primary way Zentient.Results
conveys why an operation failed.
Property | Type | Description |
---|---|---|
Category |
ErrorCategory |
A high-level classification of the error (e.g., Validation , NotFound , Authentication ). |
Code |
string |
A specific, typically machine-readable identifier for the error (e.g., "USERNAME_TOO_SHORT" , "ITEM_NOT_IN_STOCK" ). |
Message |
string |
A human-readable description of the error, suitable for logging or displaying to users. |
Data |
object? |
An optional property to attach any additional, arbitrary contextual data relevant to the error (e.g., fieldName , actualValue , validationRules ). |
InnerErrors |
IReadOnlyList<ErrorInfo> |
An optional collection of nested ErrorInfo instances. Useful for aggregating multiple validation errors or representing composite failures. |
Like Result
and Result<T>
, ErrorInfo
is a readonly struct
. This means that once an ErrorInfo
instance is created, its properties cannot be changed. This ensures:
- Predictable Behavior: The error details remain consistent as the result object travels through your system.
- Thread Safety: No mutable shared state, simplifying concurrent access.
-
Value Semantics:
ErrorInfo
instances can be compared based on their content, not their memory address.
The ErrorCategory
enum
provides a predefined set of broad classifications for errors. This allows for generic handling logic (e.g., "log all Network
errors differently") and helps in categorizing issues for monitoring and analytics.
Common ErrorCategory
Values:
-
General
: For unclassified or default errors. -
Validation
: Input data did not meet requirements. -
Authentication
: Issues with user identity or login. -
Authorization
: User lacks permissions for an action. -
NotFound
: A requested resource was not found. -
Conflict
: A conflict occurred (e.g., resource already exists). -
Exception
: An unexpected internal exception occurred (often from a caught exception). -
Network
: Issues related to network communication. -
Database
: Problems interacting with the database. -
Timeout
: An operation exceeded its allowed time. -
Security
: Security-related concerns (e.g., potential breach). -
Request
: General issues with the request itself that don't fit other categories.
Why Categorize?
- Programmatic Handling: Allows code to react differently based on the broad type of error.
- Logging & Monitoring: Grouping errors in logs and dashboards for easier analysis.
- Client Communication: Helps clients understand the nature of a failure without parsing messages.
You create ErrorInfo
instances using its constructor, passing the required details.
using Zentient.Results;
// Example 1: Simple validation error
var invalidInputError = new ErrorInfo(
ErrorCategory.Validation,
"EMAIL_REQUIRED",
"Email address is a mandatory field."
);
// Example 2: Resource not found
var productNotFoundError = new ErrorInfo(
ErrorCategory.NotFound,
"PRODUCT_SKU_UNKNOWN",
"The specified product SKU was not found in the inventory."
);
The Data
property is incredibly powerful for attaching relevant context. It can be any object, including anonymous types for ad-hoc data.
using Zentient.Results;
using System;
// Example 1: Validation error with field name
var fieldSpecificError = new ErrorInfo(
ErrorCategory.Validation,
"FIELD_TOO_SHORT",
"The 'username' field must be at least 5 characters.",
new { FieldName = "username", MinLength = 5, CurrentLength = 3 } // Anonymous object for data
);
// Example 2: Database error with SQL state or connection string info
var dbConnectionError = new ErrorInfo(
ErrorCategory.Database,
"DB_CONNECTION_FAILED",
"Failed to connect to the database.",
new Dictionary<string, object>
{
{ "ConnectionStringHash", "ABC123XYZ" },
{ "AttemptCount", 3 }
}
);
InnerErrors
is vital for scenarios like form validation where multiple distinct errors can occur simultaneously, or when you want to show a root cause error with higher-level aggregated errors.
using Zentient.Results;
using System.Collections.Generic;
// Individual validation errors
var usernameError = new ErrorInfo(ErrorCategory.Validation, "USERNAME_TOO_SHORT", "Username must be at least 5 characters.");
var passwordError = new ErrorInfo(ErrorCategory.Validation, "PASSWORD_WEAK", "Password must contain a number and a symbol.");
var emailError = new ErrorInfo(ErrorCategory.Validation, "EMAIL_INVALID_FORMAT", "Email address is not in a valid format.");
// Aggregate them into a single ErrorInfo representing overall form validation failure
var formValidationFailure = new ErrorInfo(
ErrorCategory.Validation,
"FORM_SUBMISSION_FAILED",
"One or more fields failed validation.",
innerErrors: new ErrorInfo[] { usernameError, passwordError, emailError }
);
// You can also use ErrorInfo.Aggregate (static factory method) for validation errors:
var validationErrors = new List<ErrorInfo> { usernameError, emailError };
var aggregatedValidationResult = ErrorInfo.Aggregate(validationErrors);
// This will create a top-level ErrorInfo with category Validation, a general message,
// and the provided errors in its InnerErrors collection.
While Zentient.Results
encourages explicit error handling, sometimes an unexpected Exception
might occur. ErrorInfo.FromException()
provides a convenient way to convert an Exception
into a structured ErrorInfo
, allowing it to be propagated within the Result
flow.
using Zentient.Results;
using System;
try
{
int result = 10 / int.Parse("0"); // This will throw DivideByZeroException
}
catch (Exception ex)
{
var errorInfo = ErrorInfo.FromException(ex, "Unexpected calculation error.");
// This errorInfo can now be used in a Result.Failure()
Console.WriteLine($"Converted Exception: {errorInfo.Message} (Category: {errorInfo.Category}, Code: {errorInfo.Code})");
}
Result
and Result<T>
's Failure
factory methods (and their convenience overloads like NotFound
, BadRequest
, etc.) directly accept ErrorInfo
instances.
using Zentient.Results;
public class DataAccessLayer
{
public IResult<User> GetUserFromDb(Guid id)
{
if (id == Guid.Empty)
{
return Result<User>.BadRequest(
new ErrorInfo(ErrorCategory.Validation, "EMPTY_ID", "User ID cannot be empty.")
);
}
// Simulate database lookup failure
if (id == new Guid("00000000-0000-0000-0000-000000000001"))
{
return Result<User>.Failure(
default, // No user value
new ErrorInfo(ErrorCategory.Database, "DB_TIMEOUT", "Database query timed out."),
ResultStatuses.RequestTimeout
);
}
// Simulate not found
if (id == new Guid("00000000-0000-0000-0000-000000000002"))
{
return Result<User>.NotFound(
new ErrorInfo(ErrorCategory.NotFound, "USER_NOT_FOUND", $"User with ID {id} does not exist.")
);
}
return Result<User>.Success(new User { Id = id, Username = "TestUser" });
}
}
public class User { public Guid Id { get; set; } public string Username { get; set; } }
When a Result
is in a failure state (IsFailure
is true
), you can access its Errors
collection to inspect the detailed ErrorInfo
instances.
using Zentient.Results;
using System;
public class ApiController
{
private DataAccessLayer _dataLayer = new DataAccessLayer();
public void GetUserEndpoint(Guid userId)
{
IResult<User> result = _dataLayer.GetUserFromDb(userId);
if (result.IsFailure)
{
// Access the first error message for a quick display
Console.WriteLine($"Operation failed: {result.Error}");
// Iterate through all structured errors for detailed logging or API response
foreach (var error in result.Errors)
{
Console.WriteLine($" Error Details:");
Console.WriteLine($" Category: {error.Category}");
Console.WriteLine($" Code: {error.Code}");
Console.WriteLine($" Message: {error.Message}");
if (error.Data != null)
{
Console.WriteLine($" Data: {error.Data}");
// If you know the type of Data, you can cast it:
// if (error.Data is MyCustomErrorDataType customData) { ... }
}
if (error.InnerErrors.Any()) // Use System.Linq.Any()
{
Console.WriteLine($" Inner Errors ({error.InnerErrors.Count}):");
foreach (var innerError in error.InnerErrors)
{
Console.WriteLine($" - {innerError.Message} (Code: {innerError.Code})");
}
}
}
// Map to HTTP status for API response (e.g., using Result.Status.Code)
// HttpContext.Response.StatusCode = result.Status.Code;
// return new JsonResult(new { errors = result.Errors });
}
else
{
Console.WriteLine($"User retrieved: {result.Value.Username}");
}
}
}
-
Improved Debuggability: Detailed
ErrorInfo
with categories, codes, and data makes it much easier to pinpoint the exact cause of a failure. - Better Client Communication: Clients (web, mobile, other services) can programmatically react to specific error codes or categories, and display meaningful, localized messages to end-users.
- Consistent API Responses: Ensures a uniform error response format across your entire application or API.
- Enhanced Monitoring & Analytics: You can easily collect metrics on error categories and codes, providing insights into the most common or critical failure points in your system.
-
Clearer Separation of Concerns: Business logic focuses on domain operations, while the
Result
framework handles error packaging and propagation.
-
Be Specific with Categories and Codes: Avoid generic categories and codes.
ErrorCategory.Validation
combined withCode: "EMAIL_INVALID_FORMAT"
is much more useful than just"VALIDATION_ERROR"
. -
Keep
Message
Human-Readable: The message should be clear and concise for logging or direct user display. -
Leverage
Data
for Context: Attach any relevant, non-sensitive data that helps diagnose the error or provide context for the client. -
Aggregate Errors: For scenarios like form validation, collect all individual
ErrorInfo
instances and return them in theInnerErrors
of a single top-levelErrorInfo
. -
Map Exceptions to
ErrorInfo
at Boundaries: If your code interacts with external libraries or legacy code that throws exceptions, catch them at the boundary and convert them intoErrorInfo
usingErrorInfo.FromException()
before propagating them via aResult.Failure()
. -
Consistency is Key: Establish guidelines for your team on how
ErrorInfo
categories, codes, and messages should be used across your codebase.
With a solid understanding of structured error handling, you can now explore how Zentient.Results
helps you build robust workflows:
- Basic Usage Patterns: See more practical examples of creating and consuming results.
-
Managing Operation Status with
IResultStatus
andResultStatuses
: Understand the high-level status of an operation. -
Chaining and Composing Operations: Learn how
Map
,Bind
, and other methods simplify complex logic flows.