Pitasoft.Result 7.4.1

dotnet add package Pitasoft.Result --version 7.4.1
                    
NuGet\Install-Package Pitasoft.Result -Version 7.4.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pitasoft.Result" Version="7.4.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pitasoft.Result" Version="7.4.1" />
                    
Directory.Packages.props
<PackageReference Include="Pitasoft.Result" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pitasoft.Result --version 7.4.1
                    
#r "nuget: Pitasoft.Result, 7.4.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pitasoft.Result@7.4.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pitasoft.Result&version=7.4.1
                    
Install as a Cake Addin
#tool nuget:?package=Pitasoft.Result&version=7.4.1
                    
Install as a Cake Tool

Pitasoft.Result

Build Status NuGet Version NuGet Downloads Pitasoft.Error License Target Framework Nullable

English | Castellano


English

Pitasoft.Result is a .NET library designed to standardize responses from REST services and internal application layers. It provides a robust set of classes to wrap data, status codes, and error collections, facilitating a unified communication contract between APIs, services, and clients.

Features

  • Standardized Responses: Unify your API outputs using Result, ResultEntity<T>, ResultEntities<T>, ResultPaged<T>, and ResultBatch<T>.
  • Automatic Timestamps: All results now include a CalculationTime (DateTimeOffset?) to track when they were generated, via the IHasCalculationTimestamp interface.
  • Fluent Error Handling: Easily add validation errors, exceptions, or business rule violations using a fluent interface. Includes Result.Try and Result.TryAsync for automatic exception handling, with built-in support for CancellationToken and OperationCanceledExceptionCancelOperation.
  • Rich Status Management: Built-in StatusResult enum covering common API scenarios (Success, Not Found, Forbidden, Conflict, Validation Errors, Database Errors, etc.). Includes StatusResultExtensions for semantic categorization (IsSuccess, IsInfrastructureError, IsBusinessError, IsTransientError, IsError).
  • Batch Operations: Specialized support for processing multiple items with ResultBatch<T> using IReadOnlyList<T> for efficient access.
  • Pagination Support: ResultPaged<T> provides full paging metadata (TotalCount, Page, PageSize, TotalPages, etc.). Includes a full set of static factory methods in the Result class (e.g., Result.ErrorPaged, Result.DatabaseErrorPaged, Result.NotFoundPaged) and TryPagedAsync for exception-safe paged queries.
  • Async Support: Native support for IAsyncEnumerable<T> and Task<T> with MaterializeAsync and ToResultEntitiesAsync. Includes MapAsync, BindAsync, MatchAsync, and TapAsync extensions.
  • Improved Serialization: Custom JSON converters keep API outputs clean and predictable across Result, ResultEntity<T>, ResultEntities<T>, ResultPaged<T>, and ResultBatch<T>, flattening well-known metadata such as Location, ETag, or Retry-After into top-level JSON properties.
  • Implicit Conversions: Convert StatusResult or entities directly to result types with zero boilerplate.
  • Deconstruction: Use C# deconstruction to extract values and status easily: var (status, user) = result;.
  • Extensions: Useful helper methods to check for success, specific errors, or state. Including ToOkResult(), ToAddedResult(), ToUpdatedResult(), and ToDeletedResult() for converting entities to results.
  • Functional API: Match, MatchAsync, Map, MapAsync, Tap, TapAsync, Bind, and Ensure for fluent result processing. All operations are available for Result, ResultEntity<T>, ResultEntities<T>, and ResultPaged<T>.
  • Combine Results: Aggregate multiple results into one with Result.Combine() and Result.CombineAsync().
  • Multi-value Parameters: IParameters.GetParameters() returns IEnumerable<KeyValuePair<string, string>>, supporting multiple values for the same key (e.g., ?tag=a&tag=b).
  • ErrorCollection Improvements: Empty is now a property (safe, non-shared instance). Direct Count property for O(1) access.
  • Performance: High-performance implementation with minimal allocations, AggressiveInlining, and efficient collection materialization using IReadOnlyList<T>.

Installation

dotnet add package Pitasoft.Result

Quick start

// 1) Simple result
Result r1 = Result.Ok();
Result r2 = Result.ValidationError().AddError("Email", "Invalid");

// 2) Entity
ResultEntity<User> u = Result.Ok(new User { Id = 1, Name = "John" });
var (status, user) = u; // deconstruction
var (s, e) = u; // status and entity
var (st, code, errs) = (Result)u; // deconstruction with result code from base Result

// 3) Paged entities
var users = new List<User> { new() { Id = 1, Name = "John" } };
ResultPaged<User> paged = Result.OkPaged(users, totalCount: 100, page: 1, pageSize: 10);
foreach (var it in paged) { /* iterate directly */ }

// 4) Try/Catch automation (with CancellationToken support)
var result = Result.Try(() => DoWork());
var resultAsync = await Result.TryAsync(async ct => await DoWorkAsync(ct), cancellationToken);

// 5) Try variants for collections
ResultEntities<User> entities = Result.TryEntities(() => GetUsers());
ResultEntities<User> entitiesAsync = await Result.TryEntitiesAsync(async () => await GetUsersAsync());
ResultPaged<User> pagedResult = await Result.TryPagedAsync(
    async () => await GetPagedAsync(), page: 1, pageSize: 20);

// 6) Functional extensions (available on all result types)
var dto = u.Map(x => new UserDto(x!.Id, x.Name));
var okOrThrow = u.Match(
    onSuccess: () => dto,
    onFailure: res => throw new InvalidOperationException(res.Status.ToString()));

// MatchAsync on Task<ResultPaged<T>>
var response = await Task.FromResult(pagedResult).MatchAsync(
    onSuccess: (items, total, page, pageSize) => BuildPagedDto(items, total, page, pageSize),
    onFailure: r => HandleError(r));

// 7) Combine results (sync and async)
var combined = Result.Combine(Result.Ok(), Result.Error().AddError("E", "err"));
var combinedAsync = await Result.CombineAsync(Task1Async(), Task2Async());

// 8) IsSuccess / IsFailure as extension methods
if (result.IsSuccess()) { /* ... */ }
if (result.IsFailure()) { /* ... */ }

// 9) StatusResult semantic extensions
if (result.Status.IsInfrastructureError()) { /* retry logic */ }
if (result.Status.IsBusinessError()) { /* return 422 */ }

// 10) Calculation Timestamp
DateTimeOffset? time = result.CalculationTime;

CalculationTime is normalized to UTC when assigned, so consumers can treat it as a stable cross-system timestamp.

Implementation Flows (Practical)

flowchart LR
    A["Input DTO"] --> B{"Validation OK?"}
    B -- No --> C["Result.ValidationError(...)"]
    B -- Yes --> D["Result.TryAsync(...)"]
    D --> E{"Exception?"}
    E -- Yes --> F["mapException -> StatusResult (safe normalized)"]
    E -- No --> G["Result.Added/Ok(...)"]
    G --> H["WithLocation / WithETag / WithMetadata"]
    F --> I["Return failure result"]
    H --> J["Return success result"]
flowchart LR
    A["Repository Find(id)"] --> B{"Entity exists?"}
    B -- No --> C["Result.NoExist<T>()"]
    B -- Yes --> D["Result.Ok(entity)"]
    C --> E["HTTP Adapter Layer"]
    D --> E
    E --> F{"Transport mapping"}
    F --> G["NoExist -> 404 (if API policy requires)"]
    F --> H["Ok -> 200"]

Common Mistakes Checklist

  • Returning NotFound in service/domain code when the semantic meaning is NoExist.
  • Using TryOutcome* for plain delegates that should use Try*.
  • Returning success statuses from mapException (they are normalized to Error).
  • Adding metadata keys that collide with reserved JSON fields (status, entity, entities, page, totalCount, etc.).
  • Passing metadata values with control characters (\r, \n, \t), which are rejected.
  • Relying on Newtonsoft.Json to mirror the exact flattened System.Text.Json contract without explicit adapter/configuration.
  • Rebuilding failed results and accidentally dropping Errors, ResultCode, CalculationTime, or metadata.

Implementation Recipes (Copy/Paste)

Recipe A: CRUD (Service + HTTP Mapping + Test)
// Service
public async Task<ResultEntity<UserDto>> UpdateUserAsync(int id, UpdateUserDto input, CancellationToken ct)
{
    if (id <= 0)
        return Result.ValidationError<UserDto>().AddError(nameof(id), "Invalid id");

    if (string.IsNullOrWhiteSpace(input.Name))
        return Result.ValidationError<UserDto>().AddError(nameof(input.Name), "Name is required");

    var outcome = await Result.TryAsync(async token =>
    {
        var entity = await _repo.UpdateAsync(id, input.Name, token);
        return entity is null
            ? null
            : new UserDto(entity.Id, entity.Name, entity.Version);
    }, cancellationToken: ct, mapException: ex =>
        ex is TimeoutException ? StatusResult.ServiceUnavailable : StatusResult.DatabaseError);

    if (outcome.IsFailure())
        return new ResultEntity<UserDto>(outcome.Status, outcome.Errors);

    if (outcome.Entity is null)
        return Result.NoExist<UserDto>();

    return Result.Updated(outcome.Entity)
        .WithETag($"user-{outcome.Entity.Id}-v{outcome.Entity.Version}");
}

// HTTP adapter (controller/minimal API mapper)
public static Microsoft.AspNetCore.Http.IResult ToHttp(ResultEntity<UserDto> result) => result.Status switch
{
    StatusResult.Updated => Results.Ok(result),
    StatusResult.NoExist => Results.NotFound(result),
    StatusResult.ValidationError => Results.BadRequest(result),
    StatusResult.ServiceUnavailable => Results.StatusCode(503),
    _ => Results.StatusCode(500)
};
// xUnit test
[Fact]
public async Task UpdateUser_WhenNotExists_ShouldReturnNoExist()
{
    var service = BuildServiceReturningNullFromRepository();
    var result = await service.UpdateUserAsync(10, new UpdateUserDto("John"), CancellationToken.None);

    Assert.Equal(StatusResult.NoExist, result.Status);
    Assert.False(result.IsSuccess());
}
Recipe B: Batch Import (Partial Success + Aggregated View)
public async Task<ResultBatch<ProductDto>> ImportAsync(IEnumerable<ImportProductDto> rows, CancellationToken ct)
{
    var items = new List<ResultEntity<ProductDto>>();

    foreach (var row in rows)
    {
        if (string.IsNullOrWhiteSpace(row.Name))
        {
            items.Add(Result.ValidationError<ProductDto>().AddError(nameof(row.Name), "Required"));
            continue;
        }

        var save = await Result.TryAsync(async token =>
            await _repo.InsertAsync(row.Name, token), cancellationToken: ct);

        items.Add(save.IsSuccess()
            ? Result.Added(new ProductDto(save.Entity!.Id, save.Entity.Name))
            : new ResultEntity<ProductDto>(save.Status, save.Errors));
    }

    return ResultBatch<ProductDto>.Ok(items)
        .WithMetadata("X-Import-Id", Guid.NewGuid().ToString("N"));
}
[Fact]
public async Task Import_WithMixedRows_ShouldExposePartialErrors()
{
    var result = await _service.ImportAsync(
        [new ImportProductDto("Keyboard"), new ImportProductDto("")],
        CancellationToken.None);

    Assert.True(result.HasPartialErrors);
    Assert.Equal(1, result.SuccessCount);
    Assert.Equal(1, result.ErrorCount);
}
Recipe C: Retry/Transient Classification (Caller Policy)
public async Task<ResultEntity<StockDto>> GetStockWithPolicyAsync(int productId, CancellationToken ct)
{
    var result = await Result.TryAsync(async token =>
        await _stockClient.GetAsync(productId, token),
        cancellationToken: ct,
        mapException: ex => ex switch
        {
            TimeoutException => StatusResult.ServiceUnavailable,
            HttpRequestException => StatusResult.HttpError,
            _ => StatusResult.Error
        });

    if (result.Status.IsTransientError())
    {
        // caller policy can schedule retry/backoff
        return new ResultEntity<StockDto>(result.Status, result.Errors)
            .WithRetryAfter(30);
    }

    return result.IsSuccess()
        ? Result.Ok(new StockDto(result.Entity!.ProductId, result.Entity.Available))
        : new ResultEntity<StockDto>(result.Status, result.Errors);
}

