Skip to main content

Real-World Examples

Each recipe on this page solves one everyday task from start to finish, using methods from several areas of the library together. The code compiles as shown, and every output block was produced by running it. Fake services (a converter, an HTTP transport, a rate source) are plain delegates or small classes, so you can paste a recipe into a console app and see the same result.

At a glance​

MethodWhat it does
WithProgress, ToTable, ToTimeline, ToFileSize, ToQuantity, ToHumanStringA command-line tool that converts files, shows progress, and prints a summary table and a timeline.
Retry.ExecuteAsync, WithTimeout, SingleFlight, WithFallbackAn HTTP-style client that retries, times out, shares concurrent calls and falls back to a cached value.
DeepClone, CompareProperties, ToSafeJsonAn audit log entry that lists what changed and masks secrets.
ParseKeyValuePairs, GetClosestKey, MergeA settings loader with defaults, overrides and "did you mean" hints for typos.
ToCsvA CSV export that quotes correctly and blocks spreadsheet formula injection.
Percentile, WithoutOutliers, ToSparkline, ToHistogramA latency report with percentiles, outlier removal and text charts.
ReadAllTextAsync, VerifyHmacSha256, ToHmacSha256, FromJsonA webhook endpoint that rejects requests with a missing or wrong signature.
Every, WithFallbackA BackgroundService that refreshes a value on a timer and survives failures.
WhereIf, SortBy with an allow-list, ToPageAn API list endpoint with filtering, safe sorting and paging.

A file converter CLI​

A command-line tool converts a batch of documents to PDF. While it runs, it prints one status line per file. At the end it prints a table of results and a timeline that shows where the time went.

ConvertCommand.cs
string[] files = { "invoice-1001.docx", "invoice-1002.docx", "contract-acme.docx", "price-list.xlsx" };
List<ConvertedFile> converted = new List<ConvertedFile>();
List<ConversionStep> steps = new List<ConversionStep>();
TimeSpan clock = TimeSpan.Zero;

foreach ((string file, EnumerationProgress progress) in files.WithProgress())
{
Console.WriteLine($"[{progress.Index + 1}/{progress.Total}] {progress.Percent:0}% {file}");

(long bytes, int pages, TimeSpan took) = ConvertToPdf(file);
steps.Add(new ConversionStep(file, clock, clock + took));
clock += took;
converted.Add(new ConvertedFile(file, bytes.ToFileSize(), pages, (int)took.TotalMilliseconds));
}

Console.WriteLine(converted.ToTable());
Console.WriteLine(steps.ToTimeline(step => step.File, step => step.Started, step => step.Finished, width: 24));
string fileCount = "file".ToQuantity(converted.Count);
string pageCount = "page".ToQuantity(converted.Sum(item => item.Pages));
Console.WriteLine($"Done: {fileCount}, {pageCount} in {clock.ToHumanString()}.");

// Stand-in for the real converter: fixed sizes and durations, so the output is repeatable.
static (long Bytes, int Pages, TimeSpan Took) ConvertToPdf(string file) => file switch
{
"invoice-1001.docx" => (48_213, 2, 350.Milliseconds()),
"invoice-1002.docx" => (51_870, 2, 410.Milliseconds()),
"contract-acme.docx" => (1_284_500, 38, 2_600.Milliseconds()),
_ => (226_304, 6, 900.Milliseconds()),
};

public sealed record ConvertedFile(string File, string Size, int Pages, int Milliseconds);
public sealed record ConversionStep(string File, TimeSpan Started, TimeSpan Finished);
Output
[1/4] 0% invoice-1001.docx
[2/4] 25% invoice-1002.docx
[3/4] 50% contract-acme.docx
[4/4] 75% price-list.xlsx
┌────────────────────┬─────────┬───────┬──────────────┐
│ File │ Size │ Pages │ Milliseconds │
├────────────────────┼─────────┼───────┼──────────────┤
│ invoice-1001.docx │ 47.1 KB │ 2 │ 350 │
│ invoice-1002.docx │ 50.7 KB │ 2 │ 410 │
│ contract-acme.docx │ 1.2 MB │ 38 │ 2600 │
│ price-list.xlsx │ 221 KB │ 6 │ 900 │
└────────────────────┴─────────┴───────┴──────────────┘
invoice-1001.docx ██······················ 0ms → 350ms
invoice-1002.docx ··██···················· 350ms → 760ms
contract-acme.docx ····███████████████····· 760ms → 3.4s
price-list.xlsx ···················█████ 3.4s → 4.3s
Done: 4 files, 48 pages in 4 seconds, 260 milliseconds.

