Zentient Results Api Reference Configuration Extensibility - ulfbou/Zentient.Results GitHub Wiki

🛠️ Configuration & Extensibility


Zentient.Results is designed with a focus on minimal global configuration and maximum extensibility through its core types and functional patterns. Rather than a centralized configuration class, the library provides flexible components and clear extension points that allow you to adapt it to your specific application needs, integrate with existing frameworks, and define custom behaviors.

Defining Custom Error Information

While ErrorInfo provides a robust structure, you might need to extend it with specific data or create custom error definitions tailored to your domain.

Using ErrorInfo.Parameters for Custom Data

The Parameters dictionary within ErrorInfo is the primary way to include arbitrary, context-specific data with an error without changing the ErrorInfo struct's definition. This is ideal for attaching validation details, entity IDs, or other diagnostics.

  • How to use:
    using Zentient.Results;
    using System.Collections.Generic;
    
    public IResult RegisterUser(string username, string password)
    {
        if (username.Length < 5)
        {
            return Result.Validation(
                "UsernameTooShort",
                "Username must be at least 5 characters.",
                new Dictionary<string, object> { { "MinLength", 5 }, { "CurrentLength", username.Length } }
            );
        }
        // ... more validation or business logic
        return Result.Success();
    }
    
    // Accessing parameters:
    IResult regResult = RegisterUser("user", "pass");
    if (regResult.IsFailure)
    {
        var error = regResult.Errors.First();
        if (error.Code == "UsernameTooShort" && error.Parameters.TryGetValue("MinLength", out var minLength))
        {
            Console.WriteLine($"Error: {error.Message} (Required min length: {minLength})");
        }
    }

Defining Custom Error Codes and Messages

You are encouraged to define your own specific Code strings and Message strings within ErrorInfo instances to accurately represent domain-specific errors. Consider creating static helper methods or constants for common application-specific error codes to ensure consistency.

  • Example: Centralizing custom error definitions

    public static class CustomErrorCodes
    {
        public const string UserAlreadyExists = "UserAlreadyExists";
        public const string InsufficientBalance = "InsufficientBalance";
        public const string ProductOutOfStock = "ProductOutOfStock";
    
        public static ErrorInfo UserExists(string email) =>
            new ErrorInfo(ErrorCategory.Conflict, UserAlreadyExists, $"User with email '{email}' already exists.");
    
        public static ErrorInfo InsufficientFunds(decimal required, decimal available) =>
            new ErrorInfo(ErrorCategory.BusinessLogic, InsufficientBalance, $"Insufficient funds. Required: {required}, Available: {available}",
                new Dictionary<string, object> { { "Required", required }, { "Available", available } });
    }
    
    // Usage:
    public IResult CreateUser(string email)
    {
        if (UserExistsInDb(email))
        {
            return Result.Conflict(CustomErrorCodes.UserExists(email));
        }
        // ...
        return Result.Success();
    }

Defining Custom Result Statuses

While ResultStatuses provides many common status codes (e.g., HTTP 200, 404, 500), you can define custom ResultStatus instances if your application requires a specific semantic outcome not covered by the defaults.

  • How to define custom statuses:
    using Zentient.Results;
    
    public static class CustomResultStatuses
    {
        public static IResultStatus Throttled { get; } = new ResultStatus(429, "Too Many Requests", false);
        public static IResultStatus UnderMaintenance { get; } = new ResultStatus(503, "Service Under Maintenance", false);
        public static IResultStatus PartialSuccess { get; } = new ResultStatus(206, "Partial Success", true); // Example of a successful but not fully OK status
    }
    
    // Usage:
    public IResult ProcessLargeRequest()
    {
        if (SystemOverloaded())
        {
            return Result.Failure(
                new ErrorInfo(ErrorCategory.TooManyRequests, "RateLimitExceeded", "Too many requests."),
                CustomResultStatuses.Throttled
            );
        }
        // ...
        return Result.Success();
    }
    
    // Note: Result.Failure also has overloads to accept a custom IResultStatus:
    // public static Result Failure(ErrorInfo error, IResultStatus status)
    // public static Result<T> Failure<T>(ErrorInfo error, IResultStatus status)