Decision Matrix (Quick PR Guide)

StatusResult Recommended HTTP Typical Action
Ok 200 OK Return payload or simple success envelope
Added 201 Created Return created payload + Location
Updated 200 OK / 204 No Content Return updated payload or no-content policy
Deleted 200 OK / 204 No Content Keep endpoint policy consistent
NoExist 404 Not Found (adapter decision) Preserve domain semantics in service, map in transport layer
NotFound 404 Not Found Use when transport-level absence is explicit
ValidationError 400 Bad Request Return structured field errors
UnprocessableEntity 422 Unprocessable Entity Keep semantic/business invalid state explicit
Unauthorized 401 Unauthorized Trigger auth flow
Forbidden 403 Forbidden Caller authenticated but not allowed
Conflict / ConcurrencyError 409 Conflict Return conflict details and optional retry hints
TooManyRequests 429 Too Many Requests Include Retry-After when possible
ServiceUnavailable 503 Service Unavailable Consider transient retry policy
DatabaseError / ConnectionError / HttpError / Error / Exception 500/502/503 per boundary Log, trace, and avoid leaking internals

PR checklist for this matrix:

  • Keep service semantics package-native (NoExist, ValidationError, etc.).
  • Perform HTTP translation in adapters (controller/minimal API/filter).
  • Keep mapping deterministic and documented per endpoint family.

Endpoint Policies by API Type

1. Public API (external consumers)
  • Prefer stable, minimal payloads and avoid exposing infrastructure details.
  • Suggested mapping:
    • ValidationError400
    • UnprocessableEntity422
    • NoExist / NotFound404
    • Unauthorized401, Forbidden403
    • infrastructure/system failures → 503 or generic 500
  • Always include correlation metadata (X-Correlation-Id) and sanitize error output.
2. Admin API (trusted operational clients)
  • Can expose richer operational context while preserving the same core semantics.
  • Suggested mapping:
    • keep business statuses explicit (Conflict, ConcurrencyError, TooManyRequests)
    • include Retry-After for throttling/transient states
    • keep NoExist distinct from NotFound in service logic, even if both map to 404
  • Include actionable error codes (ResultCode) and audit-friendly metadata.
3. Internal API (service-to-service)
  • Prioritize deterministic contracts and retry-aware behavior.
  • Suggested mapping:
    • transient statuses (ServiceUnavailable, ConnectionError, HttpError) should drive retry/backoff policies
    • DatabaseError can map to 503 for temporary outages or 500 for non-retryable failures
    • keep structured error payloads for diagnostics, but never leak sensitive internals
  • Document idempotency expectations for Updated/Deleted endpoints (200 vs 204 policy).

Recommended default:

  • Keep one shared status-to-HTTP table per API type.
  • Treat deviations as explicit, documented exceptions at endpoint level.

Core Components

1. Simple Result (Result)

Used for operations that don't return data, only a completion status and optional errors.

public Result DeleteUser(int id)
{
    if (id <= 0)
        return Result.ValidationError().AddError("id", "Invalid ID");

    var deleted = _repository.Delete(id);
    if (!deleted) return Result.NoExist();

    return Result.Deleted();
}

// Deconstruction
var (status, errors) = DeleteUser(1);
if (status == StatusResult.Deleted) { /* ... */ }
2. Result with Entity (ResultEntity<T>)

Used for operations returning a single object.

public ResultEntity<User> GetUser(int id)
{
    var user = _repository.Find(id);
    if (user == null) return Result.NoExist<User>();

    return ResultEntity<User>.Ok(user);
}

Implicit conversions and fluent API:

public ResultEntity<User> GetUser(int id)
{
    var user = _repository.Find(id);
    // T implicitly converts to ResultEntity<T>.Ok(entity)
    return user ?? (ResultEntity<User>)StatusResult.NoExist;
}

// Deconstruction
var (status, user) = GetUser(1);
if (status == StatusResult.Ok) { /* ... */ }

// Fluent API
var result = ResultEntity<User>.Ok(user)
    .WithCode(200)
    .AddError("System", "Service throttled");
3. Result with Multiple Entities (ResultEntities<T>)

Used for lists of results. It uses IReadOnlyList<T> for the Entities property to ensure efficiency. Direct iteration is supported via foreach as it implements IEnumerable<T>.

When an IEnumerable<T> is provided, the library materializes it into an IReadOnlyList<T> if needed. This gives stable indexing and prevents repeated enumeration of deferred sources.

public ResultEntities<User> GetActiveUsers()
{
    IEnumerable<User> users = _repository.GetActive();
    // Materializes the collection into IReadOnlyList
    return Result.OkEntities(users);
}

Use TryEntities / TryEntitiesAsync to handle exceptions automatically:

public Task<ResultEntities<User>> GetActiveUsersAsync()
    => Result.TryEntitiesAsync(() => _repository.GetActiveAsync());
4. Paged Result (ResultPaged<T>)

Inherits from ResultEntities<T> and includes complete metadata for paginated results (TotalCount, Page, PageSize, TotalPages, HasNextPage, HasPreviousPage).

When paging metadata is provided as a complete set through the constructor, Result.OkPaged(...), MaterializePaged(...), ToPaged(...), or WithPaging(...), the library enforces these invariants:

  • TotalCount >= 0
  • Page >= 1
  • PageSize > 0

Individual property setters remain permissive enough for progressive initialization and JSON deserialization, but complete paging operations are validated to avoid inconsistent states.

Create paged results easily using the Result static class:

public ResultPaged<User> GetUsers(PagingParameters paging)
{
    try 
    {
        var (users, total) = _repository.GetAll(paging);
        return Result.OkPaged(users, total, paging.Page, paging.PageSize);
    }
    catch (Exception ex)
    {
        return Result.DatabaseErrorPaged<User>(ex);
    }
}

// Deconstruction
var (status, users, total, page, pageSize) = result;

Use TryPagedAsync to handle exceptions and cancellation automatically:

public Task<ResultPaged<User>> GetUsersAsync(PagingParameters paging, CancellationToken ct)
    => Result.TryPagedAsync(
        async () => await _repository.GetAllAsync(paging, ct),
        paging.Page, paging.PageSize);
5. Batch Result (ResultBatch<T>)

Used for processing multiple items in a single request, providing a global status and individual results for each item using IReadOnlyList<ResultEntity<T>>.

As with ResultEntities<T>, batch collections are materialized to IReadOnlyList<ResultEntity<T>> when necessary. The batch Status represents the overall outcome of the operation, while each item in Entities keeps its own independent ResultEntity<T> status.

public ResultBatch<User> ImportUsers(List<User> users)
{
    var results = users.Select(u => (ResultEntity<User>)Process(u));
    return ResultBatch<User>.Ok(results);
}

// Deconstruction
var (status, results) = importResult;

// Aggregate indicators
bool hasPartialErrors = importResult.HasPartialErrors;
int successCount = importResult.SuccessCount;
int errorCount = importResult.ErrorCount;

Static factory methods are available for all common statuses:

  • ResultBatch<T>.Ok(entities)
  • ResultBatch<T>.ValidationError(errors)
  • ResultBatch<T>.DatabaseError(ex)
  • ResultBatch<T>.NotFound()
  • ResultBatch<T>.Forbidden()
  • ... and all other StatusResult states.

Error Handling

The library supports a fluent API for adding errors, which are stored in an ErrorCollection:

return Result.ValidationError()
    .AddError("Email", "Invalid format")
    .AddError("Password", "Too short")
    .AddError(new Exception("Inner system error"));

Associate a numeric code with any result using WithCode():

return Result.Error().WithCode(4001);

ErrorCollection exposes a Count property for O(1) access and Empty is always a fresh, non-shared instance:

bool hasErrors = result.Errors.Count > 0;
var emptyErrors = ErrorCollection.Empty; // always returns a new instance

Response Metadata

Results can carry response metadata through fluent helpers such as:

  • WithLocation(...)
  • WithETag(...)
  • WithRetryAfter(...)
  • WithContentLocation(...)
  • WithLastModified(...)
  • WithCacheControl(...)
  • WithMetadata(...) for custom keys

Internally, metadata is stored as a case-insensitive key/value dictionary through IHasMetadata. In the public System.Text.Json contract, well-known metadata is flattened into top-level JSON properties instead of a nested metadata object. WithMetadata(...) is not limited to predefined keys: callers can attach arbitrary metadata entries when needed. For safety, metadata keys and values reject control characters (for example \r, \n, \t) to reduce header/log injection risks in transport adapters.

Known projections:

  • Locationlocation
  • ETagetag
  • Retry-AfterretryAfter
  • Content-LocationcontentLocation
  • Last-ModifiedlastModified
  • Cache-ControlcacheControl

Examples of custom projections:

  • X-Correlation-IdxCorrelationId
  • Tenant-IdtenantId

To keep the JSON contract safe and unambiguous, WithMetadata(...) rejects keys that would collide with reserved JSON properties for the concrete result type. Examples include:

  • shared fields: status, resultCode, calculationTime, errors
  • ResultEntity<T>: entity
  • ResultEntities<T> / ResultPaged<T> / ResultBatch<T>: entities
  • ResultPaged<T>: totalCount, page, pageSize
  • derived helper names intentionally excluded from the contract, such as isSuccess, isFailure, totalPages, hasNextPage, hasPreviousPage, isEmpty, hasPartialErrors, successCount, and errorCount
  • control-character payloads in metadata keys/values (for example CR/LF) are also rejected

Example:

var result = Result.Ok(new ProductDto { Id = 1, Name = "Keyboard" })
    .WithLocation("/api/products/1")
    .WithETag("abc123")
    .WithRetryAfter(60);

Serialized JSON:

{
  "status": 1,
  "resultCode": null,
  "calculationTime": "2026-03-26T12:34:56+00:00",
  "errors": null,
  "location": "/api/products/1",
  "etag": "\"abc123\"",
  "retryAfter": "60",
  "entity": {
    "id": 1,
    "name": "Keyboard"
  }
}

Calculated/runtime helpers such as IsSuccess, IsFailure, TotalPages, HasNextPage, HasPreviousPage, IsEmpty, HasPartialErrors, SuccessCount, and ErrorCount are intentionally excluded from the public JSON contract.

System.Text.Json is the primary supported serializer for this public JSON contract, including flattened metadata fields and naming-policy-aware roundtrips. Newtonsoft.Json remains useful for basic model serialization in consumer code, but it should be treated as compatibility-oriented rather than as a guaranteed mirror of the converter-driven System.Text.Json payload shape.

Status Codes (StatusResult)

Status Category Description
Ok, Added, Updated, Deleted Success Successful operations.
NoExist Informational Requested resource was not found (semantic domain).
Warning Informational Completed with non-critical issues.
CancelOperation Control Operation was cancelled.
ValidationError Error Client-side input validation failed.
DataError, DatabaseError Error Issues with data processing or persistence.
ConcurrencyError Error Data was modified by another process.
ConnectionError Error The client could not connect to the backend server.
HttpError Error An error occurred while HttpClient was executing the HTTP operation.
Unauthorized Security Authentication or authorization failed.
Forbidden Security The server understood the request but refuses to authorize it (403).
NotFound Error The requested resource was not found (404).
Conflict Error The request conflicts with the current state of the server (409).
UnprocessableEntity Error Semantic errors in the request (422).
TooManyRequests Error Rate limit exceeded (429).
ServiceUnavailable Error Server is not ready to handle the request (503).
ChangePassword Security User must change their password.
Error, Exception Error Generic error or an unhandled exception.

Status Selection Guide