How it works:

  • WithProgress pairs each file with an EnumerationProgress. Percent counts the files finished before the current one, so the last file shows 75%, not 100%.
  • ToTable makes one column per public property and right-aligns the numbers.
  • ToTimeline has a TimeSpan overload for offsets from a common zero. The track runs from the earliest start to the latest end, so the long contract conversion stands out at once.
  • ToFileSize, ToQuantity and ToHumanString turn raw numbers into words.
Real timings

In a real tool, start a Stopwatch before the loop and record stopwatch.Elapsed before and after each conversion. Those readings are already offsets from a common zero, which is what the TimeSpan overload of ToTimeline expects. progress.EstimatedRemaining then gives a useful "time left" for a status line. It is null for the first item, because there is nothing to extrapolate from yet.

A resilient HTTP client​

A service reads exchange rates from a remote API that sometimes fails. Each attempt gets a timeout, failed attempts are retried with backoff, callers that ask for the same path at the same time share one request, and a path that stays down returns the last known value instead of an error.

ExchangeRateClient.cs
// In production: Func<string, CancellationToken, Task<string>> send = httpClient.GetStringAsync;
// This fake fails the first two calls with a 503, and "/rates/jod" is always down.
int calls = 0;
Func<string, CancellationToken, Task<string>> send = async (path, cancellationToken) =>
{
calls++;
await Task.Delay(20, cancellationToken);
if (calls <= 2 || path == "/rates/jod")
{
throw new HttpRequestException("503 Service Unavailable");
}

return $"{{\"path\":\"{path}\",\"rate\":0.709}}";
};

Func<string, Task<string>> getRates = path => Retry.ExecuteAsync(
cancellationToken => send(path, cancellationToken).WithTimeout(2.Seconds(), cancellationToken),
maxAttempts: 3,
initialDelay: 100.Milliseconds(),
shouldRetry: exception => exception is HttpRequestException or TimeoutException,
cancellationToken: CancellationToken.None);

Func<string, Task<string>> getRatesShared = getRates.SingleFlight();

// Five callers ask for the same path at the same moment.
string[] bodies = await Task.WhenAll(Enumerable.Range(0, 5).Select(_ => getRatesShared("/rates/usd")));
// bodies.Length → 5
// bodies[0] → "{\"path\":\"/rates/usd\",\"rate\":0.709}"
// calls → 3 (one shared run: two failures, then a success)

const string LastKnown = "{\"path\":\"/rates/jod\",\"rate\":0.708,\"stale\":true}";
string body = await getRatesShared("/rates/jod").WithFallback(
LastKnown,
2.Seconds(),
exception => Console.WriteLine($"Rates unavailable: {exception.Message}"));
// body → "{\"path\":\"/rates/jod\",\"rate\":0.708,\"stale\":true}"
// calls → 6 (three more attempts, all failed)
Output
Rates unavailable: 503 Service Unavailable

How it works:

  • WithTimeout limits each attempt. A slow attempt fails with TimeoutException, and shouldRetry treats that like a network error.
  • Retry.ExecuteAsync waits 100 ms after the first failure and 200 ms after the second. After the last attempt it rethrows the failure unchanged. Pass shouldRetry so a 400 Bad Request or a bug is not retried.
  • SingleFlight on the keyed delegate makes concurrent calls for the same path return the same task. The five callers caused one run of three attempts, not fifteen. Different paths run independently, and nothing is cached after a run completes.
  • WithFallback catches the final failure (or a total wait over 2 seconds) and returns the cached body. The callback receives the exception, so the failure still reaches your logs.
