API-Utilities
2.3.0
dotnet add package API-Utilities --version 2.3.0
NuGet\Install-Package API-Utilities -Version 2.3.0
<PackageReference Include="API-Utilities" Version="2.3.0" />
<PackageVersion Include="API-Utilities" Version="2.3.0" />
<PackageReference Include="API-Utilities" />
paket add API-Utilities --version 2.3.0
#r "nuget: API-Utilities, 2.3.0"
#:package API-Utilities@2.3.0
#addin nuget:?package=API-Utilities&version=2.3.0
#tool nuget:?package=API-Utilities&version=2.3.0
API-Utilities
A lightweight .NET library of reusable building blocks for API development — input guards, string helpers, uniform response envelopes, pagination, sorting, HTTP-aware exceptions, a functional result type, shared middleware, auth helpers, and IQueryable extensions.
dotnet add package API-Utilities
Table of Contents
- Namespaces
- Check — Input Guards
- StringExtensions
- ApiResponse — Uniform Response Envelope
- Pagination
- Middleware
- IQueryable Extensions
- Auth Helpers
- Exceptions
- Result<T> — Functional Result Type
- Clock - Testable Time
- Trimming and Native AOT
- Validation Filter
Namespaces
| Namespace | Contents |
|---|---|
CommonUtils.Checks |
Check — static guard methods |
CommonUtils.Extensions |
StringExtensions — string helpers |
CommonUtils.Responses |
ApiResponse, ApiResponse<T> — response envelope |
CommonUtils.Pagination |
PaginationParams, PagedResult<T>, SortParams, MultiSortParams, CursorPaginationParams<T> |
CommonUtils.Exceptions |
ApiException and HTTP-specific subclasses |
CommonUtils.Results |
Result<T>, Result, Unit — functional result type + chaining extensions |
CommonUtils.Middleware |
CommonApiExceptionHandler, CorrelationIdMiddleware, ICorrelationIdAccessor |
CommonUtils.Auth |
ClaimsPrincipalExtensions, ICurrentUserContext |
CommonUtils.Linq |
QueryableExtensions — IQueryable pagination, sorting, paged result |
CommonUtils.Filters |
ValidateModelFilter — automatic ModelState validation filter |
CommonUtils.Time |
IClock, SystemClock - testable clock abstraction |
CommonUtils.Idempotency |
IIdempotencyStore, IdempotencyMiddleware - idempotency-key support |
Check — Input Guards
Check is a static guard class. Every method validates a value and returns it unchanged if valid, so guards compose naturally inline or at the top of a method body. On failure it throws a standard .NET argument exception — your middleware decides how to map that to an HTTP response.
using CommonUtils.Checks;
public void CreateOrder(string customerId, int quantity, decimal price)
{
customerId = Check.NotEmpty(customerId, nameof(customerId)); // trims and returns
quantity = Check.Positive(quantity, nameof(quantity));
price = Check.Positive(price, nameof(price));
}
String guards
| Method | Throws | Notes |
|---|---|---|
NotEmpty(string?, paramName) |
ArgumentException |
Null, empty, or whitespace. Returns trimmed value. |
MaxLength(string, max, paramName) |
ArgumentException |
Trims before measuring. Returns original value. |
MinLength(string, min, paramName) |
ArgumentException |
Trims before measuring. Returns original value. |
Length(string, min, max, paramName) |
ArgumentException |
Combined min + max check. |
Numeric guards — int, long, decimal, double
| Method | Throws | Notes |
|---|---|---|
Positive(value, paramName) |
ArgumentOutOfRangeException |
value > 0 |
NotNegative(value, paramName) |
ArgumentOutOfRangeException |
value ≥ 0 |
InRange(value, min, max, paramName) |
ArgumentOutOfRangeException |
int and decimal overloads |
Collection guards
| Method | Throws | Notes |
|---|---|---|
NotNull<T>(value, paramName) |
ArgumentNullException |
Any reference type |
NotEmpty<T>(IEnumerable<T>?, paramName) |
ArgumentException |
Null or empty |
NotEmpty<T>(IReadOnlyCollection<T>?, paramName) |
ArgumentException |
Preferred overload — avoids double-enumeration |
MaxCount<T>(ICollection<T>, max, paramName) |
ArgumentException |
— |
MinCount<T>(ICollection<T>, min, paramName) |
ArgumentException |
— |
Other guards
| Method | Throws | Notes |
|---|---|---|
NotEmpty(Guid, paramName) |
ArgumentException |
Guid.Empty check |
Defined<T>(T, paramName) |
ArgumentException |
Enum value must be declared |
NotDefault(DateTime, paramName) |
ArgumentException |
Rejects DateTime.MinValue |
NotDefault(DateTimeOffset, paramName) |
ArgumentException |
— |
NotInPast(DateTime, paramName) |
ArgumentOutOfRangeException |
Calls NotDefault first |
NotInFuture(DateTime, paramName) |
ArgumentOutOfRangeException |
Calls NotDefault first |
NotInPast(DateTimeOffset, paramName) |
ArgumentOutOfRangeException |
— |
NotInFuture(DateTimeOffset, paramName) |
ArgumentOutOfRangeException |
— |
NotDefault(DateOnly, paramName) |
ArgumentException |
Rejects DateOnly.MinValue |
NotInPast(DateOnly, paramName) |
ArgumentOutOfRangeException |
Calls NotDefault first |
NotInFuture(DateOnly, paramName) |
ArgumentOutOfRangeException |
Calls NotDefault first |
NotDefault(TimeOnly, paramName) |
ArgumentException |
Rejects TimeOnly.MinValue |
InRange(TimeOnly, min, max, paramName) |
ArgumentOutOfRangeException |
Inclusive bounds |
Positive(TimeSpan, paramName) |
ArgumentOutOfRangeException |
value > Zero |
NotNegative(TimeSpan, paramName) |
ArgumentOutOfRangeException |
value ≥ Zero |
InRange(TimeSpan, min, max, paramName) |
ArgumentOutOfRangeException |
Inclusive bounds |
var id = Check.NotEmpty(dto.Id, nameof(dto.Id));
var tags = Check.NotEmpty(dto.Tags, nameof(dto.Tags));
var role = Check.Defined(dto.Role, nameof(dto.Role));
var expires = Check.NotInPast(dto.ExpiresAt, nameof(dto.ExpiresAt));
var apptDate = Check.NotInPast(dto.AppointmentDate, nameof(dto.AppointmentDate)); // DateOnly
var openTime = Check.InRange(dto.OpenTime, new TimeOnly(8,0), new TimeOnly(20,0), nameof(dto.OpenTime));
var timeout = Check.Positive(dto.Timeout, nameof(dto.Timeout)); // TimeSpan
Format guards
| Method | Throws | Notes |
|---|---|---|
Email(string?, paramName) |
ArgumentException |
RFC-style format check. Returns trimmed value. |
Url(string?, paramName) |
ArgumentException |
Absolute HTTP/HTTPS URL only. |
Matches(string?, pattern, paramName) |
ArgumentException |
Custom regex with 1-second timeout. |
Phone(string?, paramName) |
ArgumentException |
E.164 format (+15551234567). |
email = Check.Email(dto.Email, nameof(dto.Email));
website = Check.Url(dto.Website, nameof(dto.Website));
postCode = Check.Matches(dto.PostCode, @"^\d{5}$", nameof(dto.PostCode));
phone = Check.Phone(dto.Phone, nameof(dto.Phone));
Predicate and set guards
| Method | Throws | Notes |
|---|---|---|
That(bool, paramName, message?) |
ArgumentException |
General-purpose guard for conditions the typed guards don't cover. |
OneOf<T>(value, allowed, paramName) |
ArgumentException |
Value must be in the allowed set. Returns the value. |
Check.That(quantity % 2 == 0, nameof(quantity), "Quantity must be even.");
var status = Check.OneOf(dto.Status, ["active", "paused", "closed"], nameof(dto.Status));
Testable time guards
NotInPast / NotInFuture (for DateTime, DateTimeOffset, DateOnly) each have an overload
taking a TimeProvider, so the "now" they compare against can be controlled in tests.
// production — uses the system clock
var expires = Check.NotInPast(dto.ExpiresAt, nameof(dto.ExpiresAt));
// test — pass a FakeTimeProvider for a deterministic "now"
var expires = Check.NotInPast(dto.ExpiresAt, nameof(dto.ExpiresAt), fakeTimeProvider);
StringExtensions
using CommonUtils.Extensions;
Normalize
Trims leading/trailing whitespace and collapses internal runs of any whitespace (\t, \n, multiple spaces) to a single space. Useful for sanitising name and address fields from form input.
Note: Because
stringhas a built-inNormalize()instance method (Unicode normalization), calling.Normalize()on a string will resolve to the BCL method, not this one. Call it explicitly as a static method:
string clean = StringExtensions.Normalize(" hello\t world "); // "hello world"
Truncate
Returns the string as-is if it fits, or cuts it to exactly maxLength characters — never throws.
string preview = description.Truncate(160);
NullIfEmpty
Returns null for null, empty, or whitespace-only strings. Useful for optional fields stored as NULL in the database rather than empty strings.
string? nickname = dto.Nickname.NullIfEmpty(); // null when blank
ToSnakeCase
Converts PascalCase or camelCase to snake_case. Breaks on lowercase/digit → uppercase transitions.
"OrderId".ToSnakeCase() // "order_id"
"createdAt".ToSnakeCase() // "created_at"
"Version2Value".ToSnakeCase() // "version2_value"
Acronym behaviour: Only the last uppercase letter in a run is treated as a word boundary.
"OrderID"→"order_id"(breaks beforeD)."HTTPServer"→"httpserver"(no internal break).
ToKebabCase
Converts PascalCase or camelCase to kebab-case. Useful for URL slugs and route segments.
"OrderLineItem".ToKebabCase() // "order-line-item"
"createdAt".ToKebabCase() // "created-at"
ToPascalCase
Converts snake_case or kebab-case to PascalCase.
"order_line_item".ToPascalCase() // "OrderLineItem"
"order-line-item".ToPascalCase() // "OrderLineItem"
"hello".ToPascalCase() // "Hello"
Mask
Masks the middle of a string, preserving a configurable number of characters at each end. Safe for logging emails, tokens, and phone numbers.
"user@example.com".Mask() // "us**************" (2 visible start, default)
"user@example.com".Mask(2, 3) // "us***********com"
"secret-token".Mask(0, 0, '#') // "############"
Parameters: visibleStart (default 2), visibleEnd (default 0), maskChar (default *).
When visibleStart + visibleEnd ≥ length, the entire string is masked.
ToSlug
Produces a URL-friendly slug: strips diacritics, lowercases, and collapses any run of non-alphanumeric characters into a single hyphen (trimming leading/trailing hyphens).
"Café del Mar!".ToSlug() // "cafe-del-mar"
"Order #42 - Final".ToSlug() // "order-42-final"
ContainsIgnoreCase
Case-insensitive Contains using an ordinal comparison, without allocating lowercased copies.
"Hello World".ContainsIgnoreCase("WORLD") // true
ApiResponse — Uniform Response Envelope
All endpoints return the same shape, making client-side parsing predictable.
using CommonUtils.Responses;
Endpoints that return data
// success
return Ok(ApiResponse.Ok(user));
// failure
return BadRequest(ApiResponse.Fail<User>("User not found."));
// multiple errors
return UnprocessableEntity(ApiResponse.Fail<User>(errors));
Shape:
{ "success": true, "data": { ... }, "message": "optional", "errors": [] }
{ "success": false, "data": null, "message": null, "errors": ["..."] }
Endpoints with no payload (DELETE, commands, etc.)
return Ok(ApiResponse.Ok("Order cancelled."));
return BadRequest(ApiResponse.Fail("Insufficient stock."));
Shape:
{ "success": true, "message": "Order cancelled.", "errors": [] }
{ "success": false, "message": null, "errors": ["Insufficient stock."] }
Pagination
PaginationParams
Bind directly from the query string. Defaults to page 1, page size 20.
using CommonUtils.Pagination;
// GET /orders?page=2&pageSize=50
[HttpGet]
public IActionResult GetOrders([FromQuery] PaginationParams pagination)
{
pagination.Validate(); // throws BadRequestException on bad input
var items = _repo.GetOrders(skip: pagination.Skip, take: pagination.Take);
// ...
}
Validate(maxPageSize) throws BadRequestException if Page < 1, PageSize < 1, or PageSize > maxPageSize (default 100).
PagedResult<T>
var items = await _db.Orders.Skip(p.Skip).Take(p.Take).ToListAsync();
var totalCount = await _db.Orders.CountAsync();
PagedResult<OrderDto> result = PagedResult.Create(items, totalCount, pagination);
return Ok(ApiResponse.Ok(result));
Properties on PagedResult<T>:
| Property | Type | Description |
|---|---|---|
Items |
IReadOnlyList<T> |
Current page items |
Page |
int |
Current page (1-based) |
PageSize |
int |
Items per page |
TotalCount |
int |
Total items across all pages |
TotalPages |
int |
Computed: ⌈TotalCount / PageSize⌉ |
HasNextPage |
bool |
Page < TotalPages |
HasPreviousPage |
bool |
Page > 1 |
Use PagedResult.Empty<T>(pagination) when the data source returns nothing.
Use Map to project a page of entities to DTOs while keeping all paging metadata:
PagedResult<OrderDto> dtos = entityPage.Map(o => new OrderDto(o));
SortParams
Companion to PaginationParams for list endpoints that support ordering.
// GET /orders?sortBy=createdAt&direction=Desc
[HttpGet]
public IActionResult GetOrders(
[FromQuery] PaginationParams pagination,
[FromQuery] SortParams sort)
{
pagination.Validate();
sort.Validate(["name", "createdAt", "price"]); // throws BadRequestException for unknown columns
if (sort.IsActive)
{
query = sort.Direction == SortDirection.Desc
? query.OrderByDescending(sort.SortBy)
: query.OrderBy(sort.SortBy);
}
}
Validate(allowedColumns) is a no-op when SortBy is null or empty — safe to call unconditionally.
MultiSortParams
For endpoints that need compound ordering across multiple columns.
// GET /orders?sort=createdAt:desc&sort=name:asc
[HttpGet]
public IActionResult GetOrders(
[FromQuery] PaginationParams pagination,
[FromQuery] MultiSortParams sort)
{
sort.Validate(["name", "createdAt", "price"]);
foreach (var criterion in sort.Criteria)
{
query = criterion.Direction == SortDirection.Desc
? query.OrderByDescending(criterion.SortBy)
: query.OrderBy(criterion.SortBy);
}
}
Each entry in Sort is a "column:direction" string. Direction defaults to asc when omitted.
CursorPaginationParams<TCursor>
Keyset/cursor pagination for large datasets — avoids the OFFSET n performance cliff at high page numbers.
// GET /orders?after=550&pageSize=20
[HttpGet]
public async Task<IActionResult> GetOrders(
[FromQuery] CursorPaginationParams<int?> cursor)
{
cursor.Validate(); // throws BadRequestException when PageSize < 1 or > 100
var items = await _db.Orders
.Where(o => cursor.After == null || o.Id > cursor.After)
.OrderBy(o => o.Id)
.Take(cursor.PageSize + 1)
.ToListAsync();
var hasMore = items.Count > cursor.PageSize;
if (hasMore) items.RemoveAt(items.Count - 1);
var result = CursorPagedResult.Create<Order, int?>(
items.AsReadOnly(),
hasMore ? items[^1].Id : null);
return Ok(ApiResponse.Ok(result));
}
Middleware
CommonApiExceptionHandler
Shared IExceptionHandler that maps ApiException subclasses to uniform JSON responses. Eliminates the per-project UseExceptionHandler boilerplate.
// Program.cs
builder.Services.AddCommonApiExceptionHandling();
// or: .AddCommonApiExceptionHandling(useProblemDetails: true)
app.UseCommonApiExceptionHandling();
Supports two modes:
- Default — returns
ApiResponse-shaped JSON ({ success, errors, errorCode }) - ProblemDetails — returns RFC 7807
application/problem+json
TooManyRequestsException automatically sets the Retry-After response header.
CorrelationIdMiddleware
Reads or generates a X-Correlation-ID for every request, making it available to downstream services via ICorrelationIdAccessor. The ID is echoed back in the response header.
// Program.cs
builder.Services.AddCorrelationId();
app.UseCorrelationId();
// Custom header name
builder.Services.AddCorrelationId(options => options.HeaderName = "X-Request-ID");
// Inject in a service or controller
public class OrderService(ICorrelationIdAccessor correlationId)
{
public void LogSomething()
{
_logger.LogInformation("CorrelationId: {Id}", correlationId.CorrelationId);
}
}
Outbound propagation
CorrelationIdPropagationHandler stamps the current request's correlation ID onto every outgoing
HttpClient call, so a trace can be followed across service boundaries. Register it on any typed or
named client with AddCorrelationIdPropagation().
builder.Services.AddCorrelationId();
builder.Services
.AddHttpClient<InventoryClient>()
.AddCorrelationIdPropagation(); // outgoing requests carry X-Correlation-ID
The handler respects a custom HeaderName configured on AddCorrelationId, and never overwrites a
correlation header that the caller already set on the request.
Idempotency
IdempotencyMiddleware caches the response to a mutating request keyed by an Idempotency-Key
header and replays it verbatim if the same key is retried - so a client that retries after a
network blip does not create a duplicate resource. Only POST and PATCH participate by default,
and only successful (2xx) responses are cached.
// Program.cs
builder.Services.AddIdempotency(options =>
{
options.Ttl = TimeSpan.FromHours(12);
options.RequireKey = true; // reject participating requests with no key (400)
});
app.UseIdempotency();
A replayed response carries an Idempotency-Replayed: true header. The default store is in-memory
(IMemoryCache); register your own IIdempotencyStore before AddIdempotency() to back it with a
distributed cache such as Redis for multi-instance deployments.
| Option | Default | Description |
|---|---|---|
HeaderName |
Idempotency-Key |
Request header carrying the key |
Ttl |
24 hours | How long a captured response is replayed |
Methods |
POST, PATCH |
HTTP methods that participate |
RequireKey |
false |
Reject participating requests that omit the key |
IQueryable Extensions
using CommonUtils.Linq;
Eliminates manual Skip/Take/OrderBy in every service method. The ToPagedResultAsync overload accepts a materializer delegate so the library stays free of an EF Core dependency.
var result = await _db.Orders
.Where(o => o.CustomerId == customerId)
.ApplySorting(sort, new Dictionary<string, Expression<Func<Order, object?>>>
{
["name"] = o => o.Name,
["createdAt"] = o => o.CreatedAt,
})
.ToPagedResultAsync(
pagination,
materialize: (q, ct) => q.ToListAsync(ct),
countAsync: (q, ct) => q.CountAsync(ct),
cancellationToken);
| Method | Description |
|---|---|
ApplyPagination(PaginationParams) |
Applies Skip + Take |
ApplySorting(SortParams, columnMap) |
Applies type-safe OrderBy/OrderByDesc |
ToPagedResultAsync(pagination, materialize, countAsync, ct) |
Counts + pages + wraps in PagedResult<T> |
Auth Helpers
ClaimsPrincipalExtensions
using CommonUtils.Auth;
// In a controller or service
string? userId = User.GetUserId(); // ClaimTypes.NameIdentifier
string? email = User.GetEmail();
string? role = User.GetRole(); // first role claim
IEnumerable<string> roles = User.GetRoles();
string? custom = User.GetClaim("tenant-id");
bool isAdmin = User.HasClaim(ClaimTypes.Role, "Admin");
ICurrentUserContext
Scoped service that wraps the current user's claims for injection into services — no need to pass ClaimsPrincipal down the call stack.
// Program.cs
builder.Services.AddCurrentUserContext();
// In a service
public class OrderService(ICurrentUserContext currentUser)
{
public void PlaceOrder()
{
var userId = currentUser.UserId; // string?
var email = currentUser.Email; // string?
var role = currentUser.Role; // string?
var auth = currentUser.IsAuthenticated; // bool
}
}
Exceptions
All exceptions extend ApiException, which carries an HTTP StatusCode and an optional machine-readable ErrorCode. Wire once in middleware — no per-endpoint try/catch needed.
using CommonUtils.Exceptions;
Available exceptions
| Class | Status | When to use |
|---|---|---|
BadRequestException |
400 | Malformed input, failed preconditions |
UnauthorizedException |
401 | Missing or invalid credentials |
ForbiddenException |
403 | Authenticated but not permitted |
NotFoundException |
404 | Resource does not exist |
ConflictException |
409 | Duplicate or state conflict |
GoneException |
410 | Resource permanently removed |
ValidationException |
422 | Semantic validation errors (field-level) |
TooManyRequestsException |
429 | Rate limit exceeded |
ServiceUnavailableException |
503 | Dependency down, maintenance, or transient outage |
Usage
// Simple message
throw new NotFoundException("Order not found.", errorCode: "ORDER_NOT_FOUND");
// Convenience constructor (non-string key)
throw new NotFoundException("Order", orderId); // "Order with key '42' was not found."
// Field-level validation errors
throw new ValidationException("Email", "must be a valid email address");
// Multiple fields (e.g. from FluentValidation)
throw new ValidationException(new Dictionary<string, string[]>
{
["Email"] = ["Required", "Must be a valid email"],
["Password"] = ["Must be at least 8 characters"],
});
// Rate limiting — include a retry hint for the client
throw new TooManyRequestsException(retryAfter: TimeSpan.FromSeconds(30));
// Permanently removed resource
throw new GoneException("This API version has been retired.");
// Dependency or database unavailable
throw new ServiceUnavailableException("Payment provider is currently unavailable.", "PAYMENT_DOWN");
Middleware integration
Use CommonApiExceptionHandler from CommonUtils.Middleware for zero-boilerplate setup (see Middleware), or wire it manually:
app.UseExceptionHandler(builder => builder.Run(async context =>
{
var ex = context.Features.Get<IExceptionHandlerFeature>()?.Error;
if (ex is ApiException apiEx)
{
context.Response.StatusCode = apiEx.StatusCode;
if (ex is TooManyRequestsException tooMany && tooMany.RetryAfter.HasValue)
context.Response.Headers["Retry-After"] =
((int)tooMany.RetryAfter.Value.TotalSeconds).ToString();
await context.Response.WriteAsJsonAsync(new
{
success = false,
errorCode = apiEx.ErrorCode,
errors = ex is ValidationException vex && vex.Errors.Count > 0
? vex.Errors
: new[] { ex.Message }
});
return;
}
context.Response.StatusCode = 500;
await context.Response.WriteAsJsonAsync(new
{
success = false,
errors = new[] { "An unexpected error occurred." }
});
}));
Result<T> — Functional Result Type
An alternative to throwing exceptions for expected failure paths. Operations return Result<T> — the caller decides what to do with a failure rather than catching an exception.
using CommonUtils.Results;
Returning results
// Success with a value
Result<Order> result = Result.Ok(order);
// Failure with a single error
Result<Order> result = Result.Fail<Order>("Order not found.");
// Failure with multiple errors
Result<Order> result = Result.Fail<Order>(["Stock too low", "Payment declined"]);
// No-value success (commands, void operations)
Result<Unit> result = Result.Ok();
// Implicit conversion from value
Result<int> result = 42; // equivalent to Result.Ok(42)
Consuming results
var result = _orderService.PlaceOrder(dto);
if (result.IsFailure)
return BadRequest(ApiResponse.Fail<Order>(result.Errors));
return Ok(ApiResponse.Ok(result.Value!));
Chaining — ResultExtensions
Build pipelines without manual if (result.IsFailure) checks:
var response = await _repo.FindOrderAsync(id) // Task<Result<Order>>
.MapAsync(order => new OrderDto(order)) // project value
.BindAsync(dto => _validator.ValidateAsync(dto)) // chain another result
.MatchAsync(
onSuccess: dto => Ok(ApiResponse.Ok(dto)),
onFailure: errs => BadRequest(ApiResponse.Fail<OrderDto>(errs)));
| Method | Description |
|---|---|
Map(Func<T, TOut>) |
Project value; forwards errors unchanged |
Bind(Func<T, Result<TOut>>) |
Chain a result-returning function |
Ensure(predicate, error) |
Fail a success when the value doesn't satisfy a predicate |
Match(onSuccess, onFailure) |
Collapse both paths into a single value |
OnSuccess(Action<T>) |
Side-effect on success; returns original result |
OnFailure(Action<errors>) |
Side-effect on failure; returns original result |
Recover(Func<errors, T>) |
Supply a fallback value on failure |
ValueOrDefault() / ValueOrDefault(fallback) |
Get the value, or default/fallback on failure |
TryGet(out value) |
Try-pattern access; true + value on success |
ToApiResponse(message?) |
Convert to ApiResponse<T> |
ToActionResult(message?) |
Convert to OkObjectResult or BadRequestObjectResult |
MapAsync / BindAsync / MatchAsync |
Async equivalents for Task<Result<T>> pipelines |
Structured errors
Beyond the Errors string list, every failure also exposes ErrorDetails - a list of Error
records carrying a Message, an optional machine-readable Code, and an optional Field. This
gives Result<T> the same machine-readable code that ApiException already has. Map/Bind
forward the structured details through a pipeline, and Errors (strings) is unchanged.
// coded failure
Result<Order> result = Result.Fail<Order>("Order not found.", "ORDER_NOT_FOUND");
// or a full structured error
Result<Order> result = Result.Fail<Order>(new Error("Invalid email", "INVALID_EMAIL", field: "Email"));
if (result.IsFailure)
{
var code = result.ErrorDetails[0].Code; // "ORDER_NOT_FOUND"
}
Aggregating with Combine
Result.Combine runs several results and collects every error instead of stopping at the
first - handy for gathering all validation failures at once.
var result = Result.Combine(
ValidateName(dto.Name),
ValidateEmail(dto.Email),
ValidateAge(dto.Age)); // Result<Unit> - fails with all errors if any failed
Properties on Result<T>
| Property | Type | Description |
|---|---|---|
IsSuccess |
bool |
true when the operation succeeded |
IsFailure |
bool |
!IsSuccess |
Value |
T? |
The result value; meaningful on success |
Errors |
IReadOnlyList<string> |
Error messages; empty on success |
ErrorDetails |
IReadOnlyList<Error> |
Structured errors (message + code + field); empty on success |
Use Result<Unit> for operations with no return value. Unit.Value is the singleton instance.
Clock - Testable Time
IClock abstracts the system clock so time-dependent code can be unit-tested deterministically.
The default SystemClock is backed by TimeProvider, so tests can drive it with a FakeTimeProvider.
// Program.cs
builder.Services.AddClock();
// In a service
public class TokenService(IClock clock)
{
public bool IsExpired(DateTimeOffset expiresAt) => expiresAt < clock.UtcNow;
}
| Member | Type | Description |
|---|---|---|
UtcNow |
DateTimeOffset |
Current instant in UTC |
Today |
DateOnly |
Current UTC date |
The time-based Check guards (NotInPast, NotInFuture) also accept a TimeProvider overload for
the same testability - see Check.
Trimming and Native AOT
The library enables trim/AOT static analysis (IsAotCompatible). The guards, results, pagination,
sorting, string helpers, auth helpers, and clock are all trim- and AOT-safe.
The one exception is CommonApiExceptionHandler, which serializes the error envelope with
reflection-based System.Text.Json. It is not Native-AOT-safe as-is. If you publish with Native
AOT, supply a JsonSerializerContext for your error shapes, or handle ApiException yourself.
Validation Filter
ValidateModelFilter is an ActionFilterAttribute that automatically throws ValidationException when ModelState is invalid, removing the need for if (!ModelState.IsValid) in every action.
// Register globally in Program.cs
builder.Services.AddControllers(options =>
options.Filters.Add<ValidateModelFilter>());
// Or per-controller / per-action
[ValidateModelFilter]
public IActionResult CreateOrder([FromBody] CreateOrderDto dto) { ... }
ValidationException.FromModelState(ModelState) is also available directly if you need manual control.
License
MIT — see LICENSE.txt.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
v2.3.0
- Added Error record (Message + machine-readable Code + Field) and Result<T>.ErrorDetails for structured failures; Errors (string list) preserved for backward compatibility
- Added Result.Fail overloads for Error/coded errors and Result.Combine to aggregate many results into one
- Added Result extensions: Ensure, ValueOrDefault, TryGet; Map/Bind now forward structured error details through the pipeline
- Added IClock + SystemClock (TimeProvider-backed) and AddClock() for deterministic, testable time
- Added TimeProvider overloads to Check.NotInPast/NotInFuture (DateTime, DateTimeOffset, DateOnly)
- Added Check.That (predicate guard) and Check.OneOf (allowed-set guard)
- Added StringExtensions.ToSlug (diacritic-stripping URL slug) and ContainsIgnoreCase
- Added PagedResult<T>.Map to project a page to DTOs while preserving paging metadata
- Added CorrelationIdPropagationHandler + AddCorrelationIdPropagation() to forward X-Correlation-ID on outbound HttpClient calls
- Added idempotency-key support: IIdempotencyStore, in-memory store, middleware, AddIdempotency()/UseIdempotency()
- Packaging: ship XML docs, SourceLink + snupkg symbols, deterministic builds, AOT/trim analysis, public API surface tracking
v2.2.0
- Added Result<T> chaining extensions: Map, Bind, Match, OnSuccess, OnFailure, Recover
- Added async pipeline extensions: MapAsync, BindAsync, MatchAsync for Task<Result<T>>
- Added HTTP adapters: ToApiResponse, ToActionResult on Result<T>
- Added CommonApiExceptionHandler: shared IExceptionHandler (JSON + RFC 7807 ProblemDetails)
- Added CorrelationIdMiddleware + ICorrelationIdAccessor for X-Correlation-ID propagation
- Added IQueryable extensions: ApplyPagination, ApplySorting, ToPagedResultAsync (EF-agnostic)
- Added ClaimsPrincipalExtensions: GetUserId, GetEmail, GetRole, GetRoles, GetClaim, HasClaim
- Added ICurrentUserContext + HttpContextCurrentUserContext + AddCurrentUserContext()
- Added ValidationException.FromModelState(ModelStateDictionary)
- Added ValidateModelFilter: ActionFilterAttribute for automatic ModelState validation
- Added MultiSortParams: compound multi-column sort with Validate(allowedColumns)
- Added CursorPaginationParams<TCursor> + CursorPagedResult for keyset pagination
- Added DateOnly guards: NotDefault, NotInPast, NotInFuture
- Added TimeOnly guards: NotDefault, InRange
- Added TimeSpan guards: Positive, NotNegative, InRange
- Added Check.Phone: E.164 phone number format guard
- Added Check.NotEmpty(IReadOnlyCollection<T>) overload to eliminate double-enumeration risk
- Fixed: removed xUnit test dependencies from main library package (packaging pollution)
v2.1.0
- Added TooManyRequestsException (429), GoneException (410), ServiceUnavailableException (503)
- Added SortParams and SortDirection for standardised list endpoint ordering
- Added Check.Email, Check.Url, Check.Matches format guards
- Added ToKebabCase, ToPascalCase, Mask string extension methods
- Added Result<T> functional result type with Unit for void operations