Structured Error Handling with ErrorInfo and ErrorCategory - ulfbou/Zentient.Results GitHub Wiki

Structured Error Handling with ErrorInfo and ErrorCategory

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.


1. The Need for Structured Errors

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.


2. ErrorInfo: The Core Error Object

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.

Key Properties of ErrorInfo:

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.

Immutability

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.

3. ErrorCategory Enum: Classifying Errors

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.

4. Creating ErrorInfo Instances

You create ErrorInfo instances using its constructor, passing the required details.

Basic Construction

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."
);

With Data (Contextual Information)

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 }
    }
);

With InnerErrors (Aggregating or Nesting Errors)

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.

From Exceptions (ErrorInfo.FromException())

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})");
}

5. Integrating ErrorInfo with Result Types

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; } }

6. Consuming ErrorInfo (Handling Errors)

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}");
        }
    }
}

7. Benefits of Structured Error Handling

  • 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.

8. Best Practices

  • Be Specific with Categories and Codes: Avoid generic categories and codes. ErrorCategory.Validation combined with Code: "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 the InnerErrors of a single top-level ErrorInfo.
  • 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 into ErrorInfo using ErrorInfo.FromException() before propagating them via a Result.Failure().
  • Consistency is Key: Establish guidelines for your team on how ErrorInfo categories, codes, and messages should be used across your codebase.

Next Steps

With a solid understanding of structured error handling, you can now explore how Zentient.Results helps you build robust workflows:

⚠️ **GitHub.com Fallback** ⚠️