Use these rules to keep status semantics consistent across services and APIs:

  • Prefer NoExist when the functional contract means the requested data does not exist, even if the operation arrived through HTTP.
  • Prefer NotFound when the result is intended to map directly to an HTTP 404 response and transport-level absence is the intended meaning.
  • Use ValidationError when the input is invalid before processing starts.
  • Use UnprocessableEntity when the input is structurally valid but business semantics prevent execution.
  • Use DataError for domain or data consistency issues discovered during processing.
  • Use DatabaseError and ServiceUnavailable for persistence or service-availability failures.
  • Use ConnectionError when the client cannot connect to the backend server.
  • Use HttpError when HttpClient fails while executing the HTTP operation.
  • Use Conflict when the requested action collides with the current state of the resource.
  • Use Unauthorized when authentication is missing or invalid.
  • Use Forbidden when the caller is authenticated but does not have permission to perform the action.
  • Use Error as a generic fallback for known failures that do not fit a more specific status.
  • Use Exception only when the failure comes from an unexpected exception and no better status is available.

Examples:

// Domain absence in an internal service
return Result.NoExist<User>();

// API-facing 404 response
return Result.NotFound<User>();

// Invalid input
return Result.ValidationError<User>()
    .AddError("Email", "Invalid format");

// Valid request, but business rule prevents execution
return Result.UnprocessableEntity<User>()
    .AddError("State", "User is archived");

// Known fallback error
return Result.Error().AddError("Order", "Unable to complete the operation");

// Unexpected exception
catch (Exception ex)
{
    return Result.Exception<User>(ex);
}

StatusResult Extensions

Import Pitasoft.Result.Extensions to use semantic classification on StatusResult values:

if (result.Status.IsSuccess())            { /* Ok / Added / Updated / Deleted */ }
if (result.Status.IsInfrastructureError()) { /* DatabaseError / ConcurrencyError / ConnectionError / HttpError / ServiceUnavailable */ }
if (result.Status.IsBusinessError())       { /* ValidationError / DataError / NoExist / Unauthorized / Forbidden / NotFound / Conflict / UnprocessableEntity / ChangePassword */ }
if (result.Status.IsTransientError())      { /* Error / Exception / CancelOperation / ConcurrencyError / ConnectionError / HttpError / TooManyRequests / ServiceUnavailable */ }
if (result.Status.IsError())               { /* General or specialized error states (Validation, Data, Database, Concurrency, HTTP, Exception, etc.) */ }

These are especially useful in middleware or retry policies to decide how to handle a failure without inspecting the Status value directly.

Extensions

Import Pitasoft.Result.Extensions to use these helper methods on any IResult:

  • result.IsSuccess(): Returns true if status is Ok, Added, Updated, or Deleted.
  • result.IsError(): Returns true for most error-related statuses.
  • result.IsFailure(): Returns true for any non-successful state, excluding None, NoExist, and Warning. Covers Unauthorized, Exception, CancelOperation, and more.
  • result.IsOk(): Returns true if the status is exactly Ok.
  • result.IsWarning(): Returns true if the status is Warning.
  • result.IsNotExist(): Returns true if the resource was not found (NoExist).
  • result.IsUnauthorized(): Returns true if the status is Unauthorized.
  • result.IsForbidden() / result.IsNotFound() / result.IsConflict() / result.IsTooManyRequests() / result.IsUnprocessableEntity() / result.IsServiceUnavailable(): Semantic checks for HTTP-like error states.
  • result.HasErrors(): Returns true if there are any errors in the Errors collection.
  • result.IsSuccessOrNotExist(): Useful for "Delete" operations where both cases are often handled similarly.
  • result.IsSuccessOrWarning(): Returns true for successful or warning states.
  • Materialize() / MaterializeAsync(): Convert IEnumerable<T> or IAsyncEnumerable<T> to ResultEntities<T> or ResultPaged<T>.
  • UpdateTimestamp(): Manually updates the CalculationTime to the current UTC time on Result and derived types.
  • ToPaged(): Convert a result to a ResultPaged<T> preserving state. Requires valid paging metadata (TotalCount >= 0, Page >= 1, PageSize > 0).
  • ToResultEntity() / ToResultEntities(): Convert between result types preserving metadata.
  • ToOkResult() / ToAddedResult() / ToUpdatedResult() / ToDeletedResult(): Convert an entity to a ResultEntity<T> with the corresponding success status.

Results also validate Status assignments against the defined StatusResult enum values. This helps detect invalid manual assignments early when working with mutable instances or custom deserialization flows.

Functional API (Match, Map, Bind, Ensure, EnsureAsync, Recover, Tap, TapAsync)

Import Pitasoft.Result.Extensions to use functional-style transformations.

Behavior notes:

  • Match and Tap do not change the result. They only branch or execute side effects.
  • Map projects successful values into a new result. For collection and paged results it preserves Status, Errors, ResultCode, CalculationTime, and attached metadata entries. For ResultPaged<T> it also preserves pagination metadata.
  • Bind short-circuits on failure. For collection and paged results, failure propagation preserves ResultCode, CalculationTime, and attached metadata entries; on success, the binder owns the final result it returns.
  • Ensure / EnsureAsync only run on successful results. If validation fails, they return or produce a validation error instead of continuing the success flow.
  • Recover unwraps a ResultEntity<T> into a plain T, providing a fallback only when the result is not successful.
  • Ensure on ResultPaged<T> and ResultBatch<T> updates the current instance to ValidationError and appends errors, instead of allocating a new container result.
Match — branch on success or failure

Available for Result, ResultEntity<T>, ResultEntities<T>, and ResultPaged<T>:

// Result
string msg = result.Match(
    onSuccess: () => "Done",
    onFailure: r => $"Error: {r.Status}");

// ResultEntity<T>
var dto = userResult.Match(
    onSuccess: () => mapper.ToDto(userResult.Entity),
    onFailure: r => throw new InvalidOperationException(r.Status.ToString()));

// ResultEntities<T> / ResultPaged<T>
var view = pagedResult.Match(
    onSuccess: () => BuildView(pagedResult),
    onFailure: r => ErrorView(r));
MatchAsync — async branch on success or failure

Available for Task<Result>, Task<ResultEntity<T>>, Task<ResultEntities<T>>, and Task<ResultPaged<T>>:

var response = await Task.FromResult(pagedResult).MatchAsync(
    onSuccess: (items, total, page, pageSize) => BuildPagedResponse(items, total, page, pageSize),
    onFailure: r => HandleFailure(r));
Map — transform the entity if successful

Available for ResultEntity<T>, ResultEntities<T>, and ResultPaged<T>:

ResultEntity<UserDto>    dto    = userResult.Map(user => mapper.ToDto(user));
ResultEntities<UserDto>  dtos   = usersResult.Map(user => mapper.ToDto(user));
ResultPaged<UserDto>     paged  = pagedResult.Map(user => mapper.ToDto(user));

If the source result is not successful, Map does not execute the projection and propagates the failure state instead.

MapAsync — chain async transformations
ResultEntity<UserDto>   dto   = await userResult.MapAsync(async u => await EnrichAsync(u));
ResultEntities<UserDto> dtos  = await usersResult.MapAsync(async u => await EnrichAsync(u));
ResultPaged<UserDto>    paged = await pagedResult.MapAsync(async u => await EnrichAsync(u));
Tap — execute an action without changing the result
result.Tap(() => _logger.LogInformation("Operation successful"))
      .Tap(user => _cache.Set(user));
TapAsync — async side-effect without changing the result

Works on any result type (Result, ResultEntity<T>, ResultEntities<T>, ResultPaged<T>):

var result = await GetUserAsync(id)
    .TapAsync(async () => await _auditService.LogAsync("user fetched"));

// Typed variant
var result = await GetUserAsync(id)
    .TapAsync(async user => await _cache.SetAsync(user));
Bind — chain operations that return results (FlatMap)

Available for ResultEntity<T>, ResultEntities<T>, and ResultPaged<T>:

ResultEntity<Order> result = GetUser(id)
    .Bind(user => CreateOrder(user));

ResultPaged<OrderDto> paged = GetPagedOrders(page, size)
    .Bind(o => EnrichOrder(o));

Bind only invokes the binder when the source result is successful. If the source has already failed, the chain stops and the failure state is propagated.

Ensure — validate a condition

On Result (base):

Result result = CheckPermissions(userId)
    .Ensure(() => _quota.HasCapacity(), "Quota exceeded", "Quota");

On ResultEntity<T>:

ResultEntity<User> result = GetUser(id)
    .Ensure(u => u.Age >= 18, "User must be an adult", "Age");

For Result and ResultEntity<T>, a failed Ensure returns a validation result. For ResultPaged<T> and ResultBatch<T>, a failed Ensure mutates the current instance to ValidationError and adds the corresponding error.

EnsureAsync — validate an asynchronous condition

Perform validations that require I/O, like database checks, within the fluent chain:

ResultEntity<User> result = await GetUser(id)
    .EnsureAsync(async u => await _repo.IsEmailUniqueAsync(u.Email), "Email already exists", "Email");
Recover — provide a fallback value on failure

Safely handle failures by providing a default value or a recovery function:

// Simple fallback
User user = GetUser(id).Recover(new User { Name = "Guest" });

// Recovery function
User user = GetUser(id).Recover(r => new User { Name = $"Guest (Error: {r.Status})" });

Recover is intentionally an unwrap operation for ResultEntity<T>: after calling it, you are working with a plain value instead of a result object.

Exception-safe Factory Methods (Try)

All Try variants automatically catch exceptions. OperationCanceledException is mapped to CancelOperation instead of Exception. You can now optionally provide a mapException function to transform specific exceptions into meaningful StatusResult codes. If mapException returns a success state (Ok, Added, Updated, Deleted, or None) for an exception path, the library normalizes it to Error to avoid false-positive success responses.

Method Returns Notes
Result.Try(action, deep?, mapEx?) Result Sync, no return value
Result.Try<T>(func, deep?, mapEx?) ResultEntity<T> Sync, returns entity
Result.TryAsync(action, ct?, deep?, mapEx?) Task<Result> Async, supports CancellationToken
Result.TryAsync<T>(func, ct?, deep?, mapEx?) Task<ResultEntity<T>> Async entity, supports CancellationToken
Result.TryEntities<T>(func, deep?, mapEx?) ResultEntities<T> Sync collection
Result.TryEntitiesAsync<T>(func, deep?, mapEx?) Task<ResultEntities<T>> Async collection
Result.TryPagedAsync<T>(func, page, pageSize, deep?, mapEx?) Task<ResultPaged<T>> Async paged collection
Result.TryOutcome(...)/TryOutcomeAsync(...) Result / ResultEntity<T> Uses explicit TryResult outcomes
Result.TryOutcomeEntities(...)/TryOutcomeEntitiesAsync(...) ResultEntities<T> Outcome-based collection
Result.TryOutcomePagedAsync(...) ResultPaged<T> Outcome-based paged collection

Use this quick rule:

  • Use Try* when your delegate returns raw values (or no value) and you only need exception-to-result mapping.
  • Use TryOutcome* when your delegate must explicitly decide StatusResult (NotFound, ValidationError, NoExist, etc.).
  • Keep transport semantics explicit: use NoExist for domain/data absence and NotFound for HTTP-style 404 contracts.
  • Prefer semantic factories (Ok, Added, Updated, Deleted, NoExist, Error, Exception) instead of generic error fallbacks.
// Automatic mapping of exceptions to status codes
var result = Result.Try(() => _repo.Get(id), 
    mapException: ex => ex is KeyNotFoundException ? StatusResult.NotFound : StatusResult.Error);

// CancellationToken support — OperationCanceledException → CancelOperation
var result = await Result.TryAsync(
    async ct => await _service.ProcessAsync(ct),
    cancellationToken);

// Entities
ResultEntities<Product> products = Result.TryEntities(() => _repo.GetAll());

// Paged
ResultPaged<Product> page = await Result.TryPagedAsync(
    async () => await _repo.GetPagedAsync(1, 20),
    page: 1, pageSize: 20);

// Explicit outcome API (no Continue/Cancel; use semantic factories)
Result outcome = await Result.TryOutcomeAsync(async () =>
{
    await Task.Yield();
    return TryResult.NotFound();
});