Order matters

Put SingleFlight outside Retry, as shown, so concurrent callers share the whole retry sequence. Put WithFallback outside SingleFlight, so each caller can choose its own fallback value.

The timed-out work keeps running

WithTimeout stops the wait, not the work. Pass the CancellationToken to the real call (as send does here) so the request is cancelled too.

An audit log entry​

A support agent edits a customer. The application records which fields changed, with the old and new values, in a JSON log. The customer's API token also changed, and it must not appear in the log.

CustomerAudit.cs
Customer before = new Customer
{
Id = 42,
Name = "Lina Haddad",
Email = "lina@contoso.com",
Address = new Address { City = "Amman", Street = "Rainbow St 12" },
Tags = new List<string> { "retail" },
ApiToken = "tok_live_7f3a9c",
};

Customer after = before.DeepClone();
after.Email = "lina.haddad@contoso.com";
after.Address.City = "Irbid";
after.Tags.Add("vip");
after.ApiToken = "tok_live_b81e02";

IReadOnlyList<PropertyChange> changes = before.CompareProperties(after);
// changes.Select(change => change.ToString()) →
// ["Email: \"lina@contoso.com\" -> \"lina.haddad@contoso.com\"",
// "Address.City: \"Amman\" -> \"Irbid\"",
// "Tags: [\"retail\"] -> [\"retail\", \"vip\"]",
// "ApiToken: \"tok_live_7f3a9c\" -> \"tok_live_b81e02\""]

AuditEntry entry = new AuditEntry(
Entity: nameof(Customer),
EntityId: after.Id,
ChangedBy: "support@contoso.com",
ChangedAt: new DateTime(2026, 3, 14, 9, 30, 0, DateTimeKind.Utc),
Changes: changes.ToDictionary(change => change.PropertyName, change => new FieldChange(change.OldValue, change.NewValue)));

string json = entry.ToSafeJson();

public sealed class Address
{
public string City { get; set; } = "";
public string Street { get; set; } = "";
}

public sealed class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public Address Address { get; set; } = new Address();
public List<string> Tags { get; set; } = new List<string>();
public string? ApiToken { get; set; }
}

public sealed record FieldChange(object? From, object? To);
public sealed record AuditEntry(
string Entity, int EntityId, string ChangedBy, DateTime ChangedAt, Dictionary<string, FieldChange> Changes);
Output (json, wrapped for reading)
{"Entity":"Customer","EntityId":42,"ChangedBy":"support@contoso.com","ChangedAt":"2026-03-14T09:30:00Z",
"Changes":{"Email":{"From":"lina@contoso.com","To":"lina.haddad@contoso.com"},
"Address.City":{"From":"Amman","To":"Irbid"},
"Tags":{"From":["retail"],"To":["retail","vip"]},
"ApiToken":"***"}}

How it works:

  • DeepClone gives an independent copy to edit, so before keeps the original values, including the nested Address and the Tags list.
  • CompareProperties walks nested objects and reports them with a dotted path (Address.City). It compares collections item by item.
  • The changes are keyed by property path. ToSafeJson masks the value of any JSON key whose name looks sensitive, so the whole ApiToken change becomes "***". The log still shows that the token changed, but not its values.
Do not log PropertyChange.ToString() directly

PropertyChange.ToString() prints raw values, including the token above. Send changes through ToSafeJson, or filter them, before they reach a log. To mask more names, such as a salary, pass them: entry.ToSafeJson("Salary").

A settings loader with "did you mean"​

An application has default settings. Operators override some of them with a connection-string style environment variable. A typo in that variable should not be ignored silently: the loader reports it and suggests the closest known name.

SettingsLoader.cs
Dictionary<string, string> defaults = new Dictionary<string, string>
{
["timeout"] = "30",
["retries"] = "3",
["region"] = "eu-west-1",
["log-level"] = "Information",
};

// For example, the value of the APP_SETTINGS environment variable.
string fromEnvironment = "Timeout=10; log-level = Debug; regoin=me-south-1; colour=blue";