Integration with Dependency Injection (DI)

Zentient.Results types (IResult, IResult<T>) are primarily used as return types from your application services and repositories, not typically as services that you inject themselves. The library's design naturally complements dependency injection.

  • Injecting services that return Results: Your application services will consume other injected services (e.g., repositories, external API clients) and wrap their outcomes in Result types.

    using Microsoft.Extensions.DependencyInjection;
    using Zentient.Results;
    using System.Threading.Tasks;
    
    public interface IUserRepository
    {
        Task<IResult<User>> GetUserByIdAsync(int id);
        Task<IResult> UpdateUserEmailAsync(int id, string newEmail);
    }
    
    public class UserService
    {
        private readonly IUserRepository _userRepository;
    
        public UserService(IUserRepository userRepository)
        {
            _userRepository = userRepository;
        }
    
        public async Task<IResult> ChangeUserEmail(int userId, string email)
        {
            // Use functional composition to chain operations
            return await _userRepository.GetUserByIdAsync(userId)
                .Bind(async user => {
                    if (user.Email == email)
                    {
                        return Result.Validation("NoChange", "New email is same as current email.");
                    }
                    return await _userRepository.UpdateUserEmailAsync(userId, email);
                });
        }
    }
    
    // In Program.cs or Startup.cs
    // services.AddScoped<IUserRepository, UserRepository>();
    // services.AddScoped<UserService>();
  • Interceptors / Decorators with DI: You can use DI to register decorators for your services that return Result types. This is a powerful pattern for cross-cutting concerns like logging, metrics, or caching, without cluttering your core business logic.

    // Example: A Logging Decorator for IUserRepository
    public class LoggingUserRepositoryDecorator : IUserRepository
    {
        private readonly IUserRepository _inner;
        private readonly ILogger<LoggingUserRepositoryDecorator> _logger;
    
        public LoggingUserRepositoryDecorator(IUserRepository inner, ILogger<LoggingUserRepositoryDecorator> logger)
        {
            _inner = inner;
            _logger = logger;
        }
    
        public async Task<IResult<User>> GetUserByIdAsync(int id)
        {
            _logger.LogInformation($"Attempting to get user by ID: {id}");
            var result = await _inner.GetUserByIdAsync(id);
            result.OnFailure(errors => _logger.LogError($"Failed to get user {id}: {errors.First().Message}"));
            return result;
        }
    
        public async Task<IResult> UpdateUserEmailAsync(int id, string newEmail)
        {
            _logger.LogInformation($"Attempting to update user {id} email to: {newEmail}");
            var result = await _inner.UpdateUserEmailAsync(id, newEmail);
            result.OnFailure(errors => _logger.LogError($"Failed to update user {id} email: {errors.First().Message}"));
            return result;
        }
    }
    
    // DI Registration (e.g., using Scrutor or similar library for decorators)
    /*
    services.AddScoped<IUserRepository, UserRepository>();
    services.Decorate<IUserRepository, LoggingUserRepositoryDecorator>();
    */

Custom Error Handling Strategies