ResultEntity<Product> outcomeEntity = await Result.TryOutcomeAsync(async () =>
{
    await Task.Yield();
    return TryResult<Product>.Added(new Product { Id = 10, Name = "Tablet" });
});
Migration note (TryResult)
  • TryResult.Continue(...) and TryResult.Cancel(...) are no longer used.
  • Use semantic outcome factories instead:
    • TryResult.Ok(), TryResult.Added(), TryResult.NoExist()
    • TryResult.ValidationError(...), TryResult.NotFound(), TryResult.Exception(ex, deep)
  • For payload outcomes, use TryResult<T>.Ok(entity), TryResult<T>.Added(entity), etc.
  • When delegates return TryResult / TryResult<T>, prefer Result.TryOutcome* APIs.

Combine Results

Aggregate multiple Result instances into one. The combined result is Ok if all succeed; otherwise it carries all errors from failed results.

// Synchronous
Result combined = Result.Combine(
    ValidateName(dto.Name),
    ValidateEmail(dto.Email),
    ValidateAge(dto.Age));

// Asynchronous
Result combined = await Result.CombineAsync(
    ValidateNameAsync(dto.Name),
    ValidateEmailAsync(dto.Email));

Pagination and Entity Parameters

Use PagingParameters to receive pagination-only input, QueryParameters for filtering/search/sorting without pagination, and EntityParameters when you need both concerns together. If you prefer to program against contracts instead of concrete classes, PagingParameters implements IPagingParameters, QueryParameters implements IQueryParameters, and EntityParameters combines both.

GetParameters() returns IEnumerable<KeyValuePair<string, string>>, which supports multiple values for the same key (useful for array-style query strings like ?tag=a&tag=b):

public ResultPaged<User> GetUsers(EntityParameters parameters)
{
    // parameters.Page, parameters.PageSize, parameters.Search, parameters.Query, parameters.Order, parameters.Attrs
    var (users, total) = _repository.GetAll(parameters);
    return Result.OkPaged(users, total, parameters.Page, parameters.PageSize);
}

// Convert to Dictionary when a single-value map is sufficient
var dict = parameters.GetParameters().ToDictionary(kv => kv.Key, kv => kv.Value);

// Or use directly with HttpClient query builders that accept IEnumerable<KVP>
var query = QueryString.Create(parameters.GetParameters());

Batch Helper (Batch<T>)

Use Batch<T> to describe a set of entities and the action to perform on them:

var batch = Batch<User>.Append(newUsers);
var batch = Batch<User>.Update(modifiedUsers);
var batch = Batch<User>.Delete(removedUsers);

Performance

Performance tips
  • Factory Fast Paths: Common Result states such as NoExist() and ValidationError() use small inlined factory helpers, keeping creation overhead low without changing result mutability semantics.
  • Parameter Optimization: PagingParameters.GetParameters() and EntityParameters.GetParameters() now use yield return instead of creating temporary lists, improving memory efficiency from $O(n)$ to $O(1)$ space.
  • Collection Efficiency: ResultEntities<T> and ResultPaged<T> use IReadOnlyList<T> for the Entities property. Collections are materialized efficiently into a list only when necessary.
  • Aggressive Inlining: Static factories and extension methods are annotated with AggressiveInlining to minimize overhead.
  • Direct Iteration: Supported via foreach. Internally, its enumerator delegates to the underlying collection without using yield, avoiding extra state-machine allocations.
  • JSON Payload: Result payloads flatten well-known metadata into top-level properties such as location or etag, while automatically excluding calculated helpers like TotalPages, HasNextPage, and HasPreviousPage.
  • Status checks: IsSuccess(), IsFailure() and the other semantic extension methods are annotated with AggressiveInlining to keep their overhead minimal in hot paths.
  • Exception handling: When creating error results from exceptions manually, pass deep: false unless you explicitly need inner exception details to reduce allocations in ErrorCollection.

Benchmark results for representative operations (Mean time / Allocated memory):

The library is optimized to minimize allocations and maximize throughput. The current Pitasoft.Result.Benchmarks suite also includes the new functional and paging scenarios introduced in recent revisions.

Quick take:

  • .NET 10 leads the representative creation and functional paths in this benchmark set, especially around paging and ResultBatch<T> transformations.
  • .NET 8 stays very competitive and currently delivers the strongest System.Text.Json serialization numbers on this machine, while also remaining the LTS baseline.
  • .NET 9 works correctly and lands between both runtimes in most scenarios, but it is not the clear winner in this profile.
Operation .NET 8 .NET 9 .NET 10
Result.Ok 18.38 ns / 64 B 19.22 ns / 64 B 19.91 ns / 64 B
Result.Error(Exception) 44.12 ns / 376 B 48.85 ns / 376 B 42.78 ns / 376 B
ResultEntities.Ok 23.64 ns / 72 B 24.28 ns / 72 B 20.32 ns / 72 B
Result.OkPaged 24.35 ns / 88 B 25.28 ns / 88 B 19.65 ns / 88 B
ResultBatch.Ok(entities) 24.17 ns / 72 B 24.78 ns / 72 B 23.03 ns / 72 B
ResultEntity.Map 25.57 ns / 72 B 23.69 ns / 72 B 20.92 ns / 72 B
ResultPaged.WithPaging 19.15 ns / 88 B 19.93 ns / 88 B 17.07 ns / 88 B
ResultPaged.Bind (failure path) 22.97 ns / 88 B 24.58 ns / 88 B 20.73 ns / 88 B
ResultBatch.Map (failure path) 24.00 ns / 96 B 25.22 ns / 96 B 22.51 ns / 96 B
ResultBatch.Bind (success path) 107.04 ns / 416 B 112.56 ns / 416 B 100.61 ns / 416 B
ResultBatch.Ensure (failure path) 92.90 ns / 568 B 95.04 ns / 568 B 81.35 ns / 568 B

Methodology:

To regenerate the representative benchmark suite and keep separate snapshots for .NET 8, .NET 9, and .NET 10, run:

./scripts/run-benchmarks.sh

You can also restrict the execution to specific runtimes:

./scripts/run-benchmarks.sh net10.0

The script stores per-runtime reports under BenchmarkDotNet.Artifacts/snapshots/<framework> and refreshes the benchmark tables in this README from those snapshots automatically.

If you already have fresh snapshots and only want to rebuild the tables, run:

./scripts/update-benchmark-readme.sh

Representative JSON serialization benchmarks (System.Text.Json):

Operation .NET 8 .NET 9 .NET 10
Serialize Result 216.8 ns / 656 B 220.6 ns / 656 B 197.1 ns / 656 B
Deserialize Result 481.0 ns / 1344 B 484.6 ns / 1320 B 500.5 ns / 1320 B
Serialize ResultEntities 411.1 ns / 944 B 404.8 ns / 944 B 431.5 ns / 944 B
Deserialize ResultEntities 1,039.9 ns / 1152 B 1,019.7 ns / 1152 B 1,128.0 ns / 1152 B
Serialize ResultPaged 471.2 ns / 1144 B 470.7 ns / 1144 B 496.1 ns / 1144 B
Deserialize ResultPaged 1,312.0 ns / 1376 B 1,267.2 ns / 1376 B 1,414.9 ns / 1376 B

Microbenchmarks for status and result checks:

These numbers are useful as relative signals, not as absolute promises. When BenchmarkDotNet reports 0.0000 ns in these tiny checks, it means the runtime optimized the path so aggressively that the cost is indistinguishable from the empty-method baseline in this setup.

Operation .NET 8 .NET 9 .NET 10
Status direct success check 0.2312 ns / 0 B 0.5399 ns / 0 B 0.0175 ns / 0 B
Status.IsSuccess() 0.4051 ns / 0 B 0.1336 ns / 0 B 0.0000 ns / 0 B
Status.IsFailure() 0.2283 ns / 0 B 0.0000 ns / 0 B 0.1625 ns / 0 B
IResult.IsSuccess() 0.3246 ns / 0 B 0.0000 ns / 0 B 0.2668 ns / 0 B
IResult.IsFailure() 0.2608 ns / 0 B 0.0740 ns / 0 B 0.2546 ns / 0 B
IResult.HasErrors() without errors 0.2572 ns / 0 B 0.0000 ns / 0 B 0.0411 ns / 0 B
IResult.HasErrors() with errors 0.5401 ns / 0 B 0.0000 ns / 0 B 0.3177 ns / 0 B
Status.IsError() 0.2573 ns / 0 B 0.0000 ns / 0 B 0.2570 ns / 0 B

Castellano

Pitasoft.Result es una librería .NET diseñada para estandarizar las respuestas de los servicios REST y las capas internas de la aplicación. Proporciona un conjunto robusto de clases para envolver datos, códigos de estado y colecciones de errores, facilitando un contrato de comunicación unificado entre APIs, servicios y clientes.

Características

  • Respuestas Estandarizadas: Unifica las salidas de tu API usando Result, ResultEntity<T>, ResultEntities<T>, ResultPaged<T> y ResultBatch<T>.
  • Marcas de Tiempo Automáticas: Todos los resultados incluyen ahora CalculationTime (DateTimeOffset?) para rastrear cuándo fueron generados, a través de la interfaz IHasCalculationTimestamp.
  • Gestión de Errores Fluida: Añade fácilmente errores de validación, excepciones o violaciones de reglas de negocio mediante una interfaz fluida. Incluye Result.Try y Result.TryAsync con soporte nativo para CancellationToken y conversión automática de OperationCanceledExceptionCancelOperation.
  • Gestión de Estados Enriquecida: Enumerado StatusResult integrado que cubre escenarios comunes (Éxito, No Encontrado, Prohibido, Conflicto, Errores de Validación, Errores de Base de Datos, etc.). Incluye StatusResultExtensions para clasificación semántica (IsSuccess, IsInfrastructureError, IsBusinessError, IsTransientError, IsError).
  • Operaciones por Lote: Soporte especializado para procesar múltiples elementos con ResultBatch<T> usando IReadOnlyList<T> para un acceso eficiente.
  • Soporte para Paginación: ResultPaged<T> proporciona metadatos completos de paginación (TotalCount, Page, PageSize, TotalPages, etc.). Incluye un conjunto completo de métodos de factoría estáticos en la clase Result (ej., Result.ErrorPaged, Result.DatabaseErrorPaged, Result.NotFoundPaged) y TryPagedAsync para consultas paginadas con manejo automático de excepciones.
  • Soporte Async: Soporte nativo para IAsyncEnumerable<T> y Task<T> con MaterializeAsync y ToResultEntitiesAsync. Incluye extensiones MapAsync, BindAsync, MatchAsync y TapAsync.
  • Serialización Mejorada: Los conversores JSON mantienen salidas de API limpias y predecibles en Result, ResultEntity<T>, ResultEntities<T>, ResultPaged<T> y ResultBatch<T>, aplanando metadatos conocidos como Location, ETag o Retry-After en propiedades JSON de primer nivel.
  • Conversiones Implícitas: Convierte StatusResult o entidades directamente a tipos de resultado sin código repetitivo.
  • Deconstrucción: Usa la deconstrucción de C# para extraer valores y estado fácilmente: var (status, user) = result;.
  • Extensiones: Métodos de ayuda útiles para comprobar éxito, errores específicos o estado. Incluyendo ToOkResult(), ToAddedResult(), ToUpdatedResult() y ToDeletedResult() para convertir entidades en resultados.
  • API Funcional: Match, MatchAsync, Map, MapAsync, Tap, TapAsync, Bind y Ensure para el procesamiento fluido de resultados. Todas las operaciones están disponibles para Result, ResultEntity<T>, ResultEntities<T> y ResultPaged<T>.
  • Combinar Resultados: Agrega múltiples resultados en uno solo con Result.Combine() y Result.CombineAsync().
  • Parámetros Multivalor: IParameters.GetParameters() devuelve IEnumerable<KeyValuePair<string, string>>, soportando múltiples valores para la misma clave (ej., ?tag=a&tag=b).
  • Mejoras en ErrorCollection: Empty es ahora una propiedad (instancia nueva y segura). Propiedad Count directa con acceso O(1).
  • Rendimiento: Implementación de alto rendimiento con mínimas asignaciones, AggressiveInlining y materialización eficiente de colecciones mediante IReadOnlyList<T>.

