Documentation

A .NET helper library that standardizes process and data outputs, provides a paginated output container, and includes IQueryable pagination extension methods…

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.

Contents

  • Overview
  • Installation
  • Classes
    • ProcessOutput
    • DataOutput
    • PaginatedOutput
    • CustomException
    • PaginatedOutputExtensions (extension methods)
    • PageTokenOptions, PageTokenErrors, SortDirection (token pagination)
  • Usage examples
  • API notes & gotchas
  • Class diagram
  • Legal Details

Overview

ArturRios.Output provides lightweight types to represent the result of operations in a consistent way. Typical use cases:

  • Return success/failure information together with messages and errors
  • Return typed data payloads with fluent helpers
  • Return paginated list results and help paginate IQueryable sources (works with EF Core or in-memory LINQ)
  • Paginate by signed continuation token (keyset pagination): fast on deep pages, stable while rows are added or removed, and a caller can only reach a page with the token issued by the page before it

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.

Installation

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

Classes

  • ProcessOutput

    • Purpose: Base container for operation results. Captures messages, errors, timestamp and a convenience Success flag.
    • Key members:
      • List<string> Messages { get; set; }
      • List<string> Errors { get; set; }
      • DateTime Timestamp { get; set; } (UTC)
      • bool Success { get; } (true when no errors)
      • Fluent helpers: WithError, WithErrors, WithMessage, WithMessages
      • Add helpers: AddError, AddErrors, AddMessage, AddMessages — every one of them ignores null, empty and whitespace-only entries, and AddErrors / AddMessages ignore a null collection outright
      • Static factory: ProcessOutput.New
  • DataOutput<T> : ProcessOutput

    • Purpose: Holds a typed data payload plus everything ProcessOutput provides.
    • Key members:
      • T? Data { get; set; }
      • Add helper: AddData(T)
      • Fluent API: WithData(T), WithError(string), WithErrors(IEnumerable<string>?), WithMessage(string), WithMessages(IEnumerable<string>?)
      • Static factory: DataOutput<T>.New
  • PaginatedOutput<T> : DataOutput<List<T>>

    • Purpose: Represents a paginated result set containing List<T> as the payload.
    • Key members:
      • 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)
      • Helpers to build: WithPagination(int pageNumber, int pageSize, int totalItems), WithData(List<T>), WithData(T), WithEmptyData(), AddItem(T), AddItems(IEnumerable<T>)
      • Fluent WithMessage / WithError overloads preserved.
      • Fluent WithNextPageToken(string?)
      • Static factory: PaginatedOutput<T>.New
  • CustomException (abstract)

    • Purpose: Base exception type wrapping an array of messages. Derive from it to create domain-specific exceptions that carry multiple messages.
    • Key members:
      • Constructor that accepts string[] messages and builds the base Exception message by joining them
      • string[] Messages { get; }
  • PaginatedOutputExtensions (static)

    • Purpose: Extension methods for IQueryable<T> to paginate a query and return a PaginatedOutput<T>.
    • Methods:
      • 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)
        • Uses EF Core async if the query provider supports 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)
        • Synchronous variant. When provided, 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)
        • Returns the page after the one that issued 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)
        • For a key that repeats: 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)

    • Purpose: Settings for issuing and checking page tokens. Build one instance (typically a singleton from configuration) and pass it to every call that walks the same listing.
    • Key members:
      • 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)

    • Purpose: The errors token pagination reports when it rejects a token, as constants to map to a response.
    • 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

Usage examples

Basic (ProcessOutput / DataOutput)

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
}

Paginated example (synchronous)

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>

Token-paginated example (async, EF Core)

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

Paginated example (async, EF Core)

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>

API notes & gotchas

  • 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:

    • No skipping pages. Tokens are HMAC-SHA256 signed: editing the page number or key in one fails the signature check, so the only way to reach page N is the token issued by page N-1. 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.
    • Signed, not encrypted. Anyone can base64-decode a token and read the key values in it.
    • Bound to the listing. A token is only accepted with the same key and tiebreaker selectors, direction, page size and PageTokenOptions.Scope it was issued with; set Scope per filter (customer, tenant, search) so a token from one listing cannot be replayed against another.
    • Rejected tokens — invalid, expired or mismatched — return an empty output carrying one of the 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).
    • Keys must be non-null, and the key — or the key and tiebreaker together — must be unique, or rows that share a key across a page boundary are skipped. Any ordering already on the query is replaced.
    • Counting is off by default: TotalItems and TotalPages are 0 unless includeTotalCount: true, which counts the whole query on every call.
    • Forward only. There is no previous-page token.

Class diagram

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: uses

This 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.


Changelog

Notable changes in each release.

Contributing

Building from source, running the tests, branching and releasing.