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
| Method | What it does |
|---|---|
WithProgress, ToTable, ToTimeline, ToFileSize, ToQuantity, ToHumanString | A command-line tool that converts files, shows progress, and prints a summary table and a timeline. |
Retry.ExecuteAsync, WithTimeout, SingleFlight, WithFallback | An HTTP-style client that retries, times out, shares concurrent calls and falls back to a cached value. |
DeepClone, CompareProperties, ToSafeJson | An audit log entry that lists what changed and masks secrets. |
ParseKeyValuePairs, GetClosestKey, Merge | A settings loader with defaults, overrides and "did you mean" hints for typos. |
ToCsv | A CSV export that quotes correctly and blocks spreadsheet formula injection. |
Percentile, WithoutOutliers, ToSparkline, ToHistogram | A latency report with percentiles, outlier removal and text charts. |
ReadAllTextAsync, VerifyHmacSha256, ToHmacSha256, FromJson | A webhook endpoint that rejects requests with a missing or wrong signature. |
Every, WithFallback | A BackgroundService that refreshes a value on a timer and survives failures. |
WhereIf, SortBy with an allow-list, ToPage | An 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.
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);
[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:
WithProgresspairs each file with anEnumerationProgress.Percentcounts the files finished before the current one, so the last file shows 75%, not 100%.ToTablemakes one column per public property and right-aligns the numbers.ToTimelinehas aTimeSpanoverload 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,ToQuantityandToHumanStringturn raw numbers into words.
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.
// 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)
Rates unavailable: 503 Service Unavailable
How it works:
WithTimeoutlimits each attempt. A slow attempt fails withTimeoutException, andshouldRetrytreats that like a network error.Retry.ExecuteAsyncwaits 100 ms after the first failure and 200 ms after the second. After the last attempt it rethrows the failure unchanged. PassshouldRetryso a400 Bad Requestor a bug is not retried.SingleFlighton 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.WithFallbackcatches 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.
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.
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.
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);
{"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:
DeepClonegives an independent copy to edit, sobeforekeeps the original values, including the nestedAddressand theTagslist.ComparePropertieswalks nested objects and reports them with a dotted path (Address.City). It compares collections item by item.- The changes are keyed by property path.
ToSafeJsonmasks the value of any JSON key whose name looks sensitive, so the wholeApiTokenchange becomes"***". The log still shows that the token changed, but not its values.
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.
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:
ParseKeyValuePairssplits on;and=, trims keys and values (log-level = Debugworks), and skips empty segments.GetClosestKeycompares case-insensitively and returnsnullwhen no key reaches a similarity of 0.5. That is whycolourgets no suggestion.Mergereturns a new dictionary in which the override wins on a shared key. Neither input changes.
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.
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);
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:
ToCsvwrites 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-1003gets a leading apostrophe, so a spreadsheet shows it as text instead of running it. The number-40is not changed: only text is protected. - Numbers use the invariant culture and dates use ISO 8601, whatever the server's culture is.
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.
// 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);
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:
Percentileinterpolates between neighbours, like Excel'sPERCENTILE.INC. The median (p50) ignores the two slow requests, but p95 and p99 do not.WithoutOutliersdrops 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.ToSparklinescales the values between the smallest and the largest, so the two spikes are easy to spot in one line.ToHistogramsplits the clean values into four equal-width ranges and scales the bars to the fullest range.
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.
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:
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:
ReadAllTextAsyncreads the request body as text (UTF-8 unless a byte-order mark says otherwise).VerifyHmacSha256computes 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 optionalsha256=prefix (GitHub style).- A
nullor malformed signature returnsfalseinstead of throwing, so oneifcovers every bad request. FromJsonruns only after the signature check.
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.
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:
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. WhenstoppingTokenis cancelled, the sequence ends quietly, soawait foreachexits 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 waitsTask.Delay(interval)after each loop body, so the body's time adds to the gap. WithFallbackturns 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.
Every waits a full interval before the first tick. To load the rate at startup, call the source once before the loop.
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.
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:
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:
WhereIfadds the filter only whenstatuswas given. Without a status, all five orders are counted.SortBywith an allow-list acceptsasc/descor a leading-, and nested paths such ascustomer.name. A field outside the list, an unknown field or an unknown direction throwsArgumentExceptionwithParamName"sort", which the endpoint turns into a400Problem Details response.ToPagereturns 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
OrdertoOrderDto, soInternalNotesis never sent and cannot be sorted on.
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.
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.