Instalación

dotnet add package Pitasoft.Result

Inicio rápido

// 1) Resultado simple
Result r1 = Result.Ok();
Result r2 = Result.ValidationError().AddError("Email", "No válido");

// 2) Entidad
ResultEntity<User> u = Result.Ok(new User { Id = 1, Name = "John" });
var (status, user) = u; // deconstrucción
var (s, e) = u; // estado y entidad
var (st, code, errs) = (Result)u; // deconstrucción con código de resultado desde Result base

// 3) Colección paginada
var users = new List<User> { new() { Id = 1, Name = "John" } };
ResultPaged<User> paginado = Result.OkPaged(users, totalCount: 100, page: 1, pageSize: 10);
foreach (var it in paginado) { /* iteración directa */ }

// 4) Automatización Try/Catch (con soporte CancellationToken)
var resultado = Result.Try(() => HacerTrabajo());
var resultadoAsync = await Result.TryAsync(async ct => await HacerTrabajoAsync(ct), cancellationToken);

// 5) Variantes Try para colecciones
ResultEntities<User> entidades = Result.TryEntities(() => ObtenerUsuarios());
ResultEntities<User> entidadesAsync = await Result.TryEntitiesAsync(async () => await ObtenerUsuariosAsync());
ResultPaged<User> resultadoPaginado = await Result.TryPagedAsync(
    async () => await ObtenerPaginadoAsync(), page: 1, pageSize: 20);

// 6) Extensiones funcionales (disponibles en todos los tipos de resultado)
var dto = u.Map(x => new UserDto(x!.Id, x.Name));
var okOTira = u.Match(
    onSuccess: () => dto,
    onFailure: res => throw new InvalidOperationException(res.Status.ToString()));

// MatchAsync sobre Task<ResultPaged<T>>
var respuesta = await Task.FromResult(resultadoPaginado).MatchAsync(
    onSuccess: (items, total, page, pageSize) => ConstruirDtoPaginado(items, total, page, pageSize),
    onFailure: r => ManejarError(r));

// 7) Combinar resultados (síncrono y asíncrono)
var combinado = Result.Combine(Result.Ok(), Result.Error().AddError("E", "err"));
var combinadoAsync = await Result.CombineAsync(Tarea1Async(), Tarea2Async());

// 8) IsSuccess / IsFailure como métodos de extensión
if (resultado.IsSuccess()) { /* ... */ }
if (resultado.IsFailure()) { /* ... */ }

// 9) Extensiones semánticas de StatusResult
if (resultado.Status.IsInfrastructureError()) { /* lógica de reintento */ }
if (resultado.Status.IsBusinessError()) { /* devolver 422 */ }

// 10) Marca de tiempo de cálculo
DateTimeOffset? tiempo = resultado.CalculationTime;

CalculationTime se normaliza a UTC al asignarse, por lo que puede tratarse como una marca temporal estable entre sistemas.

Flujos de Implementación (Prácticos)

flowchart LR
    A["DTO de entrada"] --> B{"¿Validación correcta?"}
    B -- No --> C["Result.ValidationError(...)"]
    B -- Sí --> D["Result.TryAsync(...)"]
    D --> E{"¿Excepción?"}
    E -- Sí --> F["mapException -> StatusResult (normalización segura)"]
    E -- No --> G["Result.Added/Ok(...)"]
    G --> H["WithLocation / WithETag / WithMetadata"]
    F --> I["Retornar resultado de fallo"]
    H --> J["Retornar resultado exitoso"]
flowchart LR
    A["Repository Find(id)"] --> B{"¿Existe entidad?"}
    B -- No --> C["Result.NoExist<T>()"]
    B -- Sí --> D["Result.Ok(entity)"]
    C --> E["Capa adaptadora HTTP"]
    D --> E
    E --> F{"Mapeo de transporte"}
    F --> G["NoExist -> 404 (si la política API lo exige)"]
    F --> H["Ok -> 200"]

Checklist de Errores Comunes

  • Devolver NotFound en servicio/dominio cuando la semántica real es NoExist.
  • Usar TryOutcome* en delegados planos que deberían usar Try*.
  • Devolver estados de éxito desde mapException (se normalizan a Error).
  • Añadir claves de metadata que colisionan con campos JSON reservados (status, entity, entities, page, totalCount, etc.).
  • Pasar metadata con caracteres de control (\r, \n, \t), que se rechazan.
  • Suponer que Newtonsoft.Json replica exactamente el contrato aplanado de System.Text.Json sin configuración/adaptador explícito.
  • Reconstruir resultados fallidos perdiendo Errors, ResultCode, CalculationTime o metadata.

Recetas de Implementación (Copiar/Pegar)

Receta A: CRUD (Servicio + Mapeo HTTP + Test)
// Servicio
public async Task<ResultEntity<UserDto>> UpdateUserAsync(int id, UpdateUserDto input, CancellationToken ct)
{
    if (id <= 0)
        return Result.ValidationError<UserDto>().AddError(nameof(id), "Id inválido");

    if (string.IsNullOrWhiteSpace(input.Name))
        return Result.ValidationError<UserDto>().AddError(nameof(input.Name), "El nombre es obligatorio");

    var outcome = await Result.TryAsync(async token =>
    {
        var entity = await _repo.UpdateAsync(id, input.Name, token);
        return entity is null
            ? null
            : new UserDto(entity.Id, entity.Name, entity.Version);
    }, cancellationToken: ct, mapException: ex =>
        ex is TimeoutException ? StatusResult.ServiceUnavailable : StatusResult.DatabaseError);

    if (outcome.IsFailure())
        return new ResultEntity<UserDto>(outcome.Status, outcome.Errors);

    if (outcome.Entity is null)
        return Result.NoExist<UserDto>();

    return Result.Updated(outcome.Entity)
        .WithETag($"user-{outcome.Entity.Id}-v{outcome.Entity.Version}");
}

// Adaptador HTTP (controller/minimal API)
public static Microsoft.AspNetCore.Http.IResult ToHttp(ResultEntity<UserDto> result) => result.Status switch
{
    StatusResult.Updated => Results.Ok(result),
    StatusResult.NoExist => Results.NotFound(result),
    StatusResult.ValidationError => Results.BadRequest(result),
    StatusResult.ServiceUnavailable => Results.StatusCode(503),
    _ => Results.StatusCode(500)
};
// Test xUnit
[Fact]
public async Task UpdateUser_CuandoNoExiste_DebeRetornarNoExist()
{
    var service = BuildServiceReturningNullFromRepository();
    var result = await service.UpdateUserAsync(10, new UpdateUserDto("John"), CancellationToken.None);

    Assert.Equal(StatusResult.NoExist, result.Status);
    Assert.False(result.IsSuccess());
}
Receta B: Importación Batch (Éxito Parcial + Vista Agregada)
public async Task<ResultBatch<ProductDto>> ImportAsync(IEnumerable<ImportProductDto> rows, CancellationToken ct)
{
    var items = new List<ResultEntity<ProductDto>>();

    foreach (var row in rows)
    {
        if (string.IsNullOrWhiteSpace(row.Name))
        {
            items.Add(Result.ValidationError<ProductDto>().AddError(nameof(row.Name), "Obligatorio"));
            continue;
        }

        var save = await Result.TryAsync(async token =>
            await _repo.InsertAsync(row.Name, token), cancellationToken: ct);

        items.Add(save.IsSuccess()
            ? Result.Added(new ProductDto(save.Entity!.Id, save.Entity.Name))
            : new ResultEntity<ProductDto>(save.Status, save.Errors));
    }

    return ResultBatch<ProductDto>.Ok(items)
        .WithMetadata("X-Import-Id", Guid.NewGuid().ToString("N"));
}
[Fact]
public async Task Import_ConFilasMixtas_DebeMostrarErroresParciales()
{
    var result = await _service.ImportAsync(
        [new ImportProductDto("Keyboard"), new ImportProductDto("")],
        CancellationToken.None);

    Assert.True(result.HasPartialErrors);
    Assert.Equal(1, result.SuccessCount);
    Assert.Equal(1, result.ErrorCount);
}
Receta C: Clasificación Retry/Transitorio (Política del Caller)
public async Task<ResultEntity<StockDto>> GetStockWithPolicyAsync(int productId, CancellationToken ct)
{
    var result = await Result.TryAsync(async token =>
        await _stockClient.GetAsync(productId, token),
        cancellationToken: ct,
        mapException: ex => ex switch
        {
            TimeoutException => StatusResult.ServiceUnavailable,
            HttpRequestException => StatusResult.HttpError,
            _ => StatusResult.Error
        });

    if (result.Status.IsTransientError())
    {
        // la capa llamadora puede aplicar retry/backoff
        return new ResultEntity<StockDto>(result.Status, result.Errors)
            .WithRetryAfter(30);
    }

    return result.IsSuccess()
        ? Result.Ok(new StockDto(result.Entity!.ProductId, result.Entity.Available))
        : new ResultEntity<StockDto>(result.Status, result.Errors);
}

Matriz de Decisión (Guía Rápida para PR)

StatusResult HTTP recomendado Acción típica
Ok 200 OK Retornar payload o envoltura de éxito
Added 201 Created Retornar payload creado + Location
Updated 200 OK / 204 No Content Retornar payload actualizado o política sin cuerpo
Deleted 200 OK / 204 No Content Mantener la política del endpoint consistente
NoExist 404 Not Found (decisión de adaptador) Mantener semántica de dominio en servicio y mapear en transporte
NotFound 404 Not Found Usar cuando la ausencia de transporte sea explícita
ValidationError 400 Bad Request Retornar errores de validación estructurados
UnprocessableEntity 422 Unprocessable Entity Mantener explícita la invalidez semántica/de negocio
Unauthorized 401 Unauthorized Activar flujo de autenticación
Forbidden 403 Forbidden Usuario autenticado sin permisos
Conflict / ConcurrencyError 409 Conflict Retornar detalle de conflicto y opcionalmente pistas de retry
TooManyRequests 429 Too Many Requests Incluir Retry-After cuando aplique
ServiceUnavailable 503 Service Unavailable Considerar política de retry transitorio
DatabaseError / ConnectionError / HttpError / Error / Exception 500/502/503 según frontera Loguear, trazar y evitar filtrar detalles internos

Checklist de PR para esta matriz:

  • Mantener semántica nativa del paquete en servicios (NoExist, ValidationError, etc.).
  • Realizar traducción HTTP en adaptadores (controller/minimal API/filtro).
  • Conservar mapeos deterministas y documentados por familia de endpoints.

Políticas de Endpoints por Tipo de API

1. API pública (consumo externo)
  • Prioriza payloads estables y mínimos, evitando exponer detalles de infraestructura.
  • Mapeo sugerido:
    • ValidationError400
    • UnprocessableEntity422
    • NoExist / NotFound404
    • Unauthorized401, Forbidden403
    • fallos de infraestructura/sistema → 503 o 500 genérico
  • Incluye siempre metadata de correlación (X-Correlation-Id) y sanitiza la salida de errores.
2. API admin (clientes operacionales de confianza)
  • Puede exponer más contexto operativo manteniendo la misma semántica base.
  • Mapeo sugerido:
    • mantener estados de negocio explícitos (Conflict, ConcurrencyError, TooManyRequests)
    • incluir Retry-After para estados de throttling/transitorios
    • mantener NoExist distinto de NotFound en lógica de servicio, aunque ambos se traduzcan a 404
  • Incluir códigos de error accionables (ResultCode) y metadata útil para auditoría.