// Keys are lower-cased so they match the defaults.
Dictionary<string, string> overrides = fromEnvironment
.ParseKeyValuePairs()
.ToDictionary(pair => pair.Key.ToLowerInvariant(), pair => pair.Value);

List<string> problems = new List<string>();
foreach (string key in overrides.Keys.Where(key => !defaults.ContainsKey(key)).ToList())
{
string? suggestion = defaults.GetClosestKey(key);
problems.Add(suggestion is null
? $"Unknown setting '{key}'."
: $"Unknown setting '{key}'. Did you mean '{suggestion}'?");
overrides.Remove(key);
}

Dictionary<string, string> settings = defaults.Merge(overrides);
// problems → ["Unknown setting 'regoin'. Did you mean 'region'?", "Unknown setting 'colour'."]
// settings → { ["timeout"] = "10", ["retries"] = "3", ["region"] = "eu-west-1", ["log-level"] = "Debug" }

How it works:

  • ParseKeyValuePairs splits on ; and =, trims keys and values (log-level = Debug works), and skips empty segments.
  • GetClosestKey compares case-insensitively and returns null when no key reaches a similarity of 0.5. That is why colour gets no suggestion.
  • Merge returns a new dictionary in which the override wins on a shared key. Neither input changes.
Merge uses the default comparer

ParseKeyValuePairs returns case-insensitive keys, but Merge builds a new Dictionary<TKey, TValue> with the default comparer, which is case-sensitive for strings. Without the ToLowerInvariant() step, Timeout and timeout would become two separate entries. Normalize the keys first, as shown.

A CSV export​

An admin screen exports orders for a spreadsheet. Customer names are user input, so they can contain commas, quotes, or a formula that a spreadsheet would run when the file is opened.

OrderExport.cs
List<OrderRow> orders = new List<OrderRow>
{
new OrderRow("SO-1001", "Contoso, Ltd.", new DateTime(2026, 3, 1, 9, 15, 0), 1250.50m, "Paid"),
new OrderRow("SO-1002", "Fabrikam \"Retail\"", new DateTime(2026, 3, 2, 14, 0, 0), 89.99m, "Pending"),
new OrderRow("SO-1003", "=HYPERLINK(\"http://evil.example\")", new DateTime(2026, 3, 3, 8, 45, 0), 12m, "Paid"),
new OrderRow("SO-1004", "Northwind", new DateTime(2026, 3, 4, 17, 30, 0), -40m, "Refunded"),
};

string csv = orders.ToCsv();

// A UTF-8 byte-order mark helps Excel detect the encoding.
await File.WriteAllTextAsync("orders.csv", csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));

public sealed record OrderRow(string Number, string Customer, DateTime PlacedAt, decimal Total, string Status);
Output (orders.csv)
Number,Customer,PlacedAt,Total,Status
SO-1001,"Contoso, Ltd.",2026-03-01T09:15:00.0000000,1250.50,Paid
SO-1002,"Fabrikam ""Retail""",2026-03-02T14:00:00.0000000,89.99,Pending
SO-1003,"'=HYPERLINK(""http://evil.example"")",2026-03-03T08:45:00.0000000,12,Paid
SO-1004,Northwind,2026-03-04T17:30:00.0000000,-40,Refunded

How it works:

  • ToCsv writes a header row from the property names, then one row per item, with CRLF line breaks (RFC 4180).
  • A field with a comma or a quote is wrapped in quotes, and inner quotes are doubled.
  • The formula in SO-1003 gets a leading apostrophe, so a spreadsheet shows it as text instead of running it. The number -40 is not changed: only text is protected.
  • Numbers use the invariant culture and dates use ISO 8601, whatever the server's culture is.
Semicolon-separated files

In locales that use a comma as the decimal separator, Excel expects semicolons. Pass orders.ToCsv(separator: ';'). With ; as the separator, Contoso, Ltd. no longer needs quotes.

A latency report​

A nightly job summarizes the response times of a checkout endpoint. Two requests hung, and they hide what a normal request looks like. The report shows both views, and two small text charts for a chat message or a log.

