Pitasoft.Result
7.4.1
dotnet add package Pitasoft.Result --version 7.4.1
NuGet\Install-Package Pitasoft.Result -Version 7.4.1
<PackageReference Include="Pitasoft.Result" Version="7.4.1" />
<PackageVersion Include="Pitasoft.Result" Version="7.4.1" />
<PackageReference Include="Pitasoft.Result" />
paket add Pitasoft.Result --version 7.4.1
#r "nuget: Pitasoft.Result, 7.4.1"
#:package Pitasoft.Result@7.4.1
#addin nuget:?package=Pitasoft.Result&version=7.4.1
#tool nuget:?package=Pitasoft.Result&version=7.4.1
Pitasoft.Result
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>, andResultBatch<T>. - Automatic Timestamps: All results now include a
CalculationTime(DateTimeOffset?) to track when they were generated, via theIHasCalculationTimestampinterface. - Fluent Error Handling: Easily add validation errors, exceptions, or business rule violations using a fluent interface. Includes
Result.TryandResult.TryAsyncfor automatic exception handling, with built-in support forCancellationTokenandOperationCanceledException→CancelOperation. - Rich Status Management: Built-in
StatusResultenum covering common API scenarios (Success, Not Found, Forbidden, Conflict, Validation Errors, Database Errors, etc.). IncludesStatusResultExtensionsfor semantic categorization (IsSuccess,IsInfrastructureError,IsBusinessError,IsTransientError,IsError). - Batch Operations: Specialized support for processing multiple items with
ResultBatch<T>usingIReadOnlyList<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 theResultclass (e.g.,Result.ErrorPaged,Result.DatabaseErrorPaged,Result.NotFoundPaged) andTryPagedAsyncfor exception-safe paged queries. - Async Support: Native support for
IAsyncEnumerable<T>andTask<T>withMaterializeAsyncandToResultEntitiesAsync. IncludesMapAsync,BindAsync,MatchAsync, andTapAsyncextensions. - Improved Serialization: Custom JSON converters keep API outputs clean and predictable across
Result,ResultEntity<T>,ResultEntities<T>,ResultPaged<T>, andResultBatch<T>, flattening well-known metadata such asLocation,ETag, orRetry-Afterinto top-level JSON properties. - Implicit Conversions: Convert
StatusResultor 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(), andToDeletedResult()for converting entities to results. - Functional API:
Match,MatchAsync,Map,MapAsync,Tap,TapAsync,Bind, andEnsurefor fluent result processing. All operations are available forResult,ResultEntity<T>,ResultEntities<T>, andResultPaged<T>. - Combine Results: Aggregate multiple results into one with
Result.Combine()andResult.CombineAsync(). - Multi-value Parameters:
IParameters.GetParameters()returnsIEnumerable<KeyValuePair<string, string>>, supporting multiple values for the same key (e.g.,?tag=a&tag=b). ErrorCollectionImprovements:Emptyis now a property (safe, non-shared instance). DirectCountproperty for O(1) access.- Performance: High-performance implementation with minimal allocations,
AggressiveInlining, and efficient collection materialization usingIReadOnlyList<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
NotFoundin service/domain code when the semantic meaning isNoExist. - Using
TryOutcome*for plain delegates that should useTry*. - Returning success statuses from
mapException(they are normalized toError). - 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.Jsonto mirror the exact flattenedSystem.Text.Jsoncontract 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:
ValidationError→400UnprocessableEntity→422NoExist/NotFound→404Unauthorized→401,Forbidden→403- infrastructure/system failures →
503or generic500
- 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-Afterfor throttling/transient states - keep
NoExistdistinct fromNotFoundin service logic, even if both map to404
- keep business statuses explicit (
- 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 DatabaseErrorcan map to503for temporary outages or500for non-retryable failures- keep structured error payloads for diagnostics, but never leak sensitive internals
- transient statuses (
- Document idempotency expectations for
Updated/Deletedendpoints (200vs204policy).
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 >= 0Page >= 1PageSize > 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
StatusResultstates.
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:
Location→locationETag→etagRetry-After→retryAfterContent-Location→contentLocationLast-Modified→lastModifiedCache-Control→cacheControl
Examples of custom projections:
X-Correlation-Id→xCorrelationIdTenant-Id→tenantId
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>:entityResultEntities<T>/ResultPaged<T>/ResultBatch<T>:entitiesResultPaged<T>:totalCount,page,pageSize- derived helper names intentionally excluded from the contract, such as
isSuccess,isFailure,totalPages,hasNextPage,hasPreviousPage,isEmpty,hasPartialErrors,successCount, anderrorCount - 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
NoExistwhen the functional contract means the requested data does not exist, even if the operation arrived through HTTP. - Prefer
NotFoundwhen the result is intended to map directly to an HTTP404response and transport-level absence is the intended meaning. - Use
ValidationErrorwhen the input is invalid before processing starts. - Use
UnprocessableEntitywhen the input is structurally valid but business semantics prevent execution. - Use
DataErrorfor domain or data consistency issues discovered during processing. - Use
DatabaseErrorandServiceUnavailablefor persistence or service-availability failures. - Use
ConnectionErrorwhen the client cannot connect to the backend server. - Use
HttpErrorwhenHttpClientfails while executing the HTTP operation. - Use
Conflictwhen the requested action collides with the current state of the resource. - Use
Unauthorizedwhen authentication is missing or invalid. - Use
Forbiddenwhen the caller is authenticated but does not have permission to perform the action. - Use
Erroras a generic fallback for known failures that do not fit a more specific status. - Use
Exceptiononly 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(): Returnstrueif status isOk,Added,Updated, orDeleted.result.IsError(): Returnstruefor most error-related statuses.result.IsFailure(): Returnstruefor any non-successful state, excludingNone,NoExist, andWarning. CoversUnauthorized,Exception,CancelOperation, and more.result.IsOk(): Returnstrueif the status is exactlyOk.result.IsWarning(): Returnstrueif the status isWarning.result.IsNotExist(): Returnstrueif the resource was not found (NoExist).result.IsUnauthorized(): Returnstrueif the status isUnauthorized.result.IsForbidden()/result.IsNotFound()/result.IsConflict()/result.IsTooManyRequests()/result.IsUnprocessableEntity()/result.IsServiceUnavailable(): Semantic checks for HTTP-like error states.result.HasErrors(): Returnstrueif there are any errors in theErrorscollection.result.IsSuccessOrNotExist(): Useful for "Delete" operations where both cases are often handled similarly.result.IsSuccessOrWarning(): Returnstruefor successful or warning states.Materialize()/MaterializeAsync(): ConvertIEnumerable<T>orIAsyncEnumerable<T>toResultEntities<T>orResultPaged<T>.UpdateTimestamp(): Manually updates theCalculationTimeto the current UTC time onResultand derived types.ToPaged(): Convert a result to aResultPaged<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 aResultEntity<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:
MatchandTapdo not change the result. They only branch or execute side effects.Mapprojects successful values into a new result. For collection and paged results it preservesStatus,Errors,ResultCode,CalculationTime, and attached metadata entries. ForResultPaged<T>it also preserves pagination metadata.Bindshort-circuits on failure. For collection and paged results, failure propagation preservesResultCode,CalculationTime, and attached metadata entries; on success, the binder owns the final result it returns.Ensure/EnsureAsynconly run on successful results. If validation fails, they return or produce a validation error instead of continuing the success flow.Recoverunwraps aResultEntity<T>into a plainT, providing a fallback only when the result is not successful.EnsureonResultPaged<T>andResultBatch<T>updates the current instance toValidationErrorand 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 decideStatusResult(NotFound,ValidationError,NoExist, etc.). - Keep transport semantics explicit: use
NoExistfor domain/data absence andNotFoundfor 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(...)andTryResult.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>, preferResult.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
Resultstates such asNoExist()andValidationError()use small inlined factory helpers, keeping creation overhead low without changing result mutability semantics. - Parameter Optimization:
PagingParameters.GetParameters()andEntityParameters.GetParameters()now useyield returninstead of creating temporary lists, improving memory efficiency from $O(n)$ to $O(1)$ space. - Collection Efficiency:
ResultEntities<T>andResultPaged<T>useIReadOnlyList<T>for theEntitiesproperty. Collections are materialized efficiently into a list only when necessary. - Aggressive Inlining: Static factories and extension methods are annotated with
AggressiveInliningto minimize overhead. - Direct Iteration: Supported via
foreach. Internally, its enumerator delegates to the underlying collection without usingyield, avoiding extra state-machine allocations. - JSON Payload: Result payloads flatten well-known metadata into top-level properties such as
locationoretag, while automatically excluding calculated helpers likeTotalPages,HasNextPage, andHasPreviousPage. - Status checks:
IsSuccess(),IsFailure()and the other semantic extension methods are annotated withAggressiveInliningto keep their overhead minimal in hot paths. - Exception handling: When creating error results from exceptions manually, pass
deep: falseunless you explicitly need inner exception details to reduce allocations inErrorCollection.
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 10leads the representative creation and functional paths in this benchmark set, especially around paging andResultBatch<T>transformations..NET 8stays very competitive and currently delivers the strongestSystem.Text.Jsonserialization numbers on this machine, while also remaining the LTS baseline..NET 9works 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:
- BenchmarkDotNet
v0.15.8 InProcessNoEmitToolchain- Apple M5 / macOS Tahoe 26.3.1 / Arm64
- Detailed reports are generated under BenchmarkDotNet.Artifacts/results
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>yResultBatch<T>. - Marcas de Tiempo Automáticas: Todos los resultados incluyen ahora
CalculationTime(DateTimeOffset?) para rastrear cuándo fueron generados, a través de la interfazIHasCalculationTimestamp. - 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.TryyResult.TryAsynccon soporte nativo paraCancellationTokeny conversión automática deOperationCanceledException→CancelOperation. - Gestión de Estados Enriquecida: Enumerado
StatusResultintegrado que cubre escenarios comunes (Éxito, No Encontrado, Prohibido, Conflicto, Errores de Validación, Errores de Base de Datos, etc.). IncluyeStatusResultExtensionspara clasificación semántica (IsSuccess,IsInfrastructureError,IsBusinessError,IsTransientError,IsError). - Operaciones por Lote: Soporte especializado para procesar múltiples elementos con
ResultBatch<T>usandoIReadOnlyList<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 claseResult(ej.,Result.ErrorPaged,Result.DatabaseErrorPaged,Result.NotFoundPaged) yTryPagedAsyncpara consultas paginadas con manejo automático de excepciones. - Soporte Async: Soporte nativo para
IAsyncEnumerable<T>yTask<T>conMaterializeAsyncyToResultEntitiesAsync. Incluye extensionesMapAsync,BindAsync,MatchAsyncyTapAsync. - Serialización Mejorada: Los conversores JSON mantienen salidas de API limpias y predecibles en
Result,ResultEntity<T>,ResultEntities<T>,ResultPaged<T>yResultBatch<T>, aplanando metadatos conocidos comoLocation,ETagoRetry-Afteren propiedades JSON de primer nivel. - Conversiones Implícitas: Convierte
StatusResulto 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()yToDeletedResult()para convertir entidades en resultados. - API Funcional:
Match,MatchAsync,Map,MapAsync,Tap,TapAsync,BindyEnsurepara el procesamiento fluido de resultados. Todas las operaciones están disponibles paraResult,ResultEntity<T>,ResultEntities<T>yResultPaged<T>. - Combinar Resultados: Agrega múltiples resultados en uno solo con
Result.Combine()yResult.CombineAsync(). - Parámetros Multivalor:
IParameters.GetParameters()devuelveIEnumerable<KeyValuePair<string, string>>, soportando múltiples valores para la misma clave (ej.,?tag=a&tag=b). - Mejoras en
ErrorCollection:Emptyes ahora una propiedad (instancia nueva y segura). PropiedadCountdirecta con acceso O(1). - Rendimiento: Implementación de alto rendimiento con mínimas asignaciones,
AggressiveInliningy materialización eficiente de colecciones medianteIReadOnlyList<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
NotFounden servicio/dominio cuando la semántica real esNoExist. - Usar
TryOutcome*en delegados planos que deberían usarTry*. - Devolver estados de éxito desde
mapException(se normalizan aError). - 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.Jsonreplica exactamente el contrato aplanado deSystem.Text.Jsonsin configuración/adaptador explícito. - Reconstruir resultados fallidos perdiendo
Errors,ResultCode,CalculationTimeo 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:
ValidationError→400UnprocessableEntity→422NoExist/NotFound→404Unauthorized→401,Forbidden→403- fallos de infraestructura/sistema →
503o500gené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-Afterpara estados de throttling/transitorios - mantener
NoExistdistinto deNotFounden lógica de servicio, aunque ambos se traduzcan a404
- mantener estados de negocio explícitos (
- 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 DatabaseErrorpuede mapearse a503en caídas temporales o500en fallos no reintentables- mantener payload de error estructurado para diagnóstico sin filtrar información sensible
- estados transitorios (
- Documentar expectativas de idempotencia para endpoints
Updated/Deleted(política200vs204).
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 >= 0Page >= 1PageSize > 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:
Location→locationETag→etagRetry-After→retryAfterContent-Location→contentLocationLast-Modified→lastModifiedCache-Control→cacheControl
Ejemplos de proyecciones personalizadas:
X-Correlation-Id→xCorrelationIdTenant-Id→tenantId
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>:entityResultEntities<T>/ResultPaged<T>/ResultBatch<T>:entitiesResultPaged<T>:totalCount,page,pageSize- nombres de helpers derivados excluidos intencionadamente del contrato, como
isSuccess,isFailure,totalPages,hasNextPage,hasPreviousPage,isEmpty,hasPartialErrors,successCountyerrorCount - 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
NoExistcuando el contrato funcional significa que los datos solicitados no existen, incluso si la operación llegó por HTTP. - Prefiere
NotFoundcuando el resultado vaya a mapearse directamente a una respuesta HTTP404y esa ausencia a nivel de transporte sea el significado buscado. - Usa
ValidationErrorcuando la entrada es inválida antes de comenzar el procesamiento. - Usa
UnprocessableEntitycuando la entrada es estructuralmente válida pero la semántica de negocio impide ejecutar la operación. - Usa
DataErrorpara inconsistencias de dominio o de datos detectadas durante el procesamiento. - Usa
DatabaseErroryServiceUnavailablepara fallos de persistencia o disponibilidad del servicio. - Usa
ConnectionErrorcuando el cliente no puede conectarse al servidor backend. - Usa
HttpErrorcuandoHttpClientfalla mientras ejecuta la operación HTTP. - Usa
Conflictcuando la acción solicitada entra en conflicto con el estado actual del recurso. - Usa
Unauthorizedcuando falta autenticación o es inválida. - Usa
Forbiddencuando el llamador está autenticado pero no tiene permisos para ejecutar la acción. - Usa
Errorcomo fallback genérico para fallos conocidos que no encajan en un estado más específico. - Usa
Exceptionsolo 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(): Devuelvetruesi el estado esOk,Added,UpdatedoDeleted.result.IsError(): Devuelvetruepara la mayoría de los estados relacionados con errores.result.IsFailure(): Devuelvetruepara cualquier estado no exitoso, excluyendoNone,NoExistyWarning. CubreUnauthorized,Exception,CancelOperationy más.result.IsOk(): Devuelvetruesi el estado es exactamenteOk.result.IsWarning(): Devuelvetruesi el estado esWarning.result.IsNotExist(): Devuelvetruesi el recurso no fue encontrado (NoExist).result.IsUnauthorized(): Devuelvetruesi el estado esUnauthorized.result.IsForbidden()/result.IsNotFound()/result.IsConflict()/result.IsTooManyRequests()/result.IsUnprocessableEntity()/result.IsServiceUnavailable(): Comprobaciones semánticas para estados de error tipo HTTP.result.HasErrors(): Devuelvetruesi hay algún error en la colecciónErrors.result.IsSuccessOrNotExist(): Útil para operaciones "Delete" donde ambos casos suelen manejarse de forma similar.result.IsSuccessOrWarning(): Devuelvetruepara estados exitosos o de advertencia.Materialize()/MaterializeAsync(): ConvierteIEnumerable<T>oIAsyncEnumerable<T>aResultEntities<T>oResultPaged<T>.UpdateTimestamp(): Actualiza manualmente elCalculationTimea la hora UTC actual enResulty tipos derivados.ToPaged(): Convierte un resultado aResultPaged<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 unResultEntity<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:
MatchyTapno modifican el resultado. Solo bifurcan el flujo o ejecutan efectos secundarios.Mapproyecta valores exitosos a un nuevo resultado. En resultados de colección y paginados conservaStatus,Errors,ResultCode,CalculationTimey las entradas de metadatos adjuntas. EnResultPaged<T>además conserva los metadatos de paginación.Bindcorta la cadena en caso de fallo. En resultados de colección y paginados, la propagación del fallo conservaResultCode,CalculationTimey las entradas de metadatos adjuntas; en éxito, el binder pasa a ser dueño del resultado final que devuelve.Ensure/EnsureAsyncsolo 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.Recoverdesempaqueta unResultEntity<T>a unTplano, aportando un valor de respaldo solo cuando el resultado no es exitoso.EnsuresobreResultPaged<T>yResultBatch<T>actualiza la instancia actual aValidationErrory 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 aResult. - Usa
TryOutcome*cuando el delegado debe decidir explícitamente elStatusResult(NotFound,ValidationError,NoExist, etc.). - Mantén la semántica explícita:
NoExistpara ausencia de dato en dominio yNotFoundpara 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(...)yTryResult.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 familiaResult.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
ResultcomoNoExist()yValidationError()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()yEntityParameters.GetParameters()ahora utilizanyield returnen lugar de crear listas temporales, mejorando la eficiencia de memoria de $O(n)$ a $O(1)$ de espacio. - Eficiencia en Colecciones:
ResultEntities<T>yResultPaged<T>utilizanIReadOnlyList<T>para la propiedadEntities. 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
AggressiveInliningpara minimizar el overhead. - Iteración Directa: Soportada mediante
foreach. Internamente, su enumerador delega en la colección subyacente sin usaryield, evitando asignaciones extra de la máquina de estados. - Carga JSON: Los payloads de resultado aplanan metadatos conocidos en propiedades de primer nivel como
locationoetag, mientras excluyen automáticamente helpers calculados comoTotalPages,HasNextPageyHasPreviousPage. - Comprobaciones de estado:
IsSuccess(),IsFailure()y el resto de extensiones semánticas están anotadas conAggressiveInliningpara minimizar el overhead en rutas de código críticas. - Gestión de Excepciones: Al crear resultados de error desde excepciones manualmente, usa
deep: falsesalvo que necesites explícitamente el detalle de inner exceptions para reducir asignaciones enErrorCollection.
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 10lidera las rutas representativas de creación y API funcional en esta batería, especialmente en paginación y transformaciones deResultBatch<T>..NET 8sigue siendo muy competitivo y ahora mismo ofrece los mejores números de serialización conSystem.Text.Jsonen esta máquina, además de seguir siendo la base LTS..NET 9funciona 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:
- BenchmarkDotNet
v0.15.8 InProcessNoEmitToolchain- Apple M5 / macOS Tahoe 26.3.1 / Arm64
- Los reportes detallados se generan en BenchmarkDotNet.Artifacts/results
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 | Versions 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. |
-
net10.0
- Pitasoft.Error (>= 5.3.14)
-
net8.0
- Pitasoft.Error (>= 5.3.14)
-
net9.0
- Pitasoft.Error (>= 5.3.14)
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 |