Zentient Results Api Reference Configuration Extensibility - ulfbou/Zentient.Results GitHub Wiki
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.
While ErrorInfo
provides a robust structure, you might need to extend it with specific data or create custom error definitions tailored to your domain.
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})"); } }
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(); }
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)
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>(); */
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 mapsIsSuccess
,Errors
, andStatus
to appropriate HTTP status codes and response bodies (e.g., usingProblemDetails
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 specificErrorInfo.Code
values to user-friendly messages, localization keys, or specific fallback actions.
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.