Zentient.Results provides the structured data (ErrorInfo, IResultStatus) needed to implement flexible and centralized error handling, especially in web APIs.

  • Mapping to HTTP Responses: In an ASP.NET Core API, you can write a global filter or middleware that inspects the IResult returned by your action methods and maps IsSuccess, Errors, and Status to appropriate HTTP status codes and response bodies (e.g., using ProblemDetails for errors).

    // Example of a basic controller action returning IResult
    [ApiController]
    [Route("[controller]")]
    public class UserController : ControllerBase
    {
        private readonly UserService _userService; // Injected
    
        public UserController(UserService userService) => _userService = userService;
    
        [HttpPost("change-email")]
        public async Task<IActionResult> ChangeEmail([FromBody] ChangeEmailRequest request)
        {
            IResult result = await _userService.ChangeUserEmail(request.UserId, request.NewEmail);
    
            // This mapping logic can be abstracted into a global filter or extension method
            if (result.IsSuccess)
            {
                return NoContent(); // HTTP 204
            }
    
            return result.Status.Code switch
            {
                400 => BadRequest(result.Errors.ToProblemDetails()), // Convert errors to ProblemDetails
                404 => NotFound(result.Errors.ToProblemDetails()),
                409 => Conflict(result.Errors.ToProblemDetails()),
                _ => StatusCode(result.Status.Code, result.Errors.ToProblemDetails())
            };
        }
    }
    
    // Extension method to convert ErrorInfo to ProblemDetails (you'd implement this)
    /*
    public static class ErrorExtensions
    {
        public static ProblemDetails ToProblemDetails(this IReadOnlyList<ErrorInfo> errors)
        {
            var problemDetails = new ProblemDetails
            {
                Status = errors.Any() ? errors.First().Category.ToHttpStatus() : 500, // Map category to status
                Title = errors.Any() ? errors.First().Message : "An error occurred.",
                // ... add other ProblemDetails properties
            };
            // Add custom extensions for detailed errors
            problemDetails.Extensions["errors"] = errors.Select(e => new { e.Code, e.Message, e.Parameters });
            return problemDetails;
        }
    }
    */
  • Centralized Error Mapping: You can build a service that maps ErrorCategory or specific ErrorInfo.Code values to user-friendly messages, localization keys, or specific fallback actions.

Logging Integration

The OnFailure extension method is the primary and most direct way to integrate logging with Zentient.Results. It allows you to perform logging operations specifically when a result is a failure, providing access to the ErrorInfo collection.

  • Using OnFailure for Logging:

    using Zentient.Results;
    using Microsoft.Extensions.Logging;
    using System.Linq; // For .First()
    using System.Text.Json; // For logging full error details
    
    public class OrderService
    {
        private readonly ILogger<OrderService> _logger;
    
        public OrderService(ILogger<OrderService> logger)
        {
            _logger = logger;
        }
    
        public IResult PlaceOrder(OrderRequest request)
        {
            // ... business logic, validation, etc.
            if (request.Quantity <= 0)
            {
                return Result.Validation("InvalidQuantity", "Quantity must be positive.");
            }
            if (!IsProductInStock(request.ProductId))
            {
                return Result.Conflict("OutOfStock", "Product is out of stock.");
            }
    
            // Simulate success or failure
            IResult result = request.ProductId == "fail" ?
                Result.InternalError("DbSaveFailed", "Could not save order to database.") :
                Result.Success();
    
            // Log failures at the point of creation or return
            result.OnFailure(errors =>
            {
                // Log the first error message
                _logger.LogError("Order placement failed: {ErrorCode} - {ErrorMessage}", errors.First().Code, errors.First().Message);
    
                // Optionally log full error details for debugging
                _logger.LogDebug("Full error details: {ErrorJson}", JsonSerializer.Serialize(errors));
            });
    
            return result;
        }
    }
    
    // Usage:
    // var serviceProvider = new ServiceCollection()
    //     .AddLogging(builder => builder.AddConsole().SetMinimumLevel(LogLevel.Debug))
    //     .AddSingleton<OrderService>()
    //     .BuildServiceProvider();
    // var orderService = serviceProvider.GetRequiredService<OrderService>();
    
    // orderService.PlaceOrder(new OrderRequest("product1", 10)); // Success
    // orderService.PlaceOrder(new OrderRequest("product2", 0));  // Validation Failure
    // orderService.PlaceOrder(new OrderRequest("fail", 1));     // Internal Error

By leveraging these extensibility points, Zentient.Results allows you to build robust, maintainable, and highly integrated applications without imposing rigid architectural constraints.

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