LatencyReport.cs
// Response times of the last 20 requests, oldest first.
double[] latenciesMs =
{
112, 98, 105, 131, 120, 101, 97, 940, 118, 109,
104, 125, 99, 143, 110, 102, 115, 1210, 108, 121,
};

double[] typicalMs = latenciesMs.WithoutOutliers().ToArray(); // 940 and 1210 removed

string report = $"""
requests {latenciesMs.Length}
p50 {latenciesMs.Percentile(50):0.#} ms
p95 {latenciesMs.Percentile(95):0.#} ms
p99 {latenciesMs.Percentile(99):0.#} ms
outliers {latenciesMs.Length - typicalMs.Length}
p95 clean {typicalMs.Percentile(95):0.#} ms
trend {latenciesMs.ToSparkline()}
""";

string histogram = typicalMs.ToHistogram(buckets: 4, width: 20);
Output (report, then histogram)
requests 20
p50 111 ms
p95 953.5 ms
p99 1158.7 ms
outliers 2
p95 clean 132.8 ms
trend ▁▁▁▁▁▁▁▆▁▁▁▁▁▁▁▁▁█▁▁
97 – 108.5 ████████████████████ 8
108.5 – 120 ████████████▌ 5
120 – 131.5 ██████████ 4
131.5 – 143 ██▌ 1

How it works:

  • Percentile interpolates between neighbours, like Excel's PERCENTILE.INC. The median (p50) ignores the two slow requests, but p95 and p99 do not.
  • WithoutOutliers drops values outside Tukey's fences and keeps the order of the rest. The clean p95 (132.8 ms) shows normal behaviour; the raw p95 (953.5 ms) shows what some users felt. Report both.
  • ToSparkline scales the values between the smallest and the largest, so the two spikes are easy to spot in one line.
  • ToHistogram splits the clean values into four equal-width ranges and scales the bars to the fullest range.
Culture

The 0.# format strings use the current culture, so the decimal separator can be a comma. The charts themselves always use the invariant culture.

A webhook handler​

A payment provider calls your endpoint when an order is paid. It signs the raw body with a shared secret (HMAC-SHA256) and sends the signature in a header. The endpoint must reject any request whose signature is missing or wrong, before it trusts the body.

PaymentWebhook.cs
public sealed record PaymentSucceeded(string OrderNumber, decimal Amount);

public static class PaymentWebhook
{
public const string SignatureHeader = "X-Signature-256";

public static async Task<IResult> HandleAsync(
HttpRequest request, IConfiguration configuration, CancellationToken cancellationToken)
{
string secret = configuration["Payments:WebhookSecret"]
?? throw new InvalidOperationException("Payments:WebhookSecret is not configured.");

// Verify the exact bytes that were signed: read the raw body, not a re-serialized model.
string body = await request.Body.ReadAllTextAsync(cancellationToken: cancellationToken);
string? signature = request.Headers[SignatureHeader];

if (!body.VerifyHmacSha256(secret, signature))
{
return Results.Unauthorized();
}

PaymentSucceeded? payment = body.FromJson<PaymentSucceeded>();
if (payment is null || payment.OrderNumber.IsNullOrWhiteSpace())
{
return Results.BadRequest();
}

// Mark the order as paid here.
return Results.NoContent();
}
}

// Program.cs
app.MapPost("/webhooks/payments", PaymentWebhook.HandleAsync);

The provider computes the signature like this, which is also how a test can sign a request:

Signing a test request
const string Secret = "whsec_8d2f61c0";
string body = "{\"OrderNumber\":\"SO-1001\",\"Amount\":1250.50}";

string signature = $"sha256={body.ToHmacSha256(Secret)}";
// → "sha256=e29f1f8270cd4d52acba727f683738bf30011b504c705a15b870b6fd4aee0531"

string tampered = body.Replace("1250.50", "1.00");