3. API interna (servicio a servicio)
  • Prioriza contratos deterministas y comportamiento orientado a reintentos.
  • Mapeo sugerido:
    • estados transitorios (ServiceUnavailable, ConnectionError, HttpError) deben activar políticas de retry/backoff
    • DatabaseError puede mapearse a 503 en caídas temporales o 500 en fallos no reintentables
    • mantener payload de error estructurado para diagnóstico sin filtrar información sensible
  • Documentar expectativas de idempotencia para endpoints Updated/Deleted (política 200 vs 204).

Recomendación por defecto:

  • Mantener una tabla única de status->HTTP por tipo de API.
  • Tratar desviaciones como excepciones explícitas y documentadas a nivel de endpoint.

Componentes Principales

1. Resultado Simple (Result)

Utilizado para operaciones que no devuelven datos, solo un estado de finalización y errores opcionales.

public Result DeleteUser(int id)
{
    if (id <= 0)
        return Result.ValidationError().AddError("id", "ID no válido");

    var deleted = _repository.Delete(id);
    if (!deleted) return Result.NoExist();

    return Result.Deleted();
}

// Deconstrucción
var (status, errors) = DeleteUser(1);
if (status == StatusResult.Deleted) { /* ... */ }
2. Resultado con Entidad (ResultEntity<T>)

Utilizado para operaciones que devuelven un único objeto.

public ResultEntity<User> GetUser(int id)
{
    var user = _repository.Find(id);
    if (user == null) return Result.NoExist<User>();

    return ResultEntity<User>.Ok(user);
}

Conversiones implícitas y API fluida:

public ResultEntity<User> GetUser(int id)
{
    var user = _repository.Find(id);
    // T se convierte implícitamente a ResultEntity<T>.Ok(entity)
    return user ?? (ResultEntity<User>)StatusResult.NoExist;
}

// Deconstrucción
var (status, user) = GetUser(1);
if (status == StatusResult.Ok) { /* ... */ }

// API Fluida
var result = ResultEntity<User>.Ok(user)
    .WithCode(200)
    .AddError("System", "Servicio saturado");
3. Resultado con Múltiples Entidades (ResultEntities<T>)

Utilizado para listas de resultados. Utiliza IReadOnlyList<T> para la propiedad Entities para garantizar la eficiencia. Se puede iterar directamente sobre el objeto mediante foreach, ya que implementa IEnumerable<T>.

Cuando se proporciona un IEnumerable<T>, la librería lo materializa a IReadOnlyList<T> cuando es necesario. Esto da indexación estable y evita reenumerar fuentes diferidas.

public ResultEntities<User> GetActiveUsers()
{
    IEnumerable<User> users = _repository.GetActive();
    // Materializa la colección en IReadOnlyList
    return Result.OkEntities(users);
}

Usa TryEntities / TryEntitiesAsync para manejar excepciones automáticamente:

public Task<ResultEntities<User>> GetActiveUsersAsync()
    => Result.TryEntitiesAsync(() => _repository.GetActiveAsync());
4. Resultado Paginado (ResultPaged<T>)

Hereda de ResultEntities<T> e incluye metadatos completos para resultados paginados (TotalCount, Page, PageSize, TotalPages, HasNextPage, HasPreviousPage).

Cuando los metadatos de paginación se proporcionan como un conjunto completo mediante el constructor, Result.OkPaged(...), MaterializePaged(...), ToPaged(...) o WithPaging(...), la librería aplica estas invariantes:

  • TotalCount >= 0
  • Page >= 1
  • PageSize > 0

Los setters individuales siguen siendo lo bastante permisivos para inicialización progresiva y deserialización JSON, pero las operaciones de paginación completas se validan para evitar estados incoherentes.

Crea resultados paginados fácilmente usando la clase estática Result:

public ResultPaged<User> GetUsers(PagingParameters paging)
{
    try
    {
        var (users, total) = _repository.GetAll(paging);
        return Result.OkPaged(users, total, paging.Page, paging.PageSize);
    }
    catch (Exception ex)
    {
        return Result.DatabaseErrorPaged<User>(ex);
    }
}

// Deconstrucción
var (status, users, total, pagina, tamañoPagina) = result;

Usa TryPagedAsync para manejar excepciones y cancelación automáticamente:

public Task<ResultPaged<User>> GetUsersAsync(PagingParameters paging, CancellationToken ct)
    => Result.TryPagedAsync(
        async () => await _repository.GetAllAsync(paging, ct),
        paging.Page, paging.PageSize);
5. Resultado por Lote (ResultBatch<T>)

Utilizado para procesar múltiples elementos en una sola petición, proporcionando un estado global y resultados individuales para cada elemento usando IReadOnlyList<ResultEntity<T>>.

Igual que en ResultEntities<T>, las colecciones del lote se materializan a IReadOnlyList<ResultEntity<T>> cuando hace falta. El Status del lote representa el resultado global de la operación, mientras que cada elemento de Entities conserva su propio estado independiente como ResultEntity<T>.

public ResultBatch<User> ImportUsers(List<User> users)
{
    var results = users.Select(u => (ResultEntity<User>)Process(u));
    return ResultBatch<User>.Ok(results);
}

// Deconstrucción
var (status, results) = importResult;

// Indicadores agregados
bool hayErroresParciales = importResult.HasPartialErrors;
int totalCorrectos = importResult.SuccessCount;
int totalErrores = importResult.ErrorCount;

Los métodos de factoría estáticos están disponibles para todos los estados comunes:

  • ResultBatch<T>.Ok(entities)
  • ResultBatch<T>.ValidationError(errors)
  • ResultBatch<T>.DatabaseError(ex)
  • ResultBatch<T>.NotFound()
  • ResultBatch<T>.Forbidden()
  • ... y todos los demás estados de StatusResult.

Gestión de Errores

La librería soporta una API fluida para añadir errores, que se almacenan en una ErrorCollection:

return Result.ValidationError()
    .AddError("Email", "Formato no válido")
    .AddError("Password", "Demasiado corta")
    .AddError(new Exception("Error interno del sistema"));

Asocia un código numérico a cualquier resultado con WithCode():

return Result.Error().WithCode(4001);

ErrorCollection expone una propiedad Count para acceso O(1) y Empty siempre devuelve una instancia nueva y segura:

bool tieneErrores = result.Errors.Count > 0;
var erroresVacios = ErrorCollection.Empty; // siempre devuelve una instancia nueva

Metadatos de Respuesta

Los resultados pueden transportar metadatos de respuesta mediante helpers fluidos como:

  • WithLocation(...)
  • WithETag(...)
  • WithRetryAfter(...)
  • WithContentLocation(...)
  • WithLastModified(...)
  • WithCacheControl(...)
  • WithMetadata(...) para claves personalizadas

Internamente, los metadatos se almacenan como un diccionario clave/valor insensible a mayúsculas a través de IHasMetadata. En el contrato público de System.Text.Json, los metadatos conocidos se aplanan como propiedades JSON de primer nivel en lugar de aparecer dentro de un objeto metadata. WithMetadata(...) no está limitado a claves predefinidas: quien consume la librería puede adjuntar cualquier entrada de metadato cuando lo necesite. Por seguridad, las claves y valores de metadata rechazan caracteres de control (por ejemplo \r, \n, \t) para reducir riesgos de inyección en cabeceras/logs al usar adaptadores de transporte.

Proyecciones conocidas:

  • Locationlocation
  • ETagetag
  • Retry-AfterretryAfter
  • Content-LocationcontentLocation
  • Last-ModifiedlastModified
  • Cache-ControlcacheControl

Ejemplos de proyecciones personalizadas:

  • X-Correlation-IdxCorrelationId
  • Tenant-IdtenantId

Para mantener el contrato JSON seguro y sin ambigüedades, WithMetadata(...) rechaza claves que colisionen con propiedades JSON reservadas del tipo concreto de resultado. Ejemplos:

  • campos comunes: status, resultCode, calculationTime, errors
  • ResultEntity<T>: entity
  • ResultEntities<T> / ResultPaged<T> / ResultBatch<T>: entities
  • ResultPaged<T>: totalCount, page, pageSize
  • nombres de helpers derivados excluidos intencionadamente del contrato, como isSuccess, isFailure, totalPages, hasNextPage, hasPreviousPage, isEmpty, hasPartialErrors, successCount y errorCount
  • también se rechazan caracteres de control en claves/valores de metadata (por ejemplo CR/LF)

Ejemplo:

var result = Result.Ok(new ProductDto { Id = 1, Name = "Keyboard" })
    .WithLocation("/api/products/1")
    .WithETag("abc123")
    .WithRetryAfter(60);

JSON serializado:

{
  "status": 1,
  "resultCode": null,
  "calculationTime": "2026-03-26T12:34:56+00:00",
  "errors": null,
  "location": "/api/products/1",
  "etag": "\"abc123\"",
  "retryAfter": "60",
  "entity": {
    "id": 1,
    "name": "Keyboard"
  }
}

Los helpers/propiedades calculadas de ejecución como IsSuccess, IsFailure, TotalPages, HasNextPage, HasPreviousPage, IsEmpty, HasPartialErrors, SuccessCount y ErrorCount quedan intencionadamente fuera del contrato JSON público.

System.Text.Json es el serializador principal soportado para este contrato JSON público, incluyendo los metadatos aplanados y los roundtrips sensibles a la política de nombres. Newtonsoft.Json sigue siendo útil para serialización básica de modelos en proyectos consumidores, pero debe tratarse como compatibilidad práctica y no como una réplica garantizada del shape controlado por convertidores de System.Text.Json.

Códigos de Estado (StatusResult)

Estado Categoría Descripción
Ok, Added, Updated, Deleted Éxito Operaciones exitosas.
NoExist Informativo El recurso solicitado no fue encontrado (dominio semántico).
Warning Informativo Completado con problemas no críticos.
CancelOperation Control La operación fue cancelada.
ValidationError Error Falló la validación de entrada del cliente.
DataError, DatabaseError Error Problemas en el procesamiento o persistencia de datos.
ConcurrencyError Error Los datos fueron modificados por otro proceso.
ConnectionError Error El cliente no pudo conectarse al servidor backend.
HttpError Error Se produjo un error mientras HttpClient ejecutaba la operación HTTP.
Unauthorized Seguridad Falló la autenticación o autorización.
Forbidden Seguridad El servidor entendió la petición pero rehúsa autorizarla (403).
NotFound Error El recurso solicitado no fue encontrado (404).
Conflict Error La petición entra en conflicto con el estado actual del servidor (409).
UnprocessableEntity Error Errores semánticos en la petición (422).
TooManyRequests Error Límite de peticiones excedido (429).
ServiceUnavailable Error El servidor no está listo para manejar la petición (503).
ChangePassword Seguridad El usuario debe cambiar su contraseña.
Error, Exception Error Error genérico o una excepción no controlada.

Guía de Selección de Estados

Usa estas reglas para mantener una semántica consistente entre servicios y APIs:

  • Prefiere NoExist cuando el contrato funcional significa que los datos solicitados no existen, incluso si la operación llegó por HTTP.
  • Prefiere NotFound cuando el resultado vaya a mapearse directamente a una respuesta HTTP 404 y esa ausencia a nivel de transporte sea el significado buscado.
  • Usa ValidationError cuando la entrada es inválida antes de comenzar el procesamiento.
  • Usa UnprocessableEntity cuando la entrada es estructuralmente válida pero la semántica de negocio impide ejecutar la operación.
  • Usa DataError para inconsistencias de dominio o de datos detectadas durante el procesamiento.
  • Usa DatabaseError y ServiceUnavailable para fallos de persistencia o disponibilidad del servicio.
  • Usa ConnectionError cuando el cliente no puede conectarse al servidor backend.
  • Usa HttpError cuando HttpClient falla mientras ejecuta la operación HTTP.
  • Usa Conflict cuando la acción solicitada entra en conflicto con el estado actual del recurso.
  • Usa Unauthorized cuando falta autenticación o es inválida.
  • Usa Forbidden cuando el llamador está autenticado pero no tiene permisos para ejecutar la acción.
  • Usa Error como fallback genérico para fallos conocidos que no encajan en un estado más específico.
  • Usa Exception solo cuando el fallo proviene de una excepción inesperada y no exista un estado mejor.

