Changelog
Notable changes in each release.
A .NET helper library that standardizes process and data outputs, provides a paginated output container, and includes IQueryable pagination extension methods (synchronous & asynchronous) — by page number, or by signed continuation token.
ProcessOutputDataOutputPaginatedOutputCustomExceptionPaginatedOutputExtensions (extension methods)PageTokenOptions, PageTokenErrors, SortDirection (token pagination)ArturRios.Output provides lightweight types to represent the result of operations in a consistent way. Typical use
cases:
IQueryable sources (works with EF Core or in-memory LINQ)The package’s only dependency is Microsoft.EntityFrameworkCore, which PaginateAsync uses to run the query
asynchronously when the query provider supports it; for providers that don’t, it falls back to synchronous execution.
dotnet add package ArturRios.Output
The package targets net10.0.
Alternatively, add the repository as a Git submodule and reference the project directly, which lets you step into the library’s own code while debugging:
# from your solution repository root
git submodule add https://github.com/artur-rios/dotnet-output.git external/dotnet-output
git submodule update --init --recursive
# then add the csproj to your solution (adjust path)
dotnet sln add external/dotnet-output/src/ArturRios.Output.csproj
ProcessOutput
Success
flag.List<string> Messages { get; set; }List<string> Errors { get; set; }DateTime Timestamp { get; set; } (UTC)bool Success { get; } (true when no errors)WithError, WithErrors, WithMessage, WithMessagesAddError, AddErrors, AddMessage, AddMessages — every one of them ignores
null, empty and whitespace-only entries, and AddErrors / AddMessages ignore a null
collection outrightProcessOutput.NewDataOutput<T> : ProcessOutput
ProcessOutput provides.T? Data { get; set; }AddData(T)WithData(T), WithError(string), WithErrors(IEnumerable<string>?),
WithMessage(string), WithMessages(IEnumerable<string>?)DataOutput<T>.NewPaginatedOutput<T> : DataOutput<List<T>>
List<T> as the payload.int PageNumber { get; set; }int PageSize { get; set; } (the requested page size, set via WithPagination)int TotalItems { get; set; }int TotalPages { get; } (computed as Ceil(TotalItems / PageSize), or 0 when PageSize is 0)string? NextPageToken { get; set; } (token for the next page from token pagination; null on the last page
and on offset-paginated outputs)bool HasNextPage { get; } (true when NextPageToken is set)WithPagination(int pageNumber, int pageSize, int totalItems), WithData(List<T>), WithData(T),
WithEmptyData(), AddItem(T), AddItems(IEnumerable<T>)WithMessage / WithError overloads preserved.WithNextPageToken(string?)PaginatedOutput<T>.NewCustomException (abstract)
string[] messages and builds the base Exception message by joining themstring[] Messages { get; }PaginatedOutputExtensions (static)
IQueryable<T> to paginate a query and return a PaginatedOutput<T>.Task<PaginatedOutput<T>> PaginateAsync<T>(this IQueryable<T> query, int pageNumber, int pageSize, Expression<Func<T, object?>>? orderBy = null, int? totalCount = null, CancellationToken cancellationToken = default)IAsyncQueryProvider; otherwise falls back to
synchronous ToList().PaginatedOutput<T> Paginate<T>(this IQueryable<T> query, int pageNumber, int pageSize, Expression<Func<T, object?>>? orderBy = null, int? totalCount = null)orderBy is applied.PaginatedOutput<T> PaginateByToken<T, TKey>(this IQueryable<T> query, string? pageToken, int pageSize, Expression<Func<T, TKey>> keySelector, PageTokenOptions options, SortDirection direction = SortDirection.Ascending, bool includeTotalCount = false)pageToken (the first page when it is null or blank), ordered
by keySelector, with the next page’s token in NextPageToken. The key must be unique and non-null.PaginatedOutput<T> PaginateByToken<T, TKey, TTieBreaker>(this IQueryable<T> query, string? pageToken, int pageSize, Expression<Func<T, TKey>> keySelector, Expression<Func<T, TTieBreaker>> tieBreakerSelector, PageTokenOptions options, SortDirection direction = SortDirection.Ascending, bool includeTotalCount = false)tieBreakerSelector (typically the primary key) orders rows with equal keys.PaginateByTokenAsync — asynchronous variants of both, with a trailing CancellationToken; they use EF Core
async when the provider supports it, like PaginateAsync.PageTokenOptions (record)
required byte[] SigningKey { get; init; } (HMAC-SHA256 key, at least 32 bytes)TimeSpan? Lifetime { get; init; } (how long a token stays valid; null = never expires)string? Scope { get; init; } (binds tokens to one listing, e.g. "orders:customer=42")TimeProvider TimeProvider { get; init; } (clock for issuing and expiry; defaults to TimeProvider.System)PageTokenErrors (static)
Invalid (malformed, unknown version, or the signature does not match — an edited or forged token),
Expired (older than Lifetime), Mismatch (issued for another scope, key, tiebreaker, direction or page size)SortDirection (enum): Ascending, Descending
using ArturRios.Output;
// Simple success response with a message
var p = ProcessOutput.New.WithMessage("Operation completed");
// Data output
var d = DataOutput<int>.New
.WithData(42)
.WithMessage("Value computed");
if (!d.Success) {
// inspect d.Errors
}
using ArturRios.Output;
IQueryable<MyEntity> query = ...; // your LINQ or EF Core query
int pageNumber = 1;
int pageSize = 20;
var page = query.Paginate(pageNumber, pageSize, e => e.CreatedAt);
// page is PaginatedOutput<MyEntity>
var items = page.Data; // List<MyEntity>
using ArturRios.Output;
// Once, e.g. as a singleton: the key comes from configuration and is shared by every instance of the API.
var tokenOptions = new PageTokenOptions
{
SigningKey = Convert.FromBase64String(configuration["Pagination:SigningKey"]!),
Lifetime = TimeSpan.FromHours(1)
};
// Per request: pageToken is null for the first page, then the NextPageToken the client got back.
var page = await dbContext.Orders
.Where(o => o.CustomerId == customerId)
.PaginateByTokenAsync(pageToken, 50, o => o.CreatedAt, o => o.Id,
tokenOptions with { Scope = $"orders:customer={customerId}" },
SortDirection.Descending, cancellationToken: ct);
if (!page.Success)
{
return BadRequest(page.Errors); // one of PageTokenErrors
}
return Ok(page); // page.Data plus page.NextPageToken for the client's next request
using ArturRios.Output;
var page = await dbContext.Set<MyEntity>()
.Where(e => e.IsActive)
.PaginateAsync(pageNumber: 2, pageSize: 50, orderBy: e => e.CreatedAt, cancellationToken: ct);
var items = page.Data; // List<MyEntity>
Serialization. The output types round-trip under both System.Text.Json and Newtonsoft.Json,
including payloads written by one and read by the other. Every serialized member — Data,
Messages, Errors, Timestamp, PageNumber, PageSize, TotalItems — has a public getter
and setter, which both serializers handle by default, so the library carries no serializer
attributes and no serializer dependency. Success and TotalPages are computed: they are
written out, and ignored on read. The AddX / WithX helpers remain the intended way to build
an output; the setters exist so serializers can populate one.
Messages and Errors are never null. Assigning null — which is what a deserializer does
when the payload carries an explicit "messages": null — resets them to empty lists, so
Success and the AddX helpers stay safe on hostile or partial input.
PaginatedOutput.PageSize is the page size requested by the caller, not the number of items in the current page —
the last page usually holds fewer items than that. TotalItems is the total number of items across all pages, and
TotalPages is computed as Ceiling(TotalItems / PageSize), or 0 when PageSize is 0 (that is, when
WithPagination has not been called).
Argument normalisation. Paginate and PaginateAsync clamp pageNumber and pageSize to a
minimum of 1, and clamp a caller-supplied totalCount to a minimum of 0. A null query throws
ArgumentNullException. A page that starts beyond int.MaxValue items in — (pageNumber - 1) * pageSize
too large for an int — comes back empty without reading the query.
Supplying totalCount. Pass it when you already know how many rows the query matches; the count
query is then skipped and the value is reported as TotalItems verbatim. The page itself is still
read from the query, so a supplied count of 0 does not suppress the data. When totalCount is
not supplied and the computed count is 0, both methods skip the data query entirely.
Ordering. orderBy is declared as Expression<Func<T, object?>> for convenience, which makes the
compiler wrap a value-type key in a Convert(..., Object) node. Both Paginate and PaginateAsync
strip that node before calling Queryable.OrderBy, so a relational provider sees a properly typed
key and can translate the ordering to SQL.
Token pagination. PaginateByToken / PaginateByTokenAsync resume after the last item of the previous
page with a keyset filter (WHERE key > @lastKey, or < descending) instead of an offset, fetching one row
past the page to tell whether another follows. The cursor travels as a SQL parameter, so every page shares
one query plan. Notes:
PageNumber is carried
in the token, so it stays trustworthy. A caller can still walk every page in order — rate limiting is the
API’s job.PageTokenOptions.Scope it was issued with; set Scope per filter (customer, tenant, search)
so a token from one listing cannot be replayed against another.PageTokenErrors; they never throw and never fall back to the first page. Programmer errors do throw: a
null query, selector or options (ArgumentNullException), a signing key under 32 bytes
(ArgumentException), and a null key or tiebreaker on a page boundary (InvalidOperationException).TotalItems and TotalPages are 0 unless includeTotalCount: true,
which counts the whole query on every call.Below is a mermaid class diagram representing the public classes in this project. You can paste this into any mermaid-enabled renderer (for example GitHub markdown with mermaid enabled, or live editors).
classDiagram
class ProcessOutput {
+Messages: List_string
+Errors: List_string
+Timestamp: DateTime
+Success: bool
}
class DataOutput_T {
+Data: T_optional
}
class PaginatedOutput_T {
+PageNumber: int
+PageSize: int
+TotalItems: int
+TotalPages: int
+NextPageToken: string_optional
+HasNextPage: bool
}
class CustomException {
+Messages: string_array
}
class PaginatedOutputExtensions {
+PaginateAsync()
+Paginate()
+PaginateByTokenAsync()
+PaginateByToken()
}
DataOutput_T --|> ProcessOutput
PaginatedOutput_T --|> DataOutput_T
class PageTokenOptions {
+SigningKey: byte_array
+Lifetime: TimeSpan_optional
+Scope: string_optional
+TimeProvider: TimeProvider
}
PaginatedOutputExtensions ..> PaginatedOutput_T: uses
PaginatedOutputExtensions ..> PageTokenOptions: usesThis project is licensed under the Artur Rios Attribution License. It is free and open source: you may view, use, fork, modify, and extend it, provided you credit the author, Artur Rios, with a link to artur-rios.tech or github.com/artur-rios.
Notable changes in each release.
Building from source, running the tests, branching and releasing.