body.VerifyHmacSha256(Secret, signature); // → true
body.VerifyHmacSha256(Secret, signature.ToUpperInvariant()); // → true
tampered.VerifyHmacSha256(Secret, signature); // → false
body.VerifyHmacSha256(Secret, null); // → false
body.VerifyHmacSha256(Secret, "not-a-signature"); // → false
body.VerifyHmacSha256("wrong-secret", signature); // → false

Posting these requests to the endpoint above returns 204 for the signed body, 401 for the tampered body (amount changed to 1.00), and 401 when the header is missing.

How it works:

  • ReadAllTextAsync reads the request body as text (UTF-8 unless a byte-order mark says otherwise).
  • VerifyHmacSha256 computes the HMAC of the body and compares it with the header in constant time, so response times do not reveal how much of a guess was right. It accepts hex in any case, Base64, and an optional sha256= prefix (GitHub style).
  • A null or malformed signature returns false instead of throwing, so one if covers every bad request.
  • FromJson runs only after the signature check.
Keep the secret out of code

Read the secret from configuration or a secret store, as shown. Never compare signatures with ==: that comparison stops at the first different character and leaks timing information.

A background loop on a timer​

A hosted service refreshes an exchange rate every 30 seconds. When the rate source fails, the service keeps the previous rate, logs a warning and tries again on the next tick. When the host stops, the loop ends without an exception.

ExchangeRateRefresher.cs
public interface IExchangeRateSource
{
Task<decimal> GetRateAsync(string from, string to, CancellationToken cancellationToken);
}

public sealed class RateSnapshot
{
public decimal UsdToJod { get; set; } = 0.709m;
}

public sealed class ExchangeRateRefresher : BackgroundService
{
private static readonly TimeSpan Interval = 30.Seconds();

private readonly IExchangeRateSource _source;
private readonly RateSnapshot _snapshot;
private readonly ILogger<ExchangeRateRefresher> _logger;

public ExchangeRateRefresher(IExchangeRateSource source, RateSnapshot snapshot, ILogger<ExchangeRateRefresher> logger)
{
_source = source;
_snapshot = snapshot;
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (long tick in Interval.Every(stoppingToken))
{
decimal previous = _snapshot.UsdToJod;
_snapshot.UsdToJod = await _source.GetRateAsync("USD", "JOD", stoppingToken).WithFallback(
previous,
5.Seconds(),
exception => _logger.LogWarning(
"Tick {Tick}: rate unavailable ({Reason}), keeping {Rate}", tick, exception.Message, previous));

_logger.LogInformation("Tick {Tick}: USD/JOD = {Rate}", tick, _snapshot.UsdToJod);
}

_logger.LogInformation("Rate refresher stopped");
}
}

// Program.cs
builder.Services.AddSingleton<IExchangeRateSource, HttpExchangeRateSource>(); // your implementation
builder.Services.AddSingleton<RateSnapshot>();
builder.Services.AddHostedService<ExchangeRateRefresher>();

With a source that returns 0.7091, then fails with a 503, then returns 0.7089, and the host stopped after the third tick, the log reads:

Output (log messages)
Information: Tick 1: USD/JOD = 0.7091
Warning: Tick 2: rate unavailable (503 Service Unavailable), keeping 0.7091
Information: Tick 2: USD/JOD = 0.7091
Information: Tick 3: USD/JOD = 0.7089
Information: Rate refresher stopped

How it works:

  • Every(cancellationToken) yields 1, 2, 3, ... each time the interval elapses. When stoppingToken is cancelled, the sequence ends quietly, so await foreach exits and the code after the loop runs.
  • On .NET 10 it uses PeriodicTimer, so the ticks do not drift and a slow loop body skips ticks rather than running several back to back. On .NET 8 and 9 (the netstandard2.1 build) it waits Task.Delay(interval) after each loop body, so the body's time adds to the gap.
  • WithFallback turns a failure or a call longer than 5 seconds into "keep the previous rate". The loop never sees an exception, so one bad call cannot stop the service.
The first tick comes after one interval

Every waits a full interval before the first tick. To load the rate at startup, call the source once before the loop.

Shutdown during a call