Ejemplos:

// Ausencia de dominio en un servicio interno
return Result.NoExist<User>();

// Respuesta 404 orientada a API
return Result.NotFound<User>();

// Entrada inválida
return Result.ValidationError<User>()
    .AddError("Email", "Formato no válido");

// Petición válida, pero una regla de negocio impide continuar
return Result.UnprocessableEntity<User>()
    .AddError("State", "El usuario está archivado");

// Error conocido de fallback
return Result.Error().AddError("Order", "No se pudo completar la operación");

// Excepción inesperada
catch (Exception ex)
{
    return Result.Exception<User>(ex);
}

Extensiones de StatusResult

Importa Pitasoft.Result.Extensions para usar clasificación semántica sobre valores de StatusResult:

if (resultado.Status.IsSuccess())             { /* Ok / Added / Updated / Deleted */ }
if (resultado.Status.IsInfrastructureError()) { /* DatabaseError / ConcurrencyError / ConnectionError / HttpError / ServiceUnavailable */ }
if (resultado.Status.IsBusinessError())       { /* ValidationError / DataError / NoExist / Unauthorized / Forbidden / NotFound / Conflict / UnprocessableEntity / ChangePassword */ }
if (resultado.Status.IsTransientError())      { /* Error / Exception / CancelOperation / ConcurrencyError / ConnectionError / HttpError / TooManyRequests / ServiceUnavailable */ }
if (resultado.Status.IsError())                { /* Estados de error generales o especializados (Validación, Datos, BD, Concurrencia, HTTP, Excepción, etc.) */ }

Son especialmente útiles en middleware o políticas de reintento para decidir cómo manejar un fallo sin inspeccionar el valor de Status directamente.

Extensiones

Importa Pitasoft.Result.Extensions para usar estos métodos de ayuda en cualquier IResult:

  • result.IsSuccess(): Devuelve true si el estado es Ok, Added, Updated o Deleted.
  • result.IsError(): Devuelve true para la mayoría de los estados relacionados con errores.
  • result.IsFailure(): Devuelve true para cualquier estado no exitoso, excluyendo None, NoExist y Warning. Cubre Unauthorized, Exception, CancelOperation y más.
  • result.IsOk(): Devuelve true si el estado es exactamente Ok.
  • result.IsWarning(): Devuelve true si el estado es Warning.
  • result.IsNotExist(): Devuelve true si el recurso no fue encontrado (NoExist).
  • result.IsUnauthorized(): Devuelve true si el estado es Unauthorized.
  • result.IsForbidden() / result.IsNotFound() / result.IsConflict() / result.IsTooManyRequests() / result.IsUnprocessableEntity() / result.IsServiceUnavailable(): Comprobaciones semánticas para estados de error tipo HTTP.
  • result.HasErrors(): Devuelve true si hay algún error en la colección Errors.
  • result.IsSuccessOrNotExist(): Útil para operaciones "Delete" donde ambos casos suelen manejarse de forma similar.
  • result.IsSuccessOrWarning(): Devuelve true para estados exitosos o de advertencia.
  • Materialize() / MaterializeAsync(): Convierte IEnumerable<T> o IAsyncEnumerable<T> a ResultEntities<T> o ResultPaged<T>.
  • UpdateTimestamp(): Actualiza manualmente el CalculationTime a la hora UTC actual en Result y tipos derivados.
  • ToPaged(): Convierte un resultado a ResultPaged<T> preservando el estado. Requiere metadatos de paginación válidos (TotalCount >= 0, Page >= 1, PageSize > 0).
  • ToResultEntity() / ToResultEntities(): Convierte entre tipos de resultado preservando metadatos.
  • ToOkResult() / ToAddedResult() / ToUpdatedResult() / ToDeletedResult(): Convierte una entidad en un ResultEntity<T> con el estado de éxito correspondiente.

Los resultados también validan las asignaciones a Status contra los valores definidos en el enum StatusResult. Esto ayuda a detectar pronto asignaciones manuales inválidas al trabajar con instancias mutables o flujos de deserialización personalizados.

API Funcional (Match, Map, Bind, Ensure, EnsureAsync, Recover, Tap, TapAsync)

Importa Pitasoft.Result.Extensions para usar transformaciones de estilo funcional.

Notas de comportamiento:

  • Match y Tap no modifican el resultado. Solo bifurcan el flujo o ejecutan efectos secundarios.
  • Map proyecta valores exitosos a un nuevo resultado. En resultados de colección y paginados conserva Status, Errors, ResultCode, CalculationTime y las entradas de metadatos adjuntas. En ResultPaged<T> además conserva los metadatos de paginación.
  • Bind corta la cadena en caso de fallo. En resultados de colección y paginados, la propagación del fallo conserva ResultCode, CalculationTime y las entradas de metadatos adjuntas; en éxito, el binder pasa a ser dueño del resultado final que devuelve.
  • Ensure / EnsureAsync solo se ejecutan sobre resultados exitosos. Si la validación falla, devuelven o producen un error de validación en lugar de continuar el flujo exitoso.
  • Recover desempaqueta un ResultEntity<T> a un T plano, aportando un valor de respaldo solo cuando el resultado no es exitoso.
  • Ensure sobre ResultPaged<T> y ResultBatch<T> actualiza la instancia actual a ValidationError y agrega errores, en lugar de crear un nuevo resultado contenedor.
Match — bifurcar según éxito o error

Disponible para Result, ResultEntity<T>, ResultEntities<T> y ResultPaged<T>:

// Result
string msg = resultado.Match(
    onSuccess: () => "Completado",
    onFailure: r => $"Error: {r.Status}");

// ResultEntity<T>
var dto = userResult.Match(
    onSuccess: () => mapper.ToDto(userResult.Entity),
    onFailure: r => throw new InvalidOperationException(r.Status.ToString()));

// ResultEntities<T> / ResultPaged<T>
var vista = pagedResult.Match(
    onSuccess: () => ConstruirVista(pagedResult),
    onFailure: r => VistaError(r));
MatchAsync — bifurcación asíncrona según éxito o error

Disponible para Task<Result>, Task<ResultEntity<T>>, Task<ResultEntities<T>> y Task<ResultPaged<T>>:

var respuesta = await Task.FromResult(pagedResult).MatchAsync(
    onSuccess: (items, total, page, pageSize) => ConstruirRespuestaPaginada(items, total, page, pageSize),
    onFailure: r => ManejarFallo(r));
Map — transformar la entidad si el resultado es exitoso

Disponible para ResultEntity<T>, ResultEntities<T> y ResultPaged<T>:

ResultEntity<UserDto>   dto    = userResult.Map(user => mapper.ToDto(user));
ResultEntities<UserDto> dtos   = usersResult.Map(user => mapper.ToDto(user));
ResultPaged<UserDto>    paged  = pagedResult.Map(user => mapper.ToDto(user));

Si el resultado origen no es exitoso, Map no ejecuta la proyección y propaga el estado de fallo.

MapAsync — encadenar transformaciones asíncronas
ResultEntity<UserDto>   dto    = await userResult.MapAsync(async u => await EnriquecerAsync(u));
ResultEntities<UserDto> dtos   = await usersResult.MapAsync(async u => await EnriquecerAsync(u));
ResultPaged<UserDto>    paged  = await pagedResult.MapAsync(async u => await EnriquecerAsync(u));
Tap — ejecutar una acción sin cambiar el resultado
result.Tap(() => _logger.LogInformation("Operación exitosa"))
      .Tap(user => _cache.Set(user));
TapAsync — efecto secundario asíncrono sin cambiar el resultado

Disponible para cualquier tipo de resultado (Result, ResultEntity<T>, ResultEntities<T>, ResultPaged<T>):

var result = await ObtenerUsuarioAsync(id)
    .TapAsync(async () => await _auditoría.RegistrarAsync("usuario obtenido"));

// Variante tipada
var result = await ObtenerUsuarioAsync(id)
    .TapAsync(async user => await _cache.SetAsync(user));
Bind — encadenar operaciones que devuelven resultados (FlatMap)

Disponible para ResultEntity<T>, ResultEntities<T> y ResultPaged<T>:

ResultEntity<Order> result = GetUser(id)
    .Bind(user => CreateOrder(user));

ResultPaged<OrderDto> paged = GetPagedOrders(page, size)
    .Bind(o => EnrichOrder(o));

Bind solo invoca el binder cuando el resultado origen es exitoso. Si ya existe un fallo, la cadena se detiene y se propaga ese estado.

Ensure — validar una condición

Sobre Result (base):

Result result = VerificarPermisos(userId)
    .Ensure(() => _cuota.TieneCapacidad(), "Cuota superada", "Cuota");

Sobre ResultEntity<T>:

ResultEntity<User> result = GetUser(id)
    .Ensure(u => u.Age >= 18, "El usuario debe ser mayor de edad", "Edad");

En Result y ResultEntity<T>, un Ensure fallido devuelve un resultado de validación. En ResultPaged<T> y ResultBatch<T>, un Ensure fallido muta la instancia actual a ValidationError y agrega el error correspondiente.

EnsureAsync — validar una condición asíncrona

Realiza validaciones que requieren I/O, como comprobaciones en base de datos, dentro de la cadena fluida:

ResultEntity<User> result = await GetUser(id)
    .EnsureAsync(async u => await _repo.EsEmailUnicoAsync(u.Email), "Email ya existe", "Email");
Recover — proporcionar un valor de respaldo en caso de fallo

Maneja fallos de forma segura proporcionando un valor por defecto o una función de recuperación:

// Valor por defecto simple
User user = GetUser(id).Recover(new User { Name = "Invitado" });

// Función de recuperación
User user = GetUser(id).Recover(r => new User { Name = $"Invitado (Error: {r.Status})" });

Recover es intencionadamente una operación de desempaquetado sobre ResultEntity<T>: después de llamarla, ya trabajas con un valor plano y no con un objeto resultado.

Métodos Seguros ante Excepciones (Try)

Todas las variantes Try capturan excepciones automáticamente. OperationCanceledException se mapea a CancelOperation en lugar de Exception. Ahora puedes proporcionar opcionalmente una función mapException para transformar excepciones específicas en códigos StatusResult significativos. Si mapException devuelve un estado de éxito (Ok, Added, Updated, Deleted o None) en una ruta de excepción, la librería lo normaliza a Error para evitar falsos positivos de éxito.

Método Retorna Notas
Result.Try(action, deep?, mapEx?) Result Síncrono, sin valor de retorno
Result.Try<T>(func, deep?, mapEx?) ResultEntity<T> Síncrono, devuelve entidad
Result.TryAsync(action, ct?, deep?, mapEx?) Task<Result> Asíncrono, soporta CancellationToken
Result.TryAsync<T>(func, ct?, deep?, mapEx?) Task<ResultEntity<T>> Entidad asíncrona, soporta CancellationToken
Result.TryEntities<T>(func, deep?, mapEx?) ResultEntities<T> Colección síncrona
Result.TryEntitiesAsync<T>(func, deep?, mapEx?) Task<ResultEntities<T>> Colección asíncrona
Result.TryPagedAsync<T>(func, page, pageSize, deep?, mapEx?) Task<ResultPaged<T>> Colección paginada asíncrona
Result.TryOutcome(...)/TryOutcomeAsync(...) Result / ResultEntity<T> Usa outcomes explícitos con TryResult
Result.TryOutcomeEntities(...)/TryOutcomeEntitiesAsync(...) ResultEntities<T> Colección basada en outcomes
Result.TryOutcomePagedAsync(...) ResultPaged<T> Paginado basado en outcomes

Regla rápida de uso:

  • Usa Try* cuando el delegado devuelve valores planos (o sin valor) y solo necesitas mapear excepciones a Result.
  • Usa TryOutcome* cuando el delegado debe decidir explícitamente el StatusResult (NotFound, ValidationError, NoExist, etc.).
  • Mantén la semántica explícita: NoExist para ausencia de dato en dominio y NotFound para contratos HTTP tipo 404.
  • Prioriza factorías semánticas (Ok, Added, Updated, Deleted, NoExist, Error, Exception) frente a estados genéricos.
// Mapeo automático de excepciones a códigos de estado
var result = Result.Try(() => _repo.Get(id), 
    mapException: ex => ex is KeyNotFoundException ? StatusResult.NotFound : StatusResult.Error);

// Soporte CancellationToken — OperationCanceledException → CancelOperation
var resultado = await Result.TryAsync(
    async ct => await _servicio.ProcesarAsync(ct),
    cancellationToken);

// Colección
ResultEntities<Product> productos = Result.TryEntities(() => _repo.ObtenerTodos());

// Paginado
ResultPaged<Product> pagina = await Result.TryPagedAsync(
    async () => await _repo.ObtenerPaginadoAsync(1, 20),
    page: 1, pageSize: 20);

// API con outcome explícito (sin Continue/Cancel; usar factorías semánticas)
Result outcome = await Result.TryOutcomeAsync(async () =>
{
    await Task.Yield();
    return TryResult.NotFound();
});

ResultEntity<Product> outcomeEntity = await Result.TryOutcomeAsync(async () =>
{
    await Task.Yield();
    return TryResult<Product>.Added(new Product { Id = 10, Name = "Tablet" });
});
Nota de migración (TryResult)
  • TryResult.Continue(...) y TryResult.Cancel(...) ya no se usan.
  • Usa factorías semánticas de outcome:
    • TryResult.Ok(), TryResult.Added(), TryResult.NoExist()
    • TryResult.ValidationError(...), TryResult.NotFound(), TryResult.Exception(ex, deep)
  • Para outcomes con payload, usa TryResult<T>.Ok(entity), TryResult<T>.Added(entity), etc.
  • Cuando el delegado devuelve TryResult / TryResult<T>, utiliza preferentemente la familia Result.TryOutcome*.

Combinar Resultados

Agrega múltiples instancias de Result en una sola. El resultado combinado es Ok si todos tienen éxito; en caso contrario acumula todos los errores de los fallidos.

// Síncrono
Result combinado = Result.Combine(
    ValidarNombre(dto.Name),
    ValidarEmail(dto.Email),
    ValidarEdad(dto.Age));

// Asíncrono
Result combinado = await Result.CombineAsync(
    ValidarNombreAsync(dto.Name),
    ValidarEmailAsync(dto.Email));

Parámetros de Paginación y Entidad

Usa PagingParameters para recibir solo paginación, QueryParameters para filtrado/búsqueda/ordenación sin paginación y EntityParameters cuando necesites ambas cosas juntas. Si prefieres programar contra contratos en lugar de clases concretas, PagingParameters implementa IPagingParameters, QueryParameters implementa IQueryParameters y EntityParameters combina ambos.

GetParameters() devuelve IEnumerable<KeyValuePair<string, string>>, lo que permite múltiples valores para la misma clave (útil para query strings tipo array como ?tag=a&tag=b):

public ResultPaged<User> GetUsers(EntityParameters parametros)
{
    // parametros.Page, parametros.PageSize, parametros.Search, parametros.Query, parametros.Order, parametros.Attrs
    var (users, total) = _repository.GetAll(parametros);
    return Result.OkPaged(users, total, parametros.Page, parametros.PageSize);
}

// Convertir a Dictionary cuando un mapa de valor único es suficiente
var dict = parametros.GetParameters().ToDictionary(kv => kv.Key, kv => kv.Value);

// O usar directamente con constructores de query de HttpClient que acepten IEnumerable<KVP>
var query = QueryString.Create(parametros.GetParameters());

Helper de Lote (Batch<T>)

Usa Batch<T> para describir un conjunto de entidades y la acción a realizar sobre ellas:

var batch = Batch<User>.Append(newUsers);
var batch = Batch<User>.Update(modifiedUsers);
var batch = Batch<User>.Delete(removedUsers);

Rendimiento

Consejos de rendimiento
  • Factorías rápidas: Los estados comunes de Result como NoExist() y ValidationError() utilizan helpers de factoría pequeños e inlineados, manteniendo bajo el coste de creación sin cambiar la semántica mutable del resultado.
  • Optimización de Parámetros: PagingParameters.GetParameters() y EntityParameters.GetParameters() ahora utilizan yield return en lugar de crear listas temporales, mejorando la eficiencia de memoria de $O(n)$ a $O(1)$ de espacio.
  • Eficiencia en Colecciones: ResultEntities<T> y ResultPaged<T> utilizan IReadOnlyList<T> para la propiedad Entities. Las colecciones se materializan eficientemente en una lista solo cuando es necesario.
  • Aggressive Inlining: Las factorías estáticas y los métodos de extensión están anotados con AggressiveInlining para minimizar el overhead.
  • Iteración Directa: Soportada mediante foreach. Internamente, su enumerador delega en la colección subyacente sin usar yield, evitando asignaciones extra de la máquina de estados.
  • Carga JSON: Los payloads de resultado aplanan metadatos conocidos en propiedades de primer nivel como location o etag, mientras excluyen automáticamente helpers calculados como TotalPages, HasNextPage y HasPreviousPage.
  • Comprobaciones de estado: IsSuccess(), IsFailure() y el resto de extensiones semánticas están anotadas con AggressiveInlining para minimizar el overhead en rutas de código críticas.
  • Gestión de Excepciones: Al crear resultados de error desde excepciones manualmente, usa deep: false salvo que necesites explícitamente el detalle de inner exceptions para reducir asignaciones en ErrorCollection.

Resultados de benchmarks para operaciones principales (Tiempo medio / Memoria asignada):

La librería está optimizada para minimizar asignaciones y maximizar el rendimiento. La suite actual de Pitasoft.Result.Benchmarks ya incluye también los escenarios nuevos de API funcional y paginación.

Lectura rápida:

  • .NET 10 lidera las rutas representativas de creación y API funcional en esta batería, especialmente en paginación y transformaciones de ResultBatch<T>.
  • .NET 8 sigue siendo muy competitivo y ahora mismo ofrece los mejores números de serialización con System.Text.Json en esta máquina, además de seguir siendo la base LTS.
  • .NET 9 funciona correctamente y suele quedar entre ambos runtimes en la mayoría de escenarios, pero no es el claro ganador en este perfil.
Operación .NET 8 .NET 9 .NET 10
Result.Ok 18.38 ns / 64 B 19.22 ns / 64 B 19.91 ns / 64 B
Result.Error(Exception) 44.12 ns / 376 B 48.85 ns / 376 B 42.78 ns / 376 B
ResultEntities.Ok 23.64 ns / 72 B 24.28 ns / 72 B 20.32 ns / 72 B
Result.OkPaged 24.35 ns / 88 B 25.28 ns / 88 B 19.65 ns / 88 B
ResultBatch.Ok(entities) 24.17 ns / 72 B 24.78 ns / 72 B 23.03 ns / 72 B
ResultEntity.Map 25.57 ns / 72 B 23.69 ns / 72 B 20.92 ns / 72 B
ResultPaged.WithPaging 19.15 ns / 88 B 19.93 ns / 88 B 17.07 ns / 88 B
ResultPaged.Bind (ruta de fallo) 22.97 ns / 88 B 24.58 ns / 88 B 20.73 ns / 88 B
ResultBatch.Map (ruta de fallo) 24.00 ns / 96 B 25.22 ns / 96 B 22.51 ns / 96 B
ResultBatch.Bind (ruta de éxito) 107.04 ns / 416 B 112.56 ns / 416 B 100.61 ns / 416 B
ResultBatch.Ensure (ruta de fallo) 92.90 ns / 568 B 95.04 ns / 568 B 81.35 ns / 568 B

Metodología:

Para regenerar la suite representativa de benchmarks y guardar snapshots separados para .NET 8, .NET 9 y .NET 10, ejecuta:

./scripts/run-benchmarks.sh

También puedes limitar la ejecución a runtimes concretos:

./scripts/run-benchmarks.sh net10.0

El script guarda los reportes por runtime en BenchmarkDotNet.Artifacts/snapshots/<framework> y actualiza automáticamente las tablas de benchmarks de este README a partir de esos snapshots.

Si ya tienes snapshots recientes y solo quieres reconstruir las tablas, ejecuta:

./scripts/update-benchmark-readme.sh

Benchmarks representativos de serialización JSON (System.Text.Json):

Operación .NET 8 .NET 9 .NET 10
Serialize Result 216.8 ns / 656 B 220.6 ns / 656 B 197.1 ns / 656 B
Deserialize Result 481.0 ns / 1344 B 484.6 ns / 1320 B 500.5 ns / 1320 B
Serialize ResultEntities 411.1 ns / 944 B 404.8 ns / 944 B 431.5 ns / 944 B
Deserialize ResultEntities 1,039.9 ns / 1152 B 1,019.7 ns / 1152 B 1,128.0 ns / 1152 B
Serialize ResultPaged 471.2 ns / 1144 B 470.7 ns / 1144 B 496.1 ns / 1144 B
Deserialize ResultPaged 1,312.0 ns / 1376 B 1,267.2 ns / 1376 B 1,414.9 ns / 1376 B

Microbenchmarks de comprobaciones de estado y resultado:

Estos valores son utiles como senal relativa, no como promesa absoluta. Cuando BenchmarkDotNet muestra 0.0000 ns en checks tan pequenos, significa que el runtime optimizo esa ruta tanto que su coste resulta indistinguible del baseline de un metodo vacio en esta configuracion.

Operación .NET 8 .NET 9 .NET 10
Comprobacion directa de exito en Status 0.2312 ns / 0 B 0.5399 ns / 0 B 0.0175 ns / 0 B
Status.IsSuccess() 0.4051 ns / 0 B 0.1336 ns / 0 B 0.0000 ns / 0 B
Status.IsFailure() 0.2283 ns / 0 B 0.0000 ns / 0 B 0.1625 ns / 0 B
IResult.IsSuccess() 0.3246 ns / 0 B 0.0000 ns / 0 B 0.2668 ns / 0 B
IResult.IsFailure() 0.2608 ns / 0 B 0.0740 ns / 0 B 0.2546 ns / 0 B
IResult.HasErrors() sin errores 0.2572 ns / 0 B 0.0000 ns / 0 B 0.0411 ns / 0 B
IResult.HasErrors() con errores 0.5401 ns / 0 B 0.0000 ns / 0 B 0.3177 ns / 0 B
Status.IsError() 0.2573 ns / 0 B 0.0000 ns / 0 B 0.2570 ns / 0 B

Autor

Sebastián Martínez Pérez

License

Copyright © 2019-2026 Pitasoft, S.L. Licensed under the LICENSE.txt provided in this repository.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (9)

Showing the top 5 NuGet packages that depend on Pitasoft.Result:

Package Downloads
Pitasoft.Client

.NET library designed to simplify the consumption of RESTful services. It provides a robust base class and helpers to handle HTTP requests, JSON serialization, and common API patterns.

Pitasoft.Web

Librerias basicas de aplicaciones web

Pitasoft.Blazor.Result

Application of the functionalities of the Pitasoft.Blazor package, adding functionalities of Pitasoft.Result.

Pitasoft.Mail

E-Mail server service.

Pitasoft.Blazor.Toast.Result

Extensions for Pitasoft Blazor Toast.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
7.4.1 123 4/19/2026
7.3.2 202 4/3/2026
7.3.1 335 3/26/2026
7.2.9 144 3/24/2026
7.2.8 123 3/23/2026
7.2.7 118 3/22/2026
7.2.6 154 3/20/2026
7.2.5 119 3/20/2026
7.2.4 182 3/13/2026
7.2.3 122 3/11/2026
7.2.2 143 3/10/2026
7.2.1 136 3/9/2026
7.1.4 176 3/2/2026
7.1.3 165 2/24/2026
7.1.2 129 2/24/2026
7.1.1 172 2/23/2026
7.0.2 155 2/12/2026
7.0.1 183 1/26/2026
6.5.7 373 5/27/2025
6.5.6 340 5/27/2025
Loading failed