If the host stops while GetRateAsync is running, the call is cancelled and WithFallback reports the OperationCanceledException to the callback like any other failure. If that warning is noise for you, check stoppingToken.IsCancellationRequested in the callback before logging.

A paged list endpoint​

An API lists orders. The client chooses a status filter, a sort order and a page. The sort comes from the query string, so it must be limited to fields the client is allowed to sort by.

OrdersEndpoint.cs
public sealed record Customer(string Name, string Country);
public sealed record Order(int Id, string Status, decimal Total, DateTime CreatedAt, Customer Customer, string InternalNotes);
public sealed record OrderDto(int Id, string Status, decimal Total, string Customer);

public static class OrdersEndpoint
{
private static readonly string[] SortableFields = { "createdAt", "total", "customer.name" };

public static IResult List(IEnumerable<Order> orders, string? status, string? sort, int page, int pageSize)
{
try
{
Page<Order> result = orders
.WhereIf(status is not null, order => order.Status == status)
.SortBy(sort ?? "-createdAt", SortableFields)
.ToPage(page, pageSize);

Page<OrderDto> body = new Page<OrderDto>(
result.Items.Select(order => new OrderDto(order.Id, order.Status, order.Total, order.Customer.Name)).ToList(),
result.PageNumber,
result.PageSize,
result.TotalCount);

return Results.Ok(body);
}
catch (ArgumentException exception) when (exception.ParamName == "sort")
{
return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status400BadRequest, title: "Invalid sort");
}
}
}

// Program.cs (orders is an in-memory List<Order> here)
app.MapGet("/api/v1/orders", (string? status, string? sort, int? page, int? pageSize) =>
OrdersEndpoint.List(orders, status, sort, page ?? 1, pageSize ?? 20));

With five orders (ids 1 to 5, four of them Paid), these requests return:

Output
GET /api/v1/orders?status=Paid&sort=total%20desc&page=1&pageSize=2
200 {"items":[{"id":4,"status":"Paid","total":410,"customer":"Adventure Works"},
{"id":1,"status":"Paid","total":120,"customer":"Contoso"}],
"pageNumber":1,"pageSize":2,"totalCount":4,"totalPages":2,"hasPrevious":false,"hasNext":true}

GET /api/v1/orders?status=Paid&sort=customer.name&page=2&pageSize=2
200 {"items":[{"id":1,"status":"Paid","total":120,"customer":"Contoso"},
{"id":2,"status":"Paid","total":75,"customer":"Fabrikam"}],
"pageNumber":2,"pageSize":2,"totalCount":4,"totalPages":2,"hasPrevious":true,"hasNext":false}

GET /api/v1/orders?sort=internalNotes
400 {"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid sort","status":400,
"detail":"Cannot sort by 'internalNotes'. Allowed: createdAt, total, customer.name. (Parameter 'sort')"}

How it works:

  • WhereIf adds the filter only when status was given. Without a status, all five orders are counted.
  • SortBy with an allow-list accepts asc/desc or a leading -, and nested paths such as customer.name. A field outside the list, an unknown field or an unknown direction throws ArgumentException with ParamName "sort", which the endpoint turns into a 400 Problem Details response.
  • ToPage returns the items of one page with the totals the client needs (totalPages, hasPrevious, hasNext). A page past the end has no items but keeps the real total.
  • The endpoint maps Order to OrderDto, so InternalNotes is never sent and cannot be sorted on.
Entity Framework Core

Pass a DbSet<Order> instead of a list and the same chain runs in SQL: WhereIf, SortBy and ToPage all have IQueryable<T> overloads. See A list endpoint in one chain.

Allow-list entries are checked at once

Every entry in SortableFields is resolved when SortBy is called, even when the request has no sort. A misspelled entry fails on the first request instead of hiding until a client uses it. That error has ParamName "allowedProperties", so the when filter above lets it surface as a server error, which is what you want for a bug.

See also​

  • Quick start — the shortest path from install to a first call.
  • Tasks — timeouts, fallbacks, retries and other async helpers in detail.
  • Console output — every table and chart format used on this page.
  • Paging and sorting — the full sort syntax and the IQueryable<T> overloads.