Trellis.Core API Reference
Package: Trellis.Core
Namespace: Trellis
Purpose: Provides Trellis result, maybe, scalar-value, and transport-agnostic error primitives for railway-oriented application flows.
See also: trellis-api-cookbook.md — recipes using this package, trellis-api-http-abstractions.md, trellis-api-asp.md, trellis-api-primitives.md.
Use this file when
- You need exact signatures for
Result,Result<T>,Maybe<T>,Error,ITransportFault,Page<T>, DDD primitives, specifications, custom primitive base classes, or generated primitive JSON/tracing support. - You are composing domain/application flows and need the canonical ROP operation:
Bind,Map,Tap,Ensure,Combine,ParallelAsync,AsTask, orAsValueTask. - You are defining aggregates, entities, domain events, specifications, or source-generated
Required*<TSelf>value objects.
Error-handling philosophy
Trellis models expected failures as values, not exceptions. Anything a caller can reasonably anticipate — input validation, not-found, conflict, forbidden, optional absence — is returned as Result<T> (or modelled as Maybe<T>) and pattern-matched at the boundary. This keeps the railway intact and every failure path testable.
throw is not banned — it is reserved for the truly exceptional: a programming error, a "can't happen" invariant that was violated, or a startup/configuration/infrastructure fault the process cannot recover from. The rule is "never throw for an expected outcome," not "never throw at all."
| Situation | Do | Not |
|---|---|---|
| Expected domain failure (validation, not-found, conflict, forbidden) | Result.Fail<T>(new Error.X(...)) |
throw |
| Expected absence of a value | Maybe<T> |
null / throw |
| Truly exceptional / unrecoverable (bug, broken environment, violated precondition) | throw |
wrap a normal outcome in a Result just to avoid throwing |
| Internal "shouldn't happen" you still want to flow as a value | return new Error.Unexpected(code, faultId?) (renders 500 at the boundary, no exception) |
throw new Exception(...) inside a Result chain |
Analyzer TRLS010 flags throw inside Result chains (Bind/Map/Tap/Ensure); reading result.Error never throws. Never use try/catch in Domain or Application layers to drive an expected outcome — use Result.Try(...) only to convert a genuinely unexpected exception at an integration seam into a typed Error.
Patterns Index
Use this table before searching the long type catalog.
| Goal | Canonical API | See |
|---|---|---|
| Return success/failure without payload | Result.Ok() / Result.Fail(error) (returns Result<Unit>) |
Result |
| Return success/failure with payload | Result.Ok(value) / Result.Fail<T>(error) |
Result<TValue> |
| Fail but still persist staged work (worker pattern) | Result.FailAfterCommit<T>(error) / Result.FailAfterCommit(error) |
Result, IPersistOnFailure |
Classify an Error for a worker/consumer retry loop |
error.Classify() / error.IsTransient() / error.IsPermanent() / error.IsFailFast() / error.GetRetryAdvice() |
Retry classification |
| Turn a boolean guard into a result | Result.Ensure(condition, errorFactory) with fresh errors inside the factory; use error for a reused instance, or .Ensure(...) in a value-threaded chain |
Result, Ensure family |
Require nullable values without !, including nullable-returning queries |
Result.EnsureNotNull(value, fieldName, detail) for standard errors, errorFactory for custom failures, or task.EnsureNotNullAsync(...) |
Value-returning null guards, Nullable-task guards |
| Require nonblank text without trimming | value.EnsureNotNullOrWhiteSpace(fieldName, detail); use a factory for custom failures |
Required nonblank strings |
| Start independent async result-producing operations concurrently | Result.ParallelAsync(...), then combine the returned tasks |
Result |
| Combine multiple validated typed fields into a tuple | static Result.Combine<T1,T2>(Result<T1>, Result<T2>) or instance r1.Combine(r2), then .Map(...) |
Combine family |
| Combine multiple boolean guards | Result.Ensure(...).Combine(Result.Ensure(...)) then .Bind(...) (extension Combine aggregates errors and adds each value as the next tuple element; pass a Result<Unit> from a no-payload guard and ignore it with _ in the next lambda) |
Combine family |
| Adapt an already-computed result to async APIs | .AsTask() / .AsValueTask() |
ResultTaskAdapterExtensions |
| Model expected absence | Maybe<T>, Maybe.From(value), Maybe<T>.None |
Maybe<T> |
| Validate optional input, treating only null as absent | Maybe.Optional(value, function) |
Maybe |
| Treat null, empty, or whitespace-only optional text as absent before validation | Maybe.OptionalNonBlank(value, function) |
Maybe |
| Convert absence to a domain failure | maybe.ToResult(error) / maybe.ToResult(errorFactory) |
MaybeExtensions |
Migrate universal or nullable ToResult calls |
Use Result.Ok(value) for deliberate success wrapping, Result.EnsureNotNull(value, error) for required nullables, and task.EnsureNotNullAsync(error) for nullable tasks. Only Maybe<T> keeps Core's ToResult(error/factory) conversions. |
Choosing a Result entry point |
| Create HTTP-oriented domain errors | Closed Error cases plus ResourceRef.For<TResource>(id) |
Error, Error Cases |
| Validate pagination controls and return a page | PageRequest.TryCreate(cursor, limit) → typed Decode(codec) or EF ToPageAsync; PageBuilder.FromOverFetch(rows, size, row => codec.Encode(state)) or direct new Page<T>(...) for provider tokens |
Pagination |
| Model aggregates/entities/events | Aggregate<TId>, Entity<TId>, IDomainEvent |
Domain-Driven Design |
| Define a stable published integration contract | IIntegrationEvent |
IIntegrationEvent |
| Name an integration contract for the wire (cross-service) | [IntegrationEventName] |
IntegrationEventNameAttribute |
| Move reusable query predicates out of repositories | Specification<T> |
Specification<T> |
| Define custom required value objects | partial class X : RequiredString<X> / RequiredGuid<X> / other Required* bases |
Primitive value object base classes |
| Extract a success value or throw at a trust boundary (DTO → entity rehydration, JSON deserialization) | result.GetValueOrThrow($"context message") / GetValueOrThrowAsync(...) |
GetValueOrThrowExtensions (entry in the extension catalog), cookbook Recipe 30 |
Canonical async handler skeleton
Every async command/query handler that composes Trellis primitives follows the same await-then-chain shape. The sync verbs (Bind/Map/Ensure) extend Result<T> receivers with sync delegates only. The async verbs (BindAsync/MapAsync/EnsureAsync) extend Result<T>, Task<Result<T>>, and ValueTask<Result<T>> receivers; on a sync receiver they take an async delegate (Task<...> or ValueTask<...>), while on a Task/ValueTask receiver they additionally provide sync-delegate convenience overloads. A Task<Result<T>> is not a Result<T> and does not expose the sync extensions — calling .Bind(...) on a Task<Result<T>> fails with CS1929: 'Task<Result<T>>' does not contain a definition for 'Bind'.
// Generic handler — Task<Result<TOut>>
public async Task<Result<OrderResponse>> Handle(CreateDraftOrderCommand cmd, CancellationToken ct)
{
// 1. Sync precondition — produces a Result<Unit>, chains synchronously.
var preconditions = Result.Ensure(cmd.LineItems.Count > 0,
() => Error.InvalidInput.ForField(field: "lineItems", code: ValidationCodes.ValueNotEmpty, detail: "..."))
.Bind(_ => Result.Ensure(!cmd.HasDuplicates,
() => Error.InvalidInput.ForField(field: "lineItems", code: "line-items.duplicate-product", detail: "...")));
if (preconditions.IsFailure) return Result.Fail<OrderResponse>(preconditions.Error);
// 2. Async precondition — returns Task<Result<T>>; await BEFORE chaining sync extensions,
// or use the *Async siblings (BindAsync/EnsureAsync/MapAsync) which extend Task<Result<T>>.
return await LoadCustomerAsync(cmd.CustomerId, ct) // Task<Result<Customer>>
.EnsureAsync(c => c.IsActive,
c => new Error.Forbidden("customer.inactive",
ResourceRef.For<Customer>(c.Id))) // Task<Result<Customer>>
.BindAsync(c => LoadProductsAsync(cmd.LineItems, ct)) // Task<Result<IReadOnlyList<Product>>>
.MapAsync(products => Order.CreateDraft(cmd, products)) // Task<Result<Order>>
.MapAsync(order => OrderResponse.From(order)); // Task<Result<OrderResponse>>
}
// No-payload handler — Task<Result<Unit>>
public Task<Result<Unit>> Handle(CancelOrderCommand cmd, CancellationToken ct) =>
LoadOrderAsync(cmd.OrderId, ct) // Task<Result<Order>>
.BindAsync(order => order.Cancel()) // Task<Result<Unit>> (Cancel returns Result<Unit>)
.BindAsync(_ => _uow.SaveChangesResultAsync(ct)); // Task<Result<Unit>>
Common build failures and their fix:
| Diagnostic | What the model wrote | Fix |
|---|---|---|
CS1929: 'Task<Result<T>>' does not contain a definition for 'Bind' |
LoadAsync(...).Bind(x => ...) |
Use BindAsync (which extends Task<Result<T>>), or await first then .Bind(...) |
CS0411: type arguments for 'Map<TOut>' cannot be inferred |
someTaskResult.Map(...) after a CheckAsync whose result type can't be inferred |
await the precondition into a concrete Result<T> before projecting; or use MapAsync<TOut>(...) |
Common traps
- Do not use throwing value access in production code. Prefer
TryGetValue,Match,Bind,Map, or deconstruction guarded by the success flag. - Do not use
default(Result<T>)as success. The default state is a typednew Error.Unexpected("default-initialized")failure. - For no-payload success, use
Result.Ok()(which returnsResult<Unit>). TheTrellis.Unittype is the canonical "no value" payload — it is a publicreadonly record structwith a single value (Unit.Default). - Use
ParallelAsynconly for independent work. If operation B depends on operation A, compose withBind/BindAsyncinstead.
First-30-minutes surprises
| Surprise | What it actually is |
|---|---|
Result<T>.Value getter does not exist (CS1061) |
Removed from the current API because it was the primary cause of unsafe value access. Extract via TryGetValue(out var v), Match(...), deconstruction var (ok, v, err) = result;, or chain with Bind/Map. Maybe<T>.Value does exist but is hidden from IntelliSense and gated by analyzer TRLS003 — guard with HasValue/TryGetValue/Match/GetValueOrDefault. The two types do not have symmetric value-access ergonomics. |
Implicit T → Result<T> and Error → Result<T> were removed (CS0029) |
Use the explicit factory: return Result.Ok(value); and return Result.Fail<T>(error);. The C# compiler flags every site with CS0029: cannot implicitly convert type 'T' to 'Result<T>'. |
Aggregate<TId> / Entity<TId> already provide CreatedAt and LastModified (CS0108) |
Both are DateTimeOffset (not DateTime) and infrastructure-managed by Trellis EF Core. Defining your own public DateTime CreatedAt { ... } on the aggregate triggers CS0108: 'X.CreatedAt' hides inherited member 'Entity<TId>.CreatedAt'. If your spec calls for an audit timestamp, use the inherited base property instead of declaring a new one. |
Result<T> has .Error (nullable, never throws) but not .Value |
result.Error returns Error? (null on success). result.TryGetError(out var err) is the safe Boolean form. Reading the success value still requires TryGetValue/Match/destructuring. |
Migration from FunctionalDDD
Read the separate migration reference when moving a FunctionalDDD application to Trellis. It compares the released predecessor with current APIs, not intermediate Trellis alpha builds, and is not needed for new Trellis code.
Types
public interface IResult
Base success/failure contract.
Properties
| Name | Type | Notes |
|---|---|---|
IsSuccess |
bool |
true for success results. Marked [MemberNotNullWhen(false, nameof(Error))]. |
IsFailure |
bool |
true for failure results. Marked [MemberNotNullWhen(true, nameof(Error))]. |
Error |
Error? |
null on success; never throws. |
Methods
| Signature | Notes |
|---|---|
bool TryGetError(out Error? error) |
Non-throwing failure extractor. [NotNullWhen(true)] on the out parameter. |
Factory Methods
None.
public interface IResult<TValue> : IResult
Typed success/failure contract. Note: there is no Value property — the previous Value getter threw on failure and was the leading source of TRLS003. Use TryGetValue to extract the success payload.
Properties
None (inherits IsSuccess, IsFailure, Error from IResult).
Methods
| Signature | Notes |
|---|---|
bool TryGetValue([MaybeNullWhen(false)] out TValue value) |
Non-throwing success extractor. Returns true and binds value on success; returns false and leaves value at default on failure. |
Factory Methods
None.
public interface IFailureFactory<TSelf> where TSelf : IFailureFactory<TSelf>
Static factory contract for producing a failure instance of the implementing type.
Properties
None.
Methods
| Signature | Notes |
|---|---|
static abstract TSelf CreateFailure(Error error) |
Used by generic pipeline code |
Factory Methods
CreateFailure(Error error).
public static partial class Result
Static factory and helper surface for Result<TValue>. There is no non-generic instance Result type — for no-payload success/failure, use Result<Unit> (returned by the parameterless overloads listed below).
Trellis.Unit is a public readonly record struct with a single value, Unit.Default.
Qualify it when importing both Trellis and Mediator, which also declares a Unit type.
Default-state invariant.
default(Result<T>)represents a failure carrying the sharednew Error.Unexpected("default-initialized")sentinel — not success. This makes uninitialized state a typed failure rather than a silent success that would hide a programming error. Always construct viaResult.Ok(...)orResult.Fail(error). AnalyzerTRLS019flags explicitdefault(Result<T>)at call sites.
Result is public static partial class Result. It hosts the static factory and helper methods used to build every Result<TValue>.
Static factory methods
| Signature | Notes |
|---|---|
public static Result<TValue> Ok<TValue>(TValue value) |
Success factory |
public static Result<Unit> Ok() |
Success without payload (returns Result<Unit>) |
public static Result<TValue> Fail<TValue>(Error error) |
Failure factory |
public static Result<Unit> Fail(Error error) |
Failure without payload (returns Result<Unit>) |
public static Result<TValue> FailAfterCommit<TValue>(Error error) |
Persist-on-failure factory. Still a failure (IsFailure == true), but sets IPersistOnFailure.PersistOnFailure so TransactionalCommandBehavior (and any other opt-in pipeline behavior) commits staged changes alongside the failure. Canonical use: worker handler that converts a transient external-service rejection into a persisted permanently_failed row. Throws ArgumentNullException on null error. |
nullandResult.Ok.Ok<TValue>(TValue value)is unconstrained and performs no null check — at runtime anullargument yields a successful result whose value isnull(IsSuccess == true,TryGetValuereturnstruewith anullout-value). Nullable-reference annotations catch only the obvious mistake: a literalnullagainst a non-nullableT(e.g.Result.Ok<string>(null)) raises the nullable-reference warningCS8625— a build error only where warnings are promoted (TreatWarningsAsErrors, as this repo sets). They do not stop the common case — passing an already-nullable value, where inference widensTValueto the nullable type and the null success compiles silently. A success that wrapsnullis almost never intended: model optionality withMaybe<T>(absence is data) and a missing-but-required value withResult.Fail(Error.NotFound.For<T>(id: id)), notResult.Ok(null). |public static Result<Unit> FailAfterCommit(Error error)| No-payload persist-on-failure factory (returnsResult<Unit>). | |public static Result<Unit> Ensure(bool flag, Error error)| Converts a boolean toResult<Unit>| |public static Result<Unit> Ensure(bool flag, Func<Error> errorFactory)| Boolean guard with a lazy error factory; skips the factory on success, invokes it exactly once on failure. | |public static Result<Unit> Ensure(Func<bool> predicate, Error error)| Deferred predicate version | |public static Result<Unit> Ensure(Func<bool> predicate, Func<Error> errorFactory)| Evaluates the predicate once, then creates the error only when it returns false. | |public static Task<Result<Unit>> EnsureAsync(Func<Task<bool>> predicate, Error error)| Async predicate version | |public static Task<Result<Unit>> EnsureAsync(Func<Task<bool>> predicate, Func<Error> errorFactory)| Awaits the predicate once; invokes the synchronous error factory only after a false result. | |public static Result<T> EnsureNotNull<T>(T? value, Error error) where T : class| Carries the same non-null reference; null produces the supplied failure. | |public static Result<T> EnsureNotNull<T>(T? value, Func<Error> errorFactory) where T : class| Reference guard with lazy error creation. | |public static Result<T> EnsureNotNull<T>(T? value, string? fieldName, string? detail = null) where T : class| Reference guard; createsError.InvalidInput.Required(fieldName, detail)only when null. | |public static Result<T> EnsureNotNull<T>(T? value, Error error) where T : struct| UnwrapsNullable<T>; null produces the supplied failure. | |public static Result<T> EnsureNotNull<T>(T? value, Func<Error> errorFactory) where T : struct| Nullable-struct guard with lazy error creation. | |public static Result<T> EnsureNotNull<T>(T? value, string? fieldName, string? detail = null) where T : struct| Nullable-struct guard; createsError.InvalidInput.Required(fieldName, detail)only when null. | |public static Result<T> Try<T>(Func<T> func, Func<Exception, Error>? map = null)| Converts thrown exceptions to failures | |public static Task<Result<T>> TryAsync<T>(Func<Task<T>> func, Func<Exception, Error>? map = null)| Async exception capture | |public static Result<Unit> Try(Action work, Func<Exception, Error>? map = null)| No-payload exception capture (returnsResult<Unit>) | |public static Task<Result<Unit>> TryAsync(Func<Task> work, Func<Exception, Error>? map = null)| Async no-payload exception capture | |public static Result<(T1, T2)> Combine<T1, T2>(Result<T1> r1, Result<T2> r2)| Combines two results; passing aResult<Unit>addsUnitas the next tuple element | |public static Result<(T1, ..., T9)> Combine<...>(...)| Additional generated arities up to 9 | |public static (Task<Result<T1>>, ..., Task<Result<T9>>) ParallelAsync<...>(...)| Starts async result-producing operations in parallel, arities 2-9 |
The default exception mapper produces new Error.Unexpected("unhandled-exception", Guid.NewGuid().ToString("N")) { Detail = "An unexpected error occurred while processing the request." }. It never copies Exception.Message into public Detail; log exception details at the call site or provide a custom mapper with safe, domain-specific text. OperationCanceledException is always rethrown rather than mapped.
Factory Methods
Ok, Fail, FailAfterCommit, Ensure, EnsureNotNull, Try, TryAsync, Combine, and ParallelAsync. For FunctionalDDD factory and accessor changes, read the migration guide.
Value-returning null guards
Prefer static Result.EnsureNotNull(value, fieldName, detail) for ordinary required fields,
or Result.EnsureNotNull(value, errorFactory) with error construction inside the
callback for a custom failure. Use an eager error when reusing an existing instance.
Use these guards for a missing-but-required value, rather
than Result.Ensure(value is not null, error) followed by value!. Success carries the
identical reference or the unwrapped struct, so typed Combine/Map needs no null
suppression and still accumulates every missing field. These are null-only guards:
empty/whitespace strings, zero, false, and default dates succeed. They do not trim,
parse, or revalidate a value object.
Func<Error> is required even on success, but runs exactly once only when null. Its
exceptions propagate; a factory returning null on failure throws ArgumentNullException
(parameter error), as does a null eager error when the value is missing. An eager null
error is unused on success. Eager overloads have higher overload-resolution priority:
a bare null second argument binds to Error; use errorFactory: or fieldName: to
disambiguate intentional null arguments to other forms.
The field/detail forms create neither errors nor violations on success (no validation
metric), and normalize the field only on failure. Each missing value creates exactly
one ValidationCodes.ValueNotNull violation. A malformed full JSON Pointer therefore
throws ArgumentException only when the value is missing; a present value does not
validate the field. Null/empty field names target the root. All six guards emit an EnsureNotNull
activity with the normal success/failure status and never set persist-on-failure.
There is no value.EnsureNotNull(...) extension on arbitrary values; the existing
Result<T?>.EnsureNotNull(error) remains a separate receiver-based operation.
Ordinary inferred calls with a non-nullable struct, including Maybe<T> and
Result<T>, are compile errors for every error/factory/field form, sync or async.
The nullable-struct overload requires nullable input; do not use a null guard to
inspect presence or success inside these wrappers.
string? name = "Ada";
int? quantity = 0;
Result<string> label = Result.EnsureNotNull(name, "name", "Name is required.")
.Combine(Result.EnsureNotNull(quantity, "quantity", "Quantity is required."))
.Map((name, quantity) => $"{name}:{quantity}");
For Task<T?> / ValueTask<T?>, use the twelve
EnsureNotNullAsync overloads.
public interface IPersistOnFailure
Opt-in marker carried by result types whose failure outcome should still trigger any post-handler persistence step (notably TransactionalCommandBehavior's commit). The canonical producer is Result.FailAfterCommit<T>(error); the canonical consumer is TransactionalCommandBehavior, which checks result is IPersistOnFailure { PersistOnFailure: true } to decide whether to commit a failed handler outcome.
Per-instance, not type-level.
Result<T>implementsIPersistOnFailureunconditionally — the same struct represents both ordinary failures and persist-on-failure failures, and the per-instancePersistOnFailureproperty discriminates. A type-onlyis IPersistOnFailurecheck is therefore insufficient and would incorrectly include ordinaryResult.Fail<T>(error)values; consumers must use the property-bound patternresult is IPersistOnFailure { PersistOnFailure: true }.
Properties
| Name | Type | Notes |
|---|---|---|
PersistOnFailure |
bool |
true for instances created via Result.FailAfterCommit<T>(error) / Result.FailAfterCommit(error), and for failures projected or aggregated from such an instance by railway operators (e.g. Map, Bind, Combine). The flag is sticky once propagated. Result.Ok(...), Result.Fail(...), and default(Result<T>) all return false. |
Pipeline composition
| Behavior | Persist-on-failure handling |
|---|---|
TransactionalCommandBehavior (Trellis.Mediator) |
Commits staged changes on success or persist-on-failure. Commit error on a persist-on-failure outcome replaces the handler error in the returned response. |
DomainEventDispatchBehavior (Trellis.Mediator) |
Treats persist-on-failure as failure: events are not dispatched. Events the handler raised on aggregates remain on those in-memory instances and are discarded with the request scope — they are not a durable retry buffer. Model post-failure side effects via an outbox row or a dedicated follow-up command. |
Anti-pattern — composing FailAfterCommit with aggregating operators
Result.FailAfterCommit<T>(error) is a leaf worker-handler operation: it converts a single aggregate's transient external rejection into a persisted permanently_failed state and returns. Threading that result through Combine / TraverseAll / SequenceAll / WhenAllAsync OR-accumulates the PersistOnFailure flag onto the aggregated failure — TransactionalCommandBehavior then commits the staged permanent-failure mutation alongside whatever the other legs produced, which is almost never what the handler author intended.
Restructure such handlers so the aggregating step runs to its terminal outcome first and FailAfterCommit is invoked at the end (or in a follow-up command), never as a leg inside a multi-aggregate composition. See trellis-api-anti-patterns.md for the WRONG / FIX gallery entry.
public readonly struct Result<TValue> : IResult<TValue>, IEquatable<Result<TValue>>, IFailureFactory<Result<TValue>>, IPersistOnFailure
Represents either a successful TValue or a failure Error.
Default-state invariant.
default(Result<T>)represents a failure carrying the sharednew Error.Unexpected("default-initialized")sentinel — not success withdefault(T). All failure-facing APIs (Error,TryGetError,Deconstruct,Equals,GetHashCode,ToString,AsUnit) route through this sentinel so thatdefault(Result<T>)is observationally equivalent toResult.Fail<T>(new Error.Unexpected("default-initialized")). Always construct viaResult.Ok(value)orResult.Fail<T>(error). AnalyzerTRLS019flags explicitdefault(Result<T>)at call sites.
JSON serialization fails fast.
Result<T>(and theIResult/IResult<T>interfaces) carry a default[JsonConverter(typeof(ResultRequiresExplicitHttpMappingConverter))]that throwsNotSupportedExceptionon any directJsonSerializer.Serialize/Deserializecall. The intended pattern is to call.ToHttpResponse()fromTrellis.Aspon the result before it reaches STJ (the resultingMicrosoft.AspNetCore.Http.IResultwrites the body itself; the struct is never serialized), or to unwrap the value viaMatch/TryGetValuefor non-HTTP contexts. Consumers who genuinely need a raw JSON dump (logging, IPC, storage) can register a converter (or aJsonConverterFactory) inJsonSerializerOptions.Converters— option-registered converters take precedence over the type-level[JsonConverter]attribute. The override must match the declared static type: aJsonConverter<Result<T>>covers onlyResult<T>-declared values;IResult<T>-declared values needJsonConverter<IResult<T>>;IResult-declared values needJsonConverter<IResult>. Use aJsonConverterFactoryif you need to cover multiple result shapes at once.
No
Valueproperty. The throwingpublic TValue Valuegetter was removed. UseTryGetValue,Match, orDeconstructto extract success values.
Properties
| Name | Type | Notes |
|---|---|---|
Error |
Error? |
null on success; never throws. Pattern-match on the value (e.g. if (result.Error is { } error)) for imperative branches. For default(Result<T>), returns the shared new Error.Unexpected("default-initialized") sentinel. |
IsSuccess |
bool |
Success flag. [MemberNotNullWhen(false, nameof(Error))]. |
IsFailure |
bool |
Failure flag. [MemberNotNullWhen(true, nameof(Error))]. default(Result<T>).IsFailure is true. |
Methods
| Signature | Notes |
|---|---|
public static Result<TValue> CreateFailure(Error error) |
Implements IFailureFactory<Result<TValue>>; lets generic pipeline behaviors construct failures polymorphically. Equivalent to Result.Fail<TValue>(error). |
public bool TryGetValue([MaybeNullWhen(false)] out TValue value) |
Non-throwing success extractor. [MemberNotNullWhen(false, nameof(Error))]. |
public bool TryGetValue([MaybeNullWhen(false)] out TValue value, [NotNullWhen(false)] out Error? error) |
Combined extractor — binds both value (on success) and error (on failure) in one call, eliminating the need for result.Error! after a failed single-out TryGetValue. |
public bool TryGetError([NotNullWhen(true)] out Error? error) |
Non-throwing failure extractor; on default(Result<T>) returns true with the Error.Unexpected sentinel. |
public void Deconstruct(out bool isSuccess, out TValue? value, out Error? error) |
Deconstruction support: var (ok, value, error) = result;. |
public Result<Unit> AsUnit() |
Discards the success value, returning a Result<Unit>. On a default-initialized failure, returns an explicit Result.Fail(sentinel) (never another default). Preserves the persist-on-failure flag when the source was created via Result.FailAfterCommit<T>(error). |
public bool Equals(Result<TValue> other) |
Value equality. Equal if both are success with EqualityComparer<TValue>.Default.Equals over the values, or both are failure with equal Error and equal persist-on-failure intent. Default-initialized failures route through the shared sentinel; an ordinary Result.Fail(error) and a Result.FailAfterCommit(error) with the same Error are not equal. |
public override bool Equals(object? obj) |
Object equality. |
public override int GetHashCode() |
Hash code matching Equals. |
public override string ToString() |
"Success({value})" or "Failure({Code}: {Detail})". |
Operators
The implicit conversion operators (TValue → Result<TValue>, Error → Result<TValue>) were removed from the current API. Use Result.Ok(value) / Result.Fail<T>(error).
| Signature | Notes |
|---|---|
public static bool operator ==(Result<TValue> left, Result<TValue> right) |
Equality |
public static bool operator !=(Result<TValue> left, Result<TValue> right) |
Inequality |
Factory Methods
Use the static Result type.
public static class Maybe
Non-generic helpers for creating Maybe<T> and optional result flows.
Factory naming. The factory is
Maybe.From(value)/Maybe<T>.From(value)— there is noSomefactory. WritingMaybe.Some(...)(a common habit from other option types) raisesCS0117. UseFromfor presence andMaybe<T>.Nonefor absence.
Properties
None.
Methods
| Signature | Notes |
|---|---|
public static Maybe<T> From<T>(T? value) where T : notnull |
Wraps nullable input |
public static Result<Maybe<TOut>> Optional<TIn, TOut>(TIn? value, Func<TIn, Result<TOut>> function) where TIn : class where TOut : notnull |
Runs function for every non-null reference, including empty or whitespace-only strings |
public static Result<Maybe<TOut>> Optional<TIn, TOut>(TIn? value, Func<TIn, Result<TOut>> function) where TIn : struct where TOut : notnull |
Value-type overload |
public static Result<Maybe<TOut>> OptionalNonBlank<TOut>(string? value, Func<string, Result<TOut>> function) where TOut : notnull |
Opt-in string adapter: null, empty, or whitespace-only input becomes successful Maybe<TOut>.None without invoking the function; nonblank input is passed unchanged exactly once |
OptionalNonBlank uses string.IsNullOrWhiteSpace, including .NET's Unicode whitespace
classification. It does not trim nonblank input: normalization remains the supplied
factory's responsibility. The factory can return either a reference or value type, and its
failures retain the original error and persist-on-failure intent. Factory exceptions propagate.
A null function throws ArgumentNullException even for absent input, matching Optional.
Existing Optional and From semantics are unchanged; this policy is never applied
automatically to required strings, JSON binding, or query parameters.
string? input = " ";
Result<Maybe<string>> label = Maybe.OptionalNonBlank(input, value => Result.Ok(value));
// Success with None; the factory is not invoked.
Factory Methods
From, Optional, and OptionalNonBlank.
public static class MaybeInvariant
Multi-field validation helpers for Maybe<T> values. Each method returns Result<Unit> — success when the invariant holds, or an Error.InvalidInput whose Fields list carries one FieldViolation per offending field. Field paths are normalized via InputPointer.ForProperty(name) (RFC 6901 JSON Pointer).
Methods
| Signature | Notes |
|---|---|
public static Result<Unit> AllOrNone<T1, T2>(Maybe<T1> first, Maybe<T2> second, string firstFieldName, string secondFieldName) |
All fields present or all absent. Arities 2, 3, 4. |
public static Result<Unit> Requires<T1, T2>(Maybe<T1> source, Maybe<T2> required, string sourceFieldName, string requiredFieldName) |
If source is present, required must be too. Arity 2. |
public static Result<Unit> MutuallyExclusive<T1, T2>(Maybe<T1> first, Maybe<T2> second, string firstFieldName, string secondFieldName) |
At most one field may be present. Arities 2, 3, 4. |
public static Result<Unit> ExactlyOne<T1, T2>(Maybe<T1> first, Maybe<T2> second, string firstFieldName, string secondFieldName) |
Exactly one field must be present. Arities 2, 3, 4. |
public static Result<Unit> AtLeastOne<T1, T2>(Maybe<T1> first, Maybe<T2> second, string firstFieldName, string secondFieldName) |
At least one field must be present. Arities 2, 3, 4. |
Usage
// All-or-none: street + city must both be provided or both omitted
MaybeInvariant.AllOrNone(command.Street, command.City, "street", "city");
// Requires: if discount is given, reason is required
MaybeInvariant.Requires(command.Discount, command.DiscountReason, "discount", "discountReason");
// ExactlyOne: must provide either email or phone
MaybeInvariant.ExactlyOne(command.Email, command.Phone, "email", "phone");
public readonly struct Maybe<T> where T : notnull
Optional value container for domain optionality.
Default-state invariant.
default(Maybe<T>)equalsMaybe<T>.None(the type already uses an_isValueSetdiscriminator). Although correct, prefer the explicitMaybe<T>.Nonefor readability. AnalyzerTRLS019flags explicitdefault(Maybe<T>)at call sites and recommendsMaybe<T>.Noneinstead.
Properties
| Name | Type | Notes |
|---|---|---|
None |
Maybe<T> |
Static empty instance |
Value |
T |
Throws when HasNoValue is true |
HasValue |
bool |
Present flag |
HasNoValue |
bool |
Empty flag |
Methods
| Signature | Notes |
|---|---|
public static Maybe<T> From(T? value) |
Static constructor |
public T GetValueOrThrow(string? errorMessage = null) |
Throwing extractor |
public T GetValueOrDefault(T defaultValue) |
Fallback extractor |
public T GetValueOrDefault(Func<T> defaultFactory) |
Deferred fallback |
public bool TryGetValue(out T value) |
Non-throwing extractor |
public Maybe<TResult> Map<TResult>(Func<T, TResult> selector) where TResult : notnull |
Maps present value. A selector that returns null collapses to None (Maybe never holds null); the notnull constraint discourages this at compile time but cannot fully enforce it for nullable reference types. |
public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none) |
Branches on presence |
public Maybe<TResult> Bind<TResult>(Func<T, Maybe<TResult>> selector) where TResult : notnull |
Flat-map |
public Maybe<T> Or(T fallback) |
Fallback value |
public Maybe<T> Or(Func<T> fallbackFactory) |
Deferred fallback value |
public Maybe<T> Or(Maybe<T> fallback) |
Fallback maybe |
public Maybe<T> Or(Func<Maybe<T>> fallbackFactory) |
Deferred fallback maybe |
public Maybe<T> Where(Func<T, bool> predicate) |
Keeps value only when predicate passes |
public bool HasValueWhere(Func<T, bool> predicate) |
HasValue && predicate(Value). The predicate is not invoked when this instance is None. MaybeQueryInterceptor in Trellis.EntityFrameworkCore rewrites this to EF.Property<T?>(entity, "_field") != null AND predicate-body for inline expression-bodied lambdas, so the same shape translates to SQL. Method-group conversions and captured Func<T,bool> variables are not translatable — only inline lambdas. |
public Maybe<T> Tap(Action<T> action) |
Side effect on value |
public override bool Equals(object? obj) |
Equality |
public bool Equals(Maybe<T> other) |
Equality |
public bool Equals(T? other) |
Equality against raw value. Maybe<T>.None.Equals((T?)null) returns true — the absence of a value converges with the canonical null sentinel; use HasValue / HasNoValue if the distinction matters. |
public override int GetHashCode() |
Hash code |
public override string ToString() |
Debug string |
Operators
| Signature | Notes |
|---|---|
public static implicit operator Maybe<T>(T value) |
Implicit success-like wrap |
public static bool operator ==(Maybe<T> maybe, T value) |
Equality |
public static bool operator !=(Maybe<T> maybe, T value) |
Inequality |
public static bool operator ==(Maybe<T> first, Maybe<T> second) |
Equality |
public static bool operator !=(Maybe<T> first, Maybe<T> second) |
Inequality |
Removed. The
(Maybe<T>, object?)==/!=overloads were removed because they silently absorbed cross-type comparisons (e.g.Maybe<int> count = 5; count == "five"previously compiled and always returnedfalse). For boxed comparisons use theEquals(object?)instance method:maybe.Equals(boxedValue).
Factory Methods
None and From.
public interface IScalarValue<TSelf, TPrimitive> where TSelf : IScalarValue<TSelf, TPrimitive> where TPrimitive : IComparable
Contract for scalar value objects that validate and expose a primitive payload.
Properties
| Name | Type | Notes |
|---|---|---|
Value |
TPrimitive |
Wrapped primitive |
Methods
| Signature | Notes |
|---|---|
static abstract Result<TSelf> TryCreate(TPrimitive value, string? fieldName = null) |
Primitive-based validation entry point |
static abstract Result<TSelf> TryCreate(string? value, string? fieldName = null) |
String-based validation entry point |
static virtual TSelf Create(TPrimitive value) |
Throws on validation failure. Generic-constraint dispatch: invoked from generic code via T.Create(value) where T : IScalarValue<T, P>. Concrete-class call sites (e.g. EmailAddress.Create("…")) bind to ScalarValueObject<TSelf, T>.Create(T) instead — the static-virtual default does not participate in concrete-type lookup. |
Factory Methods
TryCreate and Create.
public interface IFormattableScalarValue<TSelf, TPrimitive> : IScalarValue<TSelf, TPrimitive> where TSelf : IFormattableScalarValue<TSelf, TPrimitive> where TPrimitive : IComparable
Extends IScalarValue for culture-aware string parsing.
Properties
Inherited only.
Methods
| Signature | Notes |
|---|---|
static abstract Result<TSelf> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null) |
Culture-aware parse-and-validate |
Factory Methods
TryCreate(string?, IFormatProvider?, string?).
public abstract record Error
Closed discriminated union of domain error values. Each case is a nested sealed record carrying a typed payload. The base record has a private constructor — only the cases declared in Error.cs may inherit, which makes switch over an Error reference exhaustive at the language level.
Properties
| Name | Type | Notes |
|---|---|---|
Kind |
string |
Stable domain slug (e.g. "not-found", "invalid-input"). Survives CLR renames. Suitable for telemetry. Wire-format mapping for HTTP problem-details type is the boundary's responsibility — see trellis-api-asp.md. |
Code |
string |
The machine-readable reason a consumer sees — in an HTTP body, a span tag, a metric dimension, a log field. Storage on the base with an init accessor, so every case names a reason the same way: new Error.NotFound(resource) { Code = "account.closed" }. Defaults to the sentinel error.unspecified, never to Kind. Cases whose reason is required (Conflict, InvariantViolation, Unexpected, Forbidden) take it as a positional parameter instead, so the compiler refuses one that says nothing. There is deliberately no second raw-versus-wire code member: two of them is how an HTTP body and a span tag come to disagree about the same failure, which is a bug this framework has already shipped once. ResponseFailureWriter and Trellis.Mediator.TracingBehavior both read this one property. |
Detail |
string? |
Human-readable detail. Init-only (Detail = "..."). Boundary renderers prefer it when non-null; otherwise they compute a message from Kind/Code plus the typed payload. |
Cause |
Error? |
Structured cause chain. Never holds a live System.Exception — wrap context as a child Error. Cycles are detected at init and throw InvalidOperationException. |
Methods
| Signature | Notes |
|---|---|
public string GetDisplayMessage() |
Computes the rendered detail. Returns Detail when non-null; otherwise composes from Kind/Code and the typed payload. For an InvalidInput carrying a single FieldViolation, returns just that violation's Detail. |
public override bool Equals(object? obj) / Equals(Error? other) |
Value equality over discriminator + typed payload + Detail. Cause is excluded so two errors with identical surface payload compare equal regardless of how deeply they were wrapped (mirrors System.Exception precedent). Collection-bearing payloads use EquatableArray<T> for sequence equality. |
public override int GetHashCode() |
Hash matches Equals. |
Construction and case-scoped factories
Construct cases directly or use their case-scoped static factories. The base Error has no static Error.Validation(...) / Error.NotFound(...) helpers; every call site names the case it produces.
One ordering rule: code, subject, metadata, detail. Every factory parameter that carries a reason is named code and comes first. Constructors whose code is required also lead with Code. Use named id: when omitting the optional code on NotFound or Gone; do not invent a reason just to populate that argument.
| Case(s) | Exact factory signature(s), returning the named case |
|---|---|
InvalidInput |
ForField(string code, string? field, ImmutableDictionary<string, ValidationArgValue>? args = null, string? detail = null) |
InvalidInput |
ForField(string code, InputPointer field, ImmutableDictionary<string, ValidationArgValue>? args = null, string? detail = null) |
InvalidInput |
Required(string? fieldName, string? detail = null) |
InvalidInput |
Required(InputPointer field, string? detail = null) |
InvalidInput |
ForRule(string code, IReadOnlyList<InputPointer>? fields = null, ImmutableDictionary<string, ValidationArgValue>? args = null, string? detail = null) |
Conflict, InvariantViolation, Forbidden |
For<TResource>(string code, object? id = null, string? detail = null) and For(string code, ResourceRef resource, string? detail = null) |
Conflict, InvariantViolation |
ForReason(string code, string? detail = null) |
Forbidden |
ForPolicy(string code, string? detail = null) |
NotFound, Gone |
For<TResource>(string? code = null, object? id = null, string? detail = null) |
NotFound, Gone |
For(string? code, ResourceRef resource, string? detail = null) and For(ResourceRef resource, string? detail = null) |
Required codes reject null (ArgumentNullException), empty, or whitespace (ArgumentException) at construction, including direct case constructors and FieldViolation / RuleViolation. Assignments to Error.Code, FieldViolation.ReasonCode, or RuleViolation.ReasonCode, including with expressions, enforce the same invariant. This is invalid API usage, not a domain validation failure. Nonblank application codes pass through unchanged; no vocabulary membership or trimming is imposed. NotFound and Gone factories keep optional-code normalization: omission, null, empty, or whitespace means ValidationCodes.Unspecified.
ForField converts a string field with InputPointer.ForProperty; null/empty targets the root. The pointer overload preserves its path and input location. ForRule defensively copies related fields in order; null/empty means no associated fields. Both preserve args and violation detail; ForRule also sets the root Detail. The root InvalidInput.Code remains the unspecified sentinel because reasons belong to its violations. Validated violation construction records one metric; with copies do not recount, and invalid constructor codes are rejected before recording a metric.
Error.InvalidInput.Required(fieldName, detail) is exactly equivalent to
ForField(ValidationCodes.ValueNotNull, fieldName, detail: detail): one field violation,
no rules or args, unspecified root code, and detail on the violation, not the root.
No default human-readable detail is invented. Use it for conditional/cross-field
requiredness or missing collection elements even when a plain null guard does not fit.
It shares its required-code definition with FieldViolation.Required below.
The InputPointer overload preserves the supplied path and input location. To retain
a route, query, header, or indexed-body location in a lazy null guard, use
Result.EnsureNotNull(value, () => Error.InvalidInput.Required(pointer, detail));
the field/detail guard overloads still take string field names.
Resource factories use ResourceRef.For<TResource>(id) for type naming and invariant ID formatting. The explicit-reference forms accept ResourceRef.For("Order", id) instead of the removed string-resource overloads, and reject a default reference or blank Type. Null IDs remain valid collection-level references. ForReason / ForPolicy produce resourceless errors.
Error.InvalidInput.ForField(ValidationCodes.StringEmail, "email", detail: "Invalid email.");
Error.InvalidInput.ForRule("password.mismatch",
fields: [InputPointer.ForBody("/password"), InputPointer.ForBody("/confirmation")],
detail: "Password and confirmation differ.");
Error.Conflict.For<Order>("order.already-shipped", id: orderId);
Error.Forbidden.For("orders.write", ResourceRef.For("Order", orderId));
Error.NotFound.For<Order>(id: orderId);
Error.Gone.For<Order>("order.purged", id: orderId);
Factories remove resource/pointer construction ceremony. Cases without that ceremony keep direct constructors, for example new Error.Unexpected("db.timeout") or new Error.AuthenticationRequired() { Code = "token.expired" }. Aggregate remains the multi-error composition constructor. Kind, Code, Detail, violation property names, and wire payload shapes are unchanged. See factory migration before porting positional string arguments; old calls can compile with a different meaning.
Concrete error cases
Nested sealed record cases under Error. The base constructor is private, so the case set is closed even though each nested case is publicly instantiable with new.
| Case | Constructor | Domain semantics |
|---|---|---|
Error.InvalidInput |
(EquatableArray<FieldViolation> Fields, EquatableArray<RuleViolation> Rules = default) |
Request input failed semantic validation. Use ForField(...) / ForRule(...) for the common single-violation shapes. Reasons belong to the individual violations, so the root Code stays error.unspecified. |
Error.InvariantViolation |
(string Code, ResourceRef? Resource = null) |
Domain rule failed outside field-bound request validation; use for cross-aggregate invariants or internal preconditions. Code-first For / For<TResource> and resourceless ForReason. |
Error.NotFound |
(ResourceRef Resource) |
The addressed resource does not exist. For<TResource>(id: id) needs no custom code. Supply a code only when a finer reason matters; blank optional factory codes normalize to error.unspecified. |
Error.Forbidden |
(string Code, ResourceRef? Resource = null) |
The caller is authenticated but not allowed by the named policy. PolicyId reads Code, so they cannot drift. Code-first For / For<TResource> and resourceless ForPolicy. |
Error.Conflict |
(string Code, ResourceRef? Resource = null) |
The request collides with current state (for example duplicate keys or concurrent modification). Code-first For / For<TResource> and resourceless ForReason. |
Error.Gone |
(ResourceRef Resource) |
The resource previously existed but has been permanently removed. For<TResource>(id: id) needs no custom code; supply one for a finer reason such as "order.purged". Optional-code handling matches NotFound. |
Error.AuthenticationRequired |
(string? Scheme = null) |
Authentication is missing or could not be established. Set { Code = ... } to distinguish causes that share the 401 surface — e.g. "Authentication.InvalidCredentials" vs "Authentication.MissingCredentials" vs "Authentication.TokenExpired" — so telemetry, dashboards, and client branching don't have to parse Detail. |
Error.Unavailable |
(RetryAdvice? Retry = null) |
A dependency or subsystem is temporarily unavailable; retry may succeed later. Set { Code = ... } to identify the kind of unavailability. |
Error.RateLimited |
(RetryAdvice? Retry = null) |
The caller exceeded a quota or rate limit. Set { Code = ... } to name which quota (e.g. "quota.daily-transfers") — a caller subject to several limits cannot back off intelligently while every one of them reports the same thing. |
Error.Unexpected |
(string Code, string? FaultId = null) |
Unhandled internal failure or “shouldn't happen” condition. FaultId, when supplied, correlates to telemetry. |
Error.Aggregate |
(EquatableArray<Error> Errors) (IEnumerable<Error> errors) (params Error[] errors) |
Composition node that flattens nested aggregates and preserves every inner error. Reasons belong to the children, so the root Code stays error.unspecified. |
Error.TransportFault |
(ITransportFault Fault) |
Opaque envelope for lower-layer, transport-specific faults produced outside the domain model. Code is the wrapped fault's own code when it implements ICodedTransportFault, read once at construction. |
Closed union — exhaustive matching
The catalog is closed to these 12 cases: InvalidInput, InvariantViolation, NotFound, Forbidden, Conflict, Gone, AuthenticationRequired, Unavailable, RateLimited, Unexpected, Aggregate, and TransportFault. Pattern matching over Error stays explicit and exhaustive at the language level.
Error.TransportFault envelope
Domain code treats ITransportFault as opaque and does not inspect concrete transport payloads. Boundary layers such as Trellis.Asp unwrap HttpError to synthesize HTTP status codes, companion headers, and problem-details extensions. The built-in HTTP transport payload union lives in Trellis.Http.Abstractions.
RetryAdvice
RetryAdvice is a transport-neutral retry hint: public readonly record struct RetryAdvice(TimeSpan? After = null, DateTimeOffset? At = null);. Error.RateLimited and Error.Unavailable carry it so the boundary can emit Retry-After without teaching the domain about HTTP headers.
HTTP-specific status codes, headers, and problem-details type tokens are not the domain's responsibility. See trellis-api-asp.md for the boundary mapping table.
Retry classification (ErrorRetryExtensions)
ErrorRetryExtensions (static class in the Trellis namespace) and the RetryClassification enum (Transient = 0, Permanent = 1, FailFast = 2) translate the closed Error catalog into the three decisions a worker, message-broker consumer, or outbound-gateway caller has to make on every failed item: retry, give up, or halt the batch.
Extension on Error |
Returns | Purpose |
|---|---|---|
RetryClassification Classify(this Error error) |
RetryClassification |
Default mapping for every nested Error case. Throws ArgumentNullException on null. |
bool IsTransient(this Error error) |
bool |
Shorthand for Classify(error) == RetryClassification.Transient. |
bool IsPermanent(this Error error) |
bool |
Shorthand for Classify(error) == RetryClassification.Permanent. |
bool IsFailFast(this Error error) |
bool |
Shorthand for Classify(error) == RetryClassification.FailFast. |
RetryAdvice? GetRetryAdvice(this Error error) |
RetryAdvice? |
Returns advice carried by Error.RateLimited / Error.Unavailable. Returns null for every other case, including Error.Aggregate (intentional — see below). |
Default classification table. Every non-aggregate nested case of Error is enumerated; the catalog is closed so the table is exhaustive at framework-publish time:
| Error case | Classification |
|---|---|
Error.Unavailable, Error.RateLimited, Error.Unexpected |
Transient |
Error.AuthenticationRequired |
FailFast |
Error.Forbidden, Error.InvalidInput, Error.InvariantViolation, Error.NotFound, Error.Gone, Error.Conflict, Error.TransportFault |
Permanent |
Error.Aggregate is classified by max-severity over its inner errors (the constructor flattens nested aggregates, so the inners are never themselves Aggregate): FailFast if any inner classifies as FailFast; otherwise Permanent if any inner classifies as Permanent; otherwise Transient. The rule answers "should I retry the operation that produced this aggregate as an indivisible unit?" — a mixed Aggregate(Permanent, Transient) is Permanent because retrying the unit will not change the permanent inner's outcome. Callers that want per-inner retry granularity must iterate over agg.Errors themselves.
GetRetryAdvice for Error.Aggregate always returns null. Two reasons: (1) returning the first transient inner's advice would contradict Classify whenever the aggregate is Permanent or FailFast, and (2) under-waiting is order-dependent (a RateLimited(1s) followed by a RateLimited(60s) would suggest 1 s). Callers that want a merged hint must define their own policy over the inner errors.
Conflict and eventually-consistent reads. Error.Conflict defaults to Permanent because a generic outer retry loop cannot perform the conflict-specific work (rowversion reload, natural-key regeneration) that 409-shaped failures usually require. Domains with a meaningful conflict retry strategy should override locally — branch on the concrete shape first and only call Classify() in the fallback arm — or use a dedicated primitive such as DbContext.SaveChangesWithRetryAsync (see trellis-api-efcore.md). Error.NotFound and Error.Gone default to Permanent; services whose reads are eventually consistent should override the mapping locally rather than expect the framework default to infer their consistency model.
Why Error.Unexpected is Transient. Error.Unexpected wraps unhandled exceptions and "this should not have happened" conditions. A retry can hide a deterministic bug or recover a momentary failure; the default favours availability. Producers should reserve Error.Unexpected for unknown internal faults and surface deterministic failures as Error.InvariantViolation, Error.InvalidInput, or Error.Conflict instead. Consumers must still cap retries.
Why Error.TransportFault is Permanent. ITransportFault is opaque from Trellis.Core's perspective; concrete payloads (such as HttpError in Trellis.Http.Abstractions) are defined by transport-specific packages. The retryable transient outcomes those transports produce (HTTP 429, HTTP 503, gRPC UNAVAILABLE) are mapped at the boundary to Error.RateLimited and Error.Unavailable — which carry RetryAdvice — and never reach Error.TransportFault. Every HttpError case shipped today (405, 406, 412, 413, 415, 416, 428) is a caller-side error that will not succeed by waiting and retrying. Transport packages that surface their own retryable transient faults via Error.TransportFault should provide a transport-aware classification extension that overrides this default for the specific faults that are genuinely retryable.
Supporting types
HTTP-specific supporting types (AuthChallenge, EntityTagValue, RetryAfterValue, PreconditionKind, RepresentationMetadata, WriteOutcome<T>, and AggregateETagExtensions) now live in Trellis.Http.Abstractions.
| Type | Shape | Purpose |
|---|---|---|
ResourceRef |
readonly record struct (string Type, string? Id = null) plus ResourceRef.For(string type, object? id = null) and ResourceRef.For<TResource>(object? id = null) |
Aggregate identity. The For(...) helpers convert IDs with invariant formatting when possible. For<TResource> peels Maybe<T> wrappers and strips generic arity. ResourceRef.FormatTypeName(Type) exposes just the arity-stripping step — typeof(List<int>) → "List" — without the Maybe<T> peeling, which stays scoped to For<TResource> because that method owns the resource-naming contract. Use it when a component needs a type-derived identifier on the wire (AOT-generated converter fallback messages, for example). Throws ArgumentNullException for a null type. |
InputPointer |
readonly record struct with InputPointer(string Path) and InputPointer(string Path, InputLocation In) |
RFC 6901 JSON Pointer (for example /email) plus the part of the input it addresses. Construct simple property names via InputPointer.ForProperty("email"), or use InputPointer.Root for the document root. See InputPointer locations for the location-stamping factories. |
InputLocation |
enum { Unspecified = 0, Body, Query, Path, Header } |
Which part of the input an offending value came from — the in discriminator of a violation's wire location. Unspecified projects as "unknown". |
FieldViolation |
sealed record (InputPointer Field, string ReasonCode, ImmutableDictionary<string,ValidationArgValue>? Args = null, string? Detail = null) |
Single per-field violation inside InvalidInput.Fields. Equals / GetHashCode compare Args by content. |
RuleViolation |
sealed record (string ReasonCode, EquatableArray<InputPointer> Fields = default, ImmutableDictionary<string,ValidationArgValue>? Args = null, string? Detail = null) |
Multi-field invariant or object-level rule inside InvalidInput.Rules. Equals / GetHashCode compare Args by content. |
ITransportFault |
marker interface | Transport-specific payload contract used by Error.TransportFault. HTTP-aware code uses HttpError from Trellis.Http.Abstractions; other transports can define their own implementations. |
ICodedTransportFault |
ITransportFault plus string Kind { get; } and string Code { get; } |
Opt-in sub-interface for a fault that names its own failure. See Coded transport faults below. |
RetryAdvice |
readonly record struct (TimeSpan? After = null, DateTimeOffset? At = null) |
Transport-neutral retry hint carried by Error.RateLimited and Error.Unavailable. Boundary layers translate it to headers such as Retry-After. |
EquatableArray<T> |
readonly struct (ImmutableArray<T> Items) |
Wraps ImmutableArray<T> so records get sequence equality instead of reference equality. |
ICodedTransportFault — a fault that names itself
ITransportFault is a marker: Trellis.Core knows nothing about HTTP, gRPC, or a message bus, so it cannot ask an arbitrary payload what went wrong. That leaves Error.TransportFault with no code of its own, and its Code is the sentinel — which is correct for a payload Core genuinely cannot read.
ICodedTransportFault is how a transport package opts out of that fallback:
public interface ICodedTransportFault : ITransportFault
{
string Kind { get; }
string Code { get; }
}
When Error.TransportFault wraps one, the error's Code is the fault's Code, passed through verbatim. A transport fault's code is the transport's word, not a Trellis reason code. HttpError.PreconditionFailed(..., PreconditionKind.IfMatch) codes as IfMatch — an HTTP precondition name — and rewriting it into error.* shape would misrepresent a value the caller has to match against the HTTP spec, not against this vocabulary.
HttpError in Trellis.Http.Abstractions implements this interface; its existing Kind and Code members satisfy it unchanged. Faults that stay bare ITransportFault implementations keep the sentinel behavior and continue to compile.
ValidationCodes — the reason-code vocabulary
ValidationCodes (namespace Trellis) is the closed set of reason codes the framework itself emits on FieldViolation.ReasonCode and RuleViolation.ReasonCode. FaultCodes carries framework-owned codes for non-validation outcomes across the applicable Error cases.
These values are frozen. They are wire contract: a client branches on them, and renaming one silently breaks that client at runtime with no compile error anywhere. Codes may be added; an existing code's spelling and meaning may not change.
Two conventions make the set predictable:
Punctuation is uniform — lower-case, dot-separated namespaces,
kebab-casewithin a segment. Neversnake_case.A segment is one concept, hyphenated internally; it is never subdivided across dots. A dot introduces a new namespace level, so splitting a single idea across dots claims a hierarchy that does not exist and defeats the prefix fallback below — a client matching on
state.*would be told the failure category is "state".Wrong Right Why state.machine.invalid.transitionstate-machine.invalid-transitionTwo concepts, two segments — not four levels. account.not.activeaccount.not-active"not active" is one idea. value.not_nullvalue.not-nullsnake_caseis never used.Depth itself is not the rule — a genuinely nested namespace may go deeper, as
resource.authorization-via.load-faileddoes, whereresource→authorization-via→load-failedare three real levels rather than one idea chopped up. The frozen constants below happen to need at most two segments: someFaultCodesare single-segment because they name a fault with no namespace, the rest — and everyValidationCodesentry — arenamespace.name.ValidationCodesTestsenforces that bound on the frozen set.The namespace tells you what failed, so a client can fall back on the prefix when it does not recognise the full code:
| Namespace | Means | Example |
|---|---|---|
format.* |
A CLR scalar could not be constructed from the input at all. Includes out-of-range-for-type — "99999999999" into an int is format.integer, because int.TryParse cannot distinguish it from "abc". |
format.integer, format.guid, format.date-time, format.conversion |
string.* |
A string arrived intact but did not match a required shape. All ISO code sets live here. | string.email, string.pattern, string.max-length, string.country-code |
number.* |
Validation of an already-parsed number. number.overflow is arithmetic only. |
number.precision, number.overflow |
value.* |
Type-agnostic presence or comparison. | value.not-null, value.not-empty, value.not-default, value.greater-than, value.between-inclusive |
fields.* |
The subject is a set of fields rather than one. | fields.mutually-exclusive, fields.exactly-one, fields.at-least-one |
enum.*, money.*, http.*, etag.*, cursor.*, page-size.*, attribute.* |
Domain-specific. | enum.name-undefined, money.currency-mismatch, http.bad-request |
The complete set
Emit these by constant, not by literal — a typo in a literal is a silent wire break, while a typo in a constant name does not compile.
| Constant | Wire value | Emitted when |
|---|---|---|
Unspecified |
error.unspecified |
The producer has no code to report. |
LegacyUnspecified |
validation.error |
Pre-vocabulary placeholder; flagged by ReasonCodeVocabularyAnalyzer at the producer. Do not emit. |
FormatInteger |
format.integer |
Not parseable as int/long/short/byte, including out of range for the type. |
FormatNumber |
format.number |
Not parseable as double/float. |
FormatDecimal |
format.decimal |
Not parseable as decimal. |
FormatBoolean |
format.boolean |
Not parseable as bool. |
FormatGuid |
format.guid |
Not parseable as Guid. |
FormatDateTime |
format.date-time |
Not parseable as DateTime/DateTimeOffset. |
FormatDate |
format.date |
Not parseable as DateOnly. |
FormatTime |
format.time |
Not parseable as TimeOnly. |
FormatDuration |
format.duration |
Not parseable as TimeSpan. |
FormatConversion |
format.conversion |
Conversion to the target type failed with no more specific code — including a JSON token of the wrong kind. |
StringLength |
string.length |
Length outside an allowed range. |
StringMinLength |
string.min-length |
Shorter than the minimum. Args: minLength, totalLength. |
StringMaxLength |
string.max-length |
Longer than the maximum. Args: maxLength, totalLength. Generated primitives and the FluentValidation adapter agree on both, so a client renders the same message whichever producer noticed. |
StringExactLength |
string.exact-length |
Not the required exact length. |
StringPattern |
string.pattern |
Did not match a required regular expression. |
StringEmail |
string.email |
Not a valid email address. |
StringUrl |
string.url |
Not a valid absolute HTTP/HTTPS URL. |
StringHostname |
string.hostname |
Not RFC 1123 compliant. |
StringIpAddress |
string.ip-address |
Not a valid IPv4 or IPv6 address. |
StringSlug |
string.slug |
Not a valid slug. |
StringPhoneE164 |
string.phone-e164 |
Not E.164 format. |
StringCountryCode |
string.country-code |
Not an ISO 3166-1 alpha-2 code. |
StringLanguageCode |
string.language-code |
Not an ISO 639-1 alpha-2 code. |
StringCurrencyCode |
string.currency-code |
Not an ISO 4217 code. |
StringCreditCard |
string.credit-card |
Failed credit-card validation. |
StringTimeZoneIana |
string.time-zone-iana |
Not an IANA time-zone identifier resolvable on the current system. Windows-only identifiers are rejected. |
NumberFinite |
number.finite |
An already-parsed floating-point value is NaN or infinity where a finite number is required. No args; the non-finite input is not echoed into JSON. |
NumberPrecision |
number.precision |
A parsed decimal exceeded the allowed scale or precision. |
NumberOverflow |
number.overflow |
Arithmetic overflow, such as Money.Add. Malformed input is a format.* code. |
ValueNotNull |
value.not-null |
Required and absent (or explicitly null). |
ValueNotEmpty |
value.not-empty |
Present but empty or whitespace. |
ValueNotDefault |
value.not-default |
Left at the type default — Guid.Empty, 0, default(DateTime). |
ValueMustBeNull |
value.must-be-null |
Supplied where it must be absent. |
ValueMustBeEmpty |
value.must-be-empty |
Non-empty where it must be empty. |
ValueMustEqual |
value.must-equal |
Did not equal a required value. |
ValueMustNotEqual |
value.must-not-equal |
Equalled a forbidden value. |
ValueLessThan |
value.less-than |
Not less than the bound. Args: comparisonValue. |
ValueLessThanOrEqual |
value.less-than-or-equal |
Above the maximum. Args: comparisonValue. |
ValueGreaterThan |
value.greater-than |
Not greater than the bound. Args: comparisonValue. |
ValueGreaterThanOrEqual |
value.greater-than-or-equal |
Below the minimum. Args: comparisonValue. |
ValueBetweenInclusive |
value.between-inclusive |
Outside an inclusive range. Args: from, to. |
ValueBetweenExclusive |
value.between-exclusive |
Outside an exclusive range. Args: from, to. |
FieldsRequiredWith |
fields.required-with |
Required because a companion field was supplied. |
FieldsAllOrNone |
fields.all-or-none |
Some but not all of a group were supplied. |
FieldsMutuallyExclusive |
fields.mutually-exclusive |
More than one of a mutually exclusive group was supplied. |
FieldsExactlyOne |
fields.exactly-one |
Exactly one of a group was required. |
FieldsOnlyOne |
fields.only-one |
At most one of a group was allowed. |
FieldsAtLeastOne |
fields.at-least-one |
None of a group was supplied. |
EnumNameUndefined |
enum.name-undefined |
The supplied name is not a member of the enum. Args: allowed, the permitted member names as a JSON array of strings, ordinally sorted. |
EnumUndefined |
enum.undefined |
A numeric value parsed but is not a defined member. Args: allowed, the same list the name failure carries — the remedy is identical, so a client is told its options whichever form it sent. |
MoneyCurrencyMismatch |
money.currency-mismatch |
An operation combined two different currencies. Args: expected, actual. |
MoneyNegativeResult |
money.negative-result |
The operation would produce a negative amount. |
SchedulePeriodsOverlap |
schedule.periods-overlap |
Weekly periods overlap, including across the week boundary; touching endpoints are allowed. |
PageSizeOutOfRange |
page-size.out-of-range |
Page size not positive, or above the maximum. |
HttpBadRequest |
http.bad-request |
An upstream HTTP response was 400. |
HttpUnprocessableContent |
http.unprocessable-content |
An upstream HTTP response was 422. |
HttpForbidden |
http.forbidden |
An upstream HTTP response was 403. |
HttpConflict |
http.conflict |
An upstream HTTP response was 409. |
EtagMalformed |
etag.malformed |
An ETag header value could not be parsed. |
CursorMalformed |
cursor.malformed |
A pagination cursor could not be decoded. |
AttributeInvalid |
attribute.invalid |
An actor attribute was missing or not valid. |
ValidationCodes.FormatCodeFor(Type) returns the format.* code for "this input could not be read as that type", unwrapping a nullable value type first and falling back to format.conversion. Every producer that turns a parse failure into a code routes through it — the query/route binder, the scalar JSON converters, and the composite JSON converter — so the mapping exists once rather than once per producer, and a new producer cannot quietly invent a different answer for a failure the framework already names.
FaultCodes carries the non-validation reason codes — these describe a failure of the system or of the request's fate rather than of the input, so they live apart from the validation vocabulary:
| Constant | Wire value | Emitted when |
|---|---|---|
RateLimitExceeded |
rate-limit.exceeded |
ASP.NET Core rate-limiting middleware rejected the request before endpoint execution. Carried by Error.RateLimited; emitted by Trellis.Asp's UseTrellisRejectionHandler. |
DefaultInitialized |
default-initialized |
A Result was default-initialized and never assigned. |
UnhandledException |
unhandled-exception |
An exception escaped to a boundary that converts it into a failed Result. |
NotImplemented |
not-implemented |
A boundary reached a path the framework does not implement. Carried by Error.Unexpected; surfaces as HTTP 501. |
ConcurrentModification |
concurrent-modification |
A write lost a race — the row changed between read and save. Carried by Error.Conflict; surfaces as 412 when the request carried If-Match, otherwise 409. |
DuplicateKey |
duplicate.key |
A write violates a unique constraint, including losing a concurrent unique-index insert race. Carried by Error.Conflict; default HTTP status 409. Emitted by EF save/idempotent-insert helpers and FakeRepository.SaveAsync. |
ReferentialIntegrity |
referential.integrity |
A write violates a foreign-key constraint. Carried by Error.Conflict; emitted by EF Result-returning save helpers. |
RetryAborted |
retry.aborted |
A retryable save failure was aborted by the regenerate callback. Carried by Error.Conflict; emitted by SaveChangesWithRetryAsync. |
RetryExhausted |
retry.exhausted |
A retryable save failure exhausted the allowed attempts. Carried by Error.Conflict; emitted by SaveChangesWithRetryAsync. |
StateMachineInvalidTransition |
state-machine.invalid-transition |
A trigger was rejected because the aggregate's current state forbids it. Carried by Error.InvariantViolation; surfaces as 422. Emitted by Trellis.StateMachine's FireResult. |
HttpResponseNotSuccess |
http.response-not-success |
A response carried a non-success status on a path that needed its body. Carried by Error.Unexpected; emitted by Trellis.Http's ReadJsonAsync / ReadJsonMaybeAsync. |
HttpResponseNoBody |
http.response-no-body |
A response that had to carry a body did not — 204/205, or a zero-length payload. Carried by Error.Unexpected; emitted by Trellis.Http. |
HttpResponseInvalidBody |
http.response-invalid-body |
A response body could not be deserialized, or deserialized to null. Carried by Error.Unexpected; emitted by Trellis.Http. Detail reports JSON line/byte position only, never body content. |
HttpResponseFault |
http.response-fault |
A response status has no more specific mapping in the status-to-error table. Carried by Error.Unexpected; emitted by Trellis.Http's ToResultAsync. |
ResponseLocationUnresolved |
response.location-unresolved |
A response was configured to emit a Location header but the URI could not be resolved. Carried by Error.Unexpected; emitted by Trellis.Asp. |
These persistence constants live in Core, so clients and tests can match them without an EF Core
dependency. Their wire spellings and meanings are frozen under the same contract as the rest of this
vocabulary. Match Error.Conflict.Code against FaultCodes.DuplicateKey for a unique-index race
instead of inspecting Detail or provider-specific constraint text.
RateLimitExceeded, the http.response-* family, and ResponseLocationUnresolved are not dispatch keys —
nothing branches on them to pick a status code. They are constants for the other reason a code exists: they
are what a caller groups on. The HTTP-response and location failures previously passed
Guid.NewGuid().ToString("N") as the Code positional argument
of the Error.Unexpected constructor — Unexpected(string Code, string? FaultId = null) — which gave
every single incident its own code
on the wire — an unbounded cardinality that no dashboard can aggregate — while leaving FaultId, the field
that exists for exactly that per-incident value, null. The identifier now goes to FaultId and the code
stays stable. RateLimitExceeded instead gives the ASP rate-limiter adapter an explicit code where a bare
Error.RateLimited would otherwise emit error.unspecified.
NotImplemented and ConcurrentModification are control values: the framework matches on them to select
HTTP behaviour, so they are dispatch keys as well as presentation. That is exactly why they belong here as
constants — while they were bare literals scattered across Trellis.Http, Trellis.Asp and
Trellis.EntityFrameworkCore, the shape tests in ValidationCodesTests could not see them, which is how
not_implemented and concurrent_modification kept a snake_case spelling the convention forbids.
Three distinctions are easy to get wrong and are worth stating outright:
value.not-nullvsvalue.not-empty— absent versus present-but-blank. Producers keep these apart so a client can tell "you omitted this" from "you sent it blank" without parsing prose.value.not-emptyvsvalue.not-default— an empty string is empty;Guid.Emptyand0are default, not empty.enum.name-undefinedvsenum.undefined— the name was not a member at all, versus a numeric value that parsed into an undefined member. A "did you mean?" affordance needs the first.
ValidationCodes.Unspecified (error.unspecified) is the neutral sentinel, emitted when a producer genuinely has no code to report — a Must(...) predicate, or a message-only API. ValidationCodes.LegacyUnspecified (validation.error) is the pre-vocabulary placeholder, retained so the string is documented and not reused; do not emit it. ReasonCodeVocabularyAnalyzer flags it at the producer, which is where it is worth catching — no boundary rewrites it, because a code Trellis did not choose reaches a consumer exactly as its producer spelled it.
Producer independence. The same failure reports the same code regardless of which part of the framework noticed it. A malformed integer is format.integer whether it arrived through query-string binding, a JSON body, or a generated TryCreate. This is what makes a single client branch sufficient, and it is enforced by ProducerIndependenceTests.
ValidationArgs and ValidationArgValue
ValidationArgs.Of(...) builds the Args dictionary carried by a violation — the machine-readable operands of the rule, such as the 50 in "must be at most 50" or the 0/255 bounds on a byte.
Error.InvalidInput.ForField(field: "age", code: ValidationCodes.ValueBetweenInclusive,
args: ValidationArgs.Of("from", 0, "to", 150), detail: "Age is unrealistically high.");
Values are ValidationArgValue, a closed union with four cases. Because it is closed, a client can switch over it exhaustively, and the JSON shape of an arg follows from its case rather than from whatever a producer happened to pass:
| Case | Declaration | JSON |
|---|---|---|
ValidationArgValue.Text |
sealed record Text(string Value) |
"red" |
ValidationArgValue.Number |
sealed record Number(decimal Value) |
50 |
ValidationArgValue.Bool |
sealed record Bool(bool Value) |
true |
ValidationArgValue.List |
sealed record List(EquatableArray<ValidationArgValue> Items) |
["red","green"] |
The cases are JSON's self-describing values: its three scalars, plus a list of them. JSON's other two constructs are deliberately absent. null would give the dictionary two spellings of the same thing, because an arg with no value is an arg that is simply not there — omit the key. An object would mean a client needs a schema per reason code to know what it is looking at, which gives up the property that makes the union worth having (the shape follows from the case) and turns args into an open-ended payload, which is a poor thing to echo back to a caller. Both are rejected with JsonException on read.
Important
A numeric operand reaches the wire as a JSON number, not a quoted string: {"maxLength": 50}, not {"maxLength": "50"}. A client comparing a bound against a length no longer has to parse it back out and guess at the format.
Number holds a decimal — one case rather than one per CLR numeric type, because JSON has a single number type and a client could not observe the distinction. It is written invariantly, so a German server and an American one emit the same bytes.
Note
Bool exists to model the format, not to encourage boolean args. A boolean is rarely interpolated into a rendered message — you cannot put true into a localized sentence — and a flag that selects which message to render usually belongs in the reason code, which is the branch point a client is meant to switch on. But without the case, a producer with a boolean operand would write Text("true"), reintroducing the quoted-primitive problem the union removes, and a boolean sent by a non-.NET producer would fail the entire error payload rather than one arg.
Build values implicitly. string, int, long, decimal, and bool all convert on their own, so ordinary calls need no ceremony; use ValidationArgValue.ListOf(...) (or ListFrom(...) for a sequence) for the list case:
ValidationArgs.Of("maxLength", 50); // {"maxLength": 50}
ValidationArgs.Of("expected", "USD", "actual", "EUR"); // both text
ValidationArgs.Of("allowed", ValidationArgValue.ListOf("red", "green"));
For three or more operands — a scale-and-precision failure carries four — use the params overload, which takes name/value pairs:
ValidationArgs.Of(
("expectedPrecision", 3),
("expectedScale", 1),
("actualScale", 4),
("digits", 5));
Note
There is deliberately no IFormattable overload. Adding one would make ValidationArgs.Of("max", 255) ambiguous — an int converts to IFormattable by boxing and to ValidationArgValue by a user-defined conversion, and neither target is better than the other, so the call fails with CS0121. Its absence is what lets the implicit conversions bind. Format a value with no numeric or textual meaning of its own, such as a timestamp, explicitly and invariantly at the call site.
ValidationArgs.Allowed(...) — the permitted-members entry
ValidationArgs.Allowed(names) builds the allowed entry naming the members a symbolic value may take, and is what every producer that rejects a value for not being one of a fixed set calls:
ValidationArgs.Allowed(Enum.GetNames(typeof(Colour))); // {"allowed": ["Green", "Red"]}
It exists as a helper rather than as a convention because the producers derive their members from unrelated places — Enum.GetNames for query binding and the body converter, a registry of declared statics for RequiredEnum — and nothing else would force those to agree on either the entry's name or its order. The names are sorted ordinally, which makes the order total and culture-independent; sorting by the current culture would let the same enum serialize differently on two machines. A client may therefore compare or cache the list across producers.
Passing no names yields an empty array rather than omitting the entry: "nothing is permitted" is a real answer, where a missing entry reads as "this violation forgot to say".
A list longer than ValidationArgs.MaxAllowedMembers (64) is dropped whole and replaced by allowedCount.
ValidationArgs.Allowed(isoCountryNames); // {"allowedCount": 248}
The bound exists because a list a client cannot act on is not worth what it costs to send. The 248 ISO country names serialize to roughly 3 KB attached to every rejection, and a request carrying several invalid enum fields multiplies that — a small request provoking a large response is an amplification vector, not merely waste. Past a few dozen options a client is not rendering "choose one of…" from an error payload anyway; it wants a schema or an enumeration endpoint.
The list is dropped rather than truncated because a client cannot distinguish a shortened list from a complete one. A truncated allowed is a false statement: it tells a client that a member it omitted is not permitted, so a client rendering a chooser shows a wrong set and a client validating against it rejects valid input. Omission is a case every client already handles, because a blank value carries no list either — allowedCount is what separates "too many to send" from "not applicable here".
The bound counts members rather than serialized bytes because member names are identifiers in every producer — CLR enum names, or the static field names behind a RequiredEnum — so their length is already bounded in practice, and a count is something a client can be told and can predict.
The same bound governs the human-readable Detail. RequiredEnum.TryCreate and RequiredEnumJsonConverter spell their members into prose ("'x' is not a valid Colour. Valid values: Green, Red"), which for 248 countries is another 2.8 KB — more than the arg it accompanies. Above the bound that clause is dropped too, leaving "'x' is not a valid Country.", which is the sentence the body converter already emits.
ValidationArgValueJsonConverter is applied to the union by attribute, so serialization needs no registration. It is public because the System.Text.Json source generator cannot resolve an internal converter: a trimmed or AOT-published application whose JsonSerializerContext roots a violation payload fails at compile time with SYSLIB1220 otherwise.
Important
Reading a number a decimal cannot hold exactly throws JsonException. Utf8JsonReader.GetDecimal() rounds silently — 1E-100 arrives as 0, and 0.00000000000000000000000000009 arrives as 0.0000000000000000000000000001 — and an arg is an operand a client renders a bound from, so a rounded value states a limit the producer never wrote. The converter refuses such tokens instead. Exactness is judged on significant digits rather than on text, so the ordinary spellings a non-.NET producer may emit (1e2, 1.50, -0) are all accepted and compare equal to their plain forms.
Never put the rejected value itself in Args — it would be reflected back in the error response and into anything that logs it.
Error.InvalidInput.ForField and ForRule each have an overload taking args directly.
FieldViolation.Required factories
For composite validators that construct violations directly, use these static members
on the existing FieldViolation record:
| Signature | Notes |
|---|---|
public static FieldViolation Required(string? fieldName, string? detail = null) |
Normalizes via InputPointer.ForProperty; null/empty targets the root. |
public static FieldViolation Required(InputPointer field, string? detail = null) |
Preserves the supplied path and input location, including indexed body fields and named query/header parameters. |
Both produce FieldViolation.ReasonCode = ValidationCodes.ValueNotNull, Args = null, and the exact
supplied Detail (null stays null), and count one field violation. Existing equality,
hashing, serialization, and copy/rebase behavior are unchanged.
InputPointer locations
A pointer carries where a value came from as well as which value it was. The location is what lets the boundary decide whether the accompanying path is resolvable as a document location or is merely a parameter name.
| Member | Returns | Purpose |
|---|---|---|
public InputPointer(string Path) |
— | A pointer with In = InputLocation.Unspecified. Throws ArgumentException when Path is neither empty nor /-rooted, or when it contains an escape other than ~0 / ~1. |
public InputPointer(string Path, InputLocation In) |
— | As above, stamping an explicit location. |
public InputLocation In { get; init; } |
InputLocation |
Which part of the input the value came from. |
public static InputPointer ForProperty(string propertyName) |
InputPointer |
Escapes a simple name (~→~0, /→~1) and prepends /; passes an already-/-rooted pointer through unchanged. Leaves In unspecified. |
public static InputPointer ForBody(string jsonPointer) |
InputPointer |
ForProperty semantics, stamped InputLocation.Body — so ForBody("/items/0/quantity") addresses a nested value rather than a field literally named that. Empty input yields the root path stamped Body, which is deliberately not Root: a whole-body violation is idiomatically the root pointer, and stamping it is what makes it project as body rather than unknown. |
public static InputPointer ForQuery(string name) |
InputPointer |
A query-string parameter, addressed by name. |
public static InputPointer ForPath(string name) |
InputPointer |
A route parameter, addressed by name. |
public static InputPointer ForHeader(string name) |
InputPointer |
A request header, addressed by name. |
public InputPointer AppendProperty(string propertyName) |
InputPointer |
Appends one literal property-name segment, escaping ~ and / even at the start of the name. Empty means an empty-name property, not root; null throws ArgumentNullException. Preserves In. |
public InputPointer AppendIndex(int index) |
InputPointer |
Appends a zero-based index with invariant decimal formatting. Negative indexes throw ArgumentOutOfRangeException. Preserves In. |
public void Deconstruct(out string Path) |
void |
Path only — preserves the single-element deconstruction the original positional record offered. |
public void Deconstruct(out string Path, out InputLocation In) |
void |
Path and location. |
The three name factories store the name as one escaped token, so a parameter named a/b becomes /a~1b — one parameter, not two segments. Unlike ForProperty and ForBody they escape a leading / rather than treating it as an already-formed pointer, because a parameter named /id is a name and a name factory must never reinterpret it as a location. They reject an empty name rather than collapsing to root, since the root pointer is meaningful for a body and meaningless for a named parameter.
Equals and GetHashCode compare Path and In. Path alone would make two pointers that project to different wire locations compare equal, and de-duplication would then collapse distinct failures into one.
Build nested paths without string concatenation:
var field = InputPointer.ForBody("/openingHours")
.AppendProperty("periods").AppendIndex(1).AppendProperty("open");
// Path: /openingHours/periods/1/open; In: Body
AppendProperty takes a literal name, not a pre-escaped pointer: appending "/name"
produces the segment ~1name, and appending "~1" produces ~01. Existing parent
segments are not re-escaped. Appending to Root or a default pointer works identically.
Moved HTTP transport types
AuthChallenge, EntityTagValue, RetryAfterValue, PreconditionKind, RepresentationMetadata, WriteOutcome<T>, and AggregateETagExtensions are no longer part of Trellis.Core.
Use Trellis.Http.Abstractions when you need header-aware HTTP payloads, conditional-request helpers, representation metadata, or HTTP-shaped write outcomes.
The base record's constructor is private; new cases cannot be added by consumers.
public sealed class RailwayTrackAttribute : Attribute
Annotates result helpers with whether they operate on the success or failure railway.
Properties
| Name | Type |
|---|---|
Track |
TrackBehavior |
Methods
| Signature | Notes |
|---|---|
public RailwayTrackAttribute(TrackBehavior track) |
Constructor |
Factory Methods
None.
public enum TrackBehavior
Values: Success, Failure.
public static class ResultDebugSettings
Global debug switch for result tracing.
Properties
| Name | Type |
|---|---|
EnableDebugTracing |
bool |
Methods
None.
Factory Methods
None.
public static class ResultsTraceProviderBuilderExtensions
OpenTelemetry helper for Trellis result instrumentation. Lives in Trellis.Core\src\ResultsTraceProviderBuilderExtensions.cs and takes a hard dependency on the OpenTelemetry.Trace package — Trellis.Core references the OpenTelemetry SDK so consumers do not need a separate package reference to opt in.
Fields
| Signature | Notes |
|---|---|
public const string ActivitySourceName |
The name of the ROP ActivitySource: "Trellis.Results". AddTrellisResultsInstrumentation is the recommended way to subscribe; use ResultsTraceProviderBuilderExtensions.ActivitySourceName when composing a source list by hand, filtering in a processor, or configuring a backend from code, so the name is never duplicated as a literal. |
Methods
| Signature | Notes |
|---|---|
public static TracerProviderBuilder AddTrellisResultsInstrumentation(this TracerProviderBuilder builder) |
Registers the Trellis ROP ActivitySource (named "Trellis.Results", exposed as ResultsTraceProviderBuilderExtensions.ActivitySourceName) with the supplied OpenTelemetry tracer-provider builder. Returns the same builder for chaining. |
Performance characteristics
The per-operation tracing is essentially free when no listener is registered. AddTrellisResultsInstrumentation is the Trellis-provided helper for registering the "Trellis.Results" source with OpenTelemetry; consumers may also call AddSource("Trellis.Results") directly or attach an ActivityListener. Measured on .NET 10 / x64 with an ambient ASP.NET request activity present (benchmark in Trellis.Benchmark/TracingOverheadBenchmarks.cs):
| Pipeline depth | No listener | Listener attached (AllDataAndRecorded sampling) |
|---|---|---|
1-step Bind |
~20 ns, 0 B | ~228 ns, 400 B |
5-step Bind chain |
~107 ns, 0 B | ~1,135 ns, 2,000 B |
10-step Bind chain |
~242 ns, 0 B | ~2,266 ns, 4,000 B |
10-step Map chain |
~115 ns, 0 B | ~2,227 ns, 4,000 B |
10-step Tap chain |
~176 ns, 0 B | ~2,281 ns, 4,096 B |
No listener registered (default): ~14–20 ns per Bind/Map/Tap, 0 bytes allocated. The per-extension using var activity = ActivitySource.StartActivity(...) returns null almost immediately when no consumer has registered the "Trellis.Results" source. Trellis does not set status or result.error.code on an ambient ASP.NET or Mediator activity from ROP operators unless that activity was started by the Trellis ROP ActivitySource. On a failure the tags are result.error.code (Error.Code, the same string the HTTP boundary writes) and result.error.type (the error class name, which keeps the cases whose wire code is the sentinel distinguishable).
With AddTrellisResultsInstrumentation registered: each combinator costs ~200 ns and allocates ~400 B (the Activity object + name + tags). At 10 000 RPS with a 10-step pipeline that's ~22 ms/sec of CPU and ~40 MB/sec of GC pressure — material at high throughput.
Granularity guidance
Per-Result-extension spans add limited signal beyond the outer pipeline span (Trellis.Mediator.TracingBehavior) or the ASP.NET request span. They appear as a deeply nested tree under the outer span with no business context — most observability backends collapse or charge per span.
- Production / high-throughput services: instrument at the pipeline-behavior altitude (already covered by
Trellis.Mediator.TracingBehavior) and the HTTP-boundary altitude (AddAspNetCoreInstrumentation); skipAddTrellisResultsInstrumentation. - Development / debugging / low-rate paths: register
AddTrellisResultsInstrumentationto get step-by-step ROP visibility. The cost is intentional — you opted in.
The cost is bounded by the consumer's choice; the framework does not gate it further. If AddTrellisResultsInstrumentation is registered, the spans appear; if not, they don't, and the cost is noise-floor.
public static class ValidationMetrics
The Meter and instruments Trellis publishes for validation failures. Lives in Trellis.Core\src\ValidationMetrics.cs.
What it is for
Validation failures look like user error, and that label is why nobody watches them. Server-side validation is a backstop: when a client enforces the same rules before sending, this counter sits near zero, and that expected value is what makes it alertable. A rising count means client-side validation has drifted from the server's, a client broke against you, or you tightened a rule without noticing — your defect, arriving disguised as theirs. Alert on a code's rate changing rather than on absolute volume, and expect a non-zero floor when third parties call you.
validation.code is what makes it actionable, and why an HTTP-status metric is not a substitute: a 4xx rate says something drifted, the code says which rule did. There is deliberately no field or route tag — both are unbounded — so detection lives in the metric and diagnosis in the trace, whose JSON pointer names the field.
A second, narrower use is the framework's own: whether a reason code the framework can emit is ever actually emitted. A trace answers that only for sampled requests, so a zero-volume code is indistinguishable from one whose traces were all sampled away. A code that never fires may also be shadowed by an earlier check rather than unused.
Fields
| Signature | Notes |
|---|---|
public const string MeterName |
"Trellis.Validation" — the meter carrying Trellis validation instruments. |
public const string FailuresInstrumentName |
"trellis.validation.failures" — a Counter<long> incremented once per violation, unit {failure}. |
public const string OtherCode |
"other" — the validation.code tag value substituted for any code outside the framework vocabulary. |
Tags
| Tag | Values |
|---|---|
validation.code |
A ValidationCodes constant, or other. |
validation.violation |
field or rule, matching FieldViolation and RuleViolation. |
Where the count happens
A violation is counted where it is created: the FieldViolation.ReasonCode and RuleViolation.ReasonCode initializers.
The counting site is the violation, not the Error.InvalidInput that carries it, and the distinction is load-bearing. The carrying failure is rebuilt during re-projection — JsonValidationPathRebase re-roots pointers by constructing a fresh InvalidInput from an existing one's violations, and the ASP validation context aggregates collected violations into a final one — so counting there would count a single rule firing two or three times. The violation is the atom: created once when a rule fires, only copied thereafter.
Nor can the count live at a reporting boundary. A validation failure surfaces at the HTTP boundary, at the mediator pipeline, at both, or — in a worker — at neither, so no reporting site observes each failure exactly once. Coordinating between them fails on a language detail: an AsyncLocal assigned inside an awaited Send is not visible to the caller afterwards, because a callee's assignment does not flow back up.
A with-expression does not recount. The synthesized copy constructor copies backing fields rather than re-running initializers, which is what makes re-projection free — the rebase path rewrites a pointer with violation with { Field = ... } and the count is unaffected.
The consequence worth stating plainly: the counter measures rules firing, not responses sent. A violation created and then discarded is still counted.
One case deserves calling out, because it looks like a miscount and is not. Trellis.Http maps an upstream 400 or 422 response into an Error.InvalidInput carrying ValidationCodes.HttpBadRequest or ValidationCodes.HttpUnprocessableContent, so a client process increments the counter for a failure a server produced. That is honest — the client really did construct that violation — and the two codes are distinct members of the vocabulary, so they never blur into the counts for locally-evaluated rules.
A corollary for framework code: an Error.InvalidInput built only to key a lookup on its runtime type must carry no violations, or it records a failure that never happened. ScalarValidationStatus does exactly this, and constructs its shared probe as new Error.InvalidInput(EquatableArray<FieldViolation>.Empty).
Why unknown codes are bucketed
An application code reaches the wire verbatim: ValidationCodeProjection passes through anything it does not reserve. Tagging those verbatim would let an application minting a code per entity or per tenant create an unbounded number of time series. Framework codes are a closed set and are the only ones the dead-rule question is about, so everything else folds into OtherCode. The total stays exact; only the breakdown is bucketed. The known set is read from the ValidationCodes constants themselves, so a newly added code is tagged under its own name with no second list to maintain.
public static class ValidationMeterProviderBuilderExtensions
OpenTelemetry helper for collecting Trellis validation metrics. Lives in Trellis.Core\src\ValidationMeterProviderBuilderExtensions.cs.
Methods
| Signature | Notes |
|---|---|
public static MeterProviderBuilder AddTrellisValidationInstrumentation(this MeterProviderBuilder builder) |
Subscribes the meter provider to ValidationMetrics.MeterName ("Trellis.Validation"), so validation counts are collected. Returns the same builder for chaining. Throws ArgumentNullException when builder is null. Equivalent to AddMeter("Trellis.Validation"). |
This gap is silent. Trellis counts violations as they are created, but a counter with no listener records nothing. Until this is called the instrument is inert, and nothing reports the omission — a metric that never appears reads exactly like a rule that never fires, which is the ambiguity the counter exists to remove.
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddTrellisValidationInstrumentation()
.AddOtlpExporter());
public readonly struct EquatableArray<T> : IEquatable<EquatableArray<T>>
Wraps ImmutableArray<T> so records and other value-equal types get sequence equality. Built-in record equality compares arrays by reference; this wrapper restores element-wise comparison. A default-initialized EquatableArray<T> represents an empty sequence — two default values compare equal, and Items always returns ImmutableArray<T>.Empty instead of an uninitialized array.
LINQ / FluentAssertions /
IEnumerable<T>consumers.EquatableArray<T>exposes a duck-typedGetEnumerator()(allocation-freeforeach) but does not implementIEnumerable<T>— this is intentional, to keep the value-type sequence-equality wrapper allocation-free. Methods that bind onIEnumerable<T>(Select,Where,Any,ToList, FluentAssertions'Should().ContainSingle()/Should().HaveCount(...)/Should().BeEquivalentTo(...),string.Join, etc.) won't see the contents directly. Project through.Itemsfirst (which returns the wrappedImmutableArray<T>, anIEnumerable<T>):// ❌ Doesn't compile: 'EquatableArray<RuleViolation>' does not contain a definition for 'Where' unproc.Rules.Where(r => r.ReasonCode == "..."); // ❌ FluentAssertions: 'object does not contain a definition for ContainSingle' unproc.Rules.Should().ContainSingle(); // ✅ Use .Items unproc.Rules.Items.Where(r => r.ReasonCode == "..."); unproc.Rules.Items.Should().ContainSingle().Which.ReasonCode.Should().Be("...");
Properties
| Name | Type | Notes |
|---|---|---|
Items |
ImmutableArray<T> |
The wrapped array. Returns ImmutableArray<T>.Empty for default-initialized values rather than the uninitialized default. |
Length |
int |
Number of items. |
IsEmpty |
bool |
True when the wrapped array is empty. |
this[int index] |
T |
Indexer over the wrapped array. |
Empty |
EquatableArray<T> |
Static empty instance, mirrors ImmutableArray<T>.Empty. |
Methods
| Signature | Returns | Description |
|---|---|---|
public EquatableArray(ImmutableArray<T> items) |
— | Wraps an existing immutable array. |
public static EquatableArray<T> Create(params T[] items) |
EquatableArray<T> |
Builds from a params array. |
public static EquatableArray<T> From(IEnumerable<T> items) |
EquatableArray<T> |
Builds from any enumerable. |
public ImmutableArray<T>.Enumerator GetEnumerator() |
ImmutableArray<T>.Enumerator |
Allocation-free foreach support. |
public bool Equals(EquatableArray<T> other) |
bool |
Sequence equality using EqualityComparer<T>.Default. |
public override bool Equals(object? obj) |
bool |
Object equality. |
public override int GetHashCode() |
int |
Combines hashes of all items via HashCode. |
Operators
| Signature | Notes |
|---|---|
public static bool operator ==(EquatableArray<T> left, EquatableArray<T> right) |
Equality |
public static bool operator !=(EquatableArray<T> left, EquatableArray<T> right) |
Inequality |
public static implicit operator EquatableArray<T>(ImmutableArray<T> items) |
Implicit conversion from ImmutableArray<T> |
public static class EquatableArray
Non-generic factory companion that allows type inference at the call site.
| Signature | Returns | Description |
|---|---|---|
public static EquatableArray<T> Create<T>(params T[] items) |
EquatableArray<T> |
Inferred-T factory; equivalent to EquatableArray<T>.Create(items). |
public static EquatableArray<T> From<T>(IEnumerable<T> items) |
EquatableArray<T> |
Inferred-T factory; equivalent to EquatableArray<T>.From(items). |
Extension Methods
MaybeExtensions
| Signature |
|---|
public static Maybe<T> AsMaybe<T>(this T? value) where T : struct |
public static Maybe<T> AsMaybe<T>(this T value) where T : class |
public static T? AsNullable<T>(in this Maybe<T> value) where T : struct |
public static Result<TValue> ToResult<TValue>(in this Maybe<TValue> maybe, Error error) where TValue : notnull |
public static Result<TValue> ToResult<TValue>(in this Maybe<TValue> maybe, Func<Error> ferror) where TValue : notnull |
ToResult(..., Func<Error> ferror) validates ferror before inspecting the Maybe<T> state. A null factory throws ArgumentNullException even when the maybe currently has a value; async overloads validate the factory before awaiting the receiver.
MaybeExtensionsAsync
| Signature |
|---|
public static Task<Result<TValue>> ToResultAsync<TValue>(this Task<Maybe<TValue>> maybeTask, Error error) where TValue : notnull |
public static ValueTask<Result<TValue>> ToResultAsync<TValue>(this ValueTask<Maybe<TValue>> maybeTask, Error error) where TValue : notnull |
public static Task<Result<TValue>> ToResultAsync<TValue>(this Task<Maybe<TValue>> maybeTask, Func<Error> ferror) where TValue : notnull |
public static ValueTask<Result<TValue>> ToResultAsync<TValue>(this ValueTask<Maybe<TValue>> maybeTask, Func<Error> ferror) where TValue : notnull |
public static Task<TResult> MatchAsync<TValue, TResult>(this Task<Maybe<TValue>> maybeTask, Func<TValue, TResult> some, Func<TResult> none) where TValue : notnull |
public static ValueTask<TResult> MatchAsync<TValue, TResult>(this ValueTask<Maybe<TValue>> maybeTask, Func<TValue, TResult> some, Func<TResult> none) where TValue : notnull |
public static Task<TResult> MatchAsync<TValue, TResult>(this Task<Maybe<TValue>> maybeTask, Func<TValue, Task<TResult>> some, Func<Task<TResult>> none) where TValue : notnull |
public static ValueTask<TResult> MatchAsync<TValue, TResult>(this ValueTask<Maybe<TValue>> maybeTask, Func<TValue, ValueTask<TResult>> some, Func<ValueTask<TResult>> none) where TValue : notnull |
MaybeChooseExtensions
| Signature |
|---|
public static IEnumerable<T> Choose<T>(this IEnumerable<Maybe<T>> source) where T : notnull |
public static IEnumerable<TResult> Choose<T, TResult>(this IEnumerable<Maybe<T>> source, Func<T, TResult> selector) where T : notnull |
MaybeLinqExtensions
| Signature |
|---|
public static Maybe<TOut> Select<TIn, TOut>(this Maybe<TIn> maybe, Func<TIn, TOut> selector) where TIn : notnull where TOut : notnull |
public static Maybe<TResult> SelectMany<TSource, TCollection, TResult>(this Maybe<TSource> source, Func<TSource, Maybe<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
MaybeLinqExtensionsTaskAsync / MaybeLinqExtensionsTaskLeftAsync / MaybeLinqExtensionsTaskRightAsync
LINQ query syntax over Task<Maybe<T>>. Closes the syntactic gap so from x in FindAsync() compiles when FindAsync() returns Task<Maybe<T>>. Mirrors the Result LINQ async surface.
| Signature |
|---|
public static Task<Maybe<TOut>> Select<TIn, TOut>(this Task<Maybe<TIn>> maybeTask, Func<TIn, TOut> selector) where TIn : notnull where TOut : notnull |
public static Task<Maybe<TResult>> SelectMany<TSource, TCollection, TResult>(this Task<Maybe<TSource>> source, Func<TSource, Task<Maybe<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
public static Task<Maybe<TResult>> SelectMany<TSource, TCollection, TResult>(this Task<Maybe<TSource>> source, Func<TSource, Maybe<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
public static Task<Maybe<TResult>> SelectMany<TSource, TCollection, TResult>(this Maybe<TSource> source, Func<TSource, Task<Maybe<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
public static Task<Maybe<TSource>> Where<TSource>(this Task<Maybe<TSource>> source, Func<TSource, bool> predicate) where TSource : notnull |
MaybeLinqExtensionsValueTaskAsync / MaybeLinqExtensionsValueTaskLeftAsync / MaybeLinqExtensionsValueTaskRightAsync
LINQ query syntax over ValueTask<Maybe<T>> for zero-allocation scenarios. Same shape as the Task overloads.
| Signature |
|---|
public static ValueTask<Maybe<TOut>> Select<TIn, TOut>(this ValueTask<Maybe<TIn>> maybeTask, Func<TIn, TOut> selector) where TIn : notnull where TOut : notnull |
public static ValueTask<Maybe<TResult>> SelectMany<TSource, TCollection, TResult>(this ValueTask<Maybe<TSource>> source, Func<TSource, ValueTask<Maybe<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
public static ValueTask<Maybe<TResult>> SelectMany<TSource, TCollection, TResult>(this ValueTask<Maybe<TSource>> source, Func<TSource, Maybe<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
public static ValueTask<Maybe<TResult>> SelectMany<TSource, TCollection, TResult>(this Maybe<TSource> source, Func<TSource, ValueTask<Maybe<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) where TSource : notnull where TCollection : notnull where TResult : notnull |
public static ValueTask<Maybe<TSource>> Where<TSource>(this ValueTask<Maybe<TSource>> source, Func<TSource, bool> predicate) where TSource : notnull |
MaybeTaskAdapterExtensions
| Signature |
|---|
public static Task<Maybe<T>> AsTask<T>(this Maybe<T> maybe) where T : notnull |
public static ValueTask<Maybe<T>> AsValueTask<T>(this Maybe<T> maybe) where T : notnull |
Wraps a synchronous Maybe<T> in a completed Task / ValueTask for participation in async pipelines and disambiguation of overloads at call sites.
MaybeCollectionExtensions
| Signature |
|---|
public static Maybe<T> TryFirst<T>(this IEnumerable<T> source) where T : notnull |
public static Maybe<T> TryFirst<T>(this IEnumerable<T> source, Func<T, bool> predicate) where T : notnull |
public static Maybe<T> TryLast<T>(this IEnumerable<T> source) where T : notnull |
public static Maybe<T> TryLast<T>(this IEnumerable<T> source, Func<T, bool> predicate) where T : notnull |
Result pipeline extension families
The result API contains a large generated extension surface. Exact public families:
| Static Class | Public Surface |
|---|---|
BindExtensions, BindExtensionsAsync |
Bind/BindAsync for Result<T> plus generated tuple overloads for arities 2-9 |
BindZipExtensions, BindZipExtensionsAsync |
Zips one result into another result-producing function, with sync/Task/ValueTask combinations and tuple arities |
CheckExtensions, CheckExtensionsAsync |
Runs side-effect validations that return Result<T> (use Result<Unit> for no-payload validators) while preserving original success value |
CheckIfExtensions, CheckIfExtensionsAsync |
Conditional Check variants |
CombineExtensions, CombineExtensionsAsync, CombineErrorExtensions |
Combines results, including tuple and enumerable forms |
DiscardExtensions, DiscardTaskExtensions, DiscardValueTaskExtensions |
Drops the Result<T> value entirely (returns void/Task/ValueTask) for intentional fire-and-forget pipelines |
EnsureExtensions, EnsureExtensionsAsync, EnsureAllExtensions, EnsureAllExtensionsAsync |
Predicate-based validation on successful values; includes collection-wide validation |
GetValueOrDefaultExtensions |
Non-throwing value fallback helpers |
GetValueOrThrowExtensions |
Production-safe throwing extractor for trust-boundary crossings (DTO → entity rehydration, JSON deserialization). Mirrors Maybe<T>.GetValueOrThrow(string?); throws InvalidOperationException on failure. Sync + Task<Result<T>> + ValueTask<Result<T>> overloads. See cookbook Recipe 30. |
ResultLinqExtensions, ResultLinqExtensionsTaskAsync, ResultLinqExtensionsTaskLeftAsync, ResultLinqExtensionsTaskRightAsync, ResultLinqExtensionsValueTaskAsync, ResultLinqExtensionsValueTaskLeftAsync, ResultLinqExtensionsValueTaskRightAsync |
LINQ query syntax support via Select/SelectMany/Where for Result<T>, Task<Result<T>> and ValueTask<Result<T>> (mixed sync/async sources and continuations) |
MapExtensions, MapExtensionsAsync, MapIfExtensions, MapOnFailureExtensions |
Success-path mapping, conditional mapping, and failure remapping; tuple overloads generated for arities 2-9 |
MatchExtensions, MatchExtensionsAsync, MatchTupleExtensions, MatchTupleExtensionsAsync |
Terminal branching for normal and tuple results. (The previous MatchErrorExtensions API was removed — use result.Match(_ => ..., e => e switch { Error.NotFound nf => ..., ... }) against the closed catalog.) |
RecoverExtensions, RecoverExtensionsAsync, RecoverOnFailureExtensions, RecoverOnFailureExtensionsAsync |
Converts failures into fallback success values or results |
TapExtensions, TapExtensionsAsync, TapOnFailureExtensions, TapOnFailureExtensionsAsync |
Side effects on success or failure; tuple overloads generated for arities 2-9 |
ToMaybeExtensions, ToMaybeExtensionsAsync |
Converts Result<T> to Maybe<T> |
TraverseExtensions, SequenceAllExtensions, TraverseAllExtensions |
Traverses collections through result-producing functions; *All variants accumulate failures via Error.Combine instead of short-circuiting |
WhenExtensions, WhenExtensionsAsync, WhenAllExtensionsAsync |
Conditional execution and async fan-in utilities |
Representative exact signatures:
public static Result<TResult> Bind<TValue, TResult>(this Result<TValue> result, Func<TValue, Result<TResult>> func)
public static Task<Result<TResult>> BindAsync<TValue, TResult>(this Result<TValue> result, Func<TValue, Task<Result<TResult>>> func)
public static Result<TOut> Map<TIn, TOut>(this Result<TIn> result, Func<TIn, TOut> map)
public static Result<TValue> Ensure<TValue>(this Result<TValue> result, Func<TValue, bool> predicate, Error error)
public static TOut Match<TIn, TOut>(this Result<TIn> result, Func<TIn, TOut> onSuccess, Func<Error, TOut> onFailure)
public static Result<T> ToResult<T>(this T? obj, Error error) where T : class
public static Maybe<T> ToMaybe<T>(this Result<T> result) where T : notnull
public static Result<TValue> Recover<TValue>(this Result<TValue> result, Func<Error, TValue> fallbackFunc)
public static Result<TValue> TapOnFailure<TValue>(this Result<TValue> result, Action<Error> action)
For tuple-enabled families, generated overloads cover the declared arity ranges shown above; no ValueTuple arities higher than 9 are public in this package.
Extension class catalog (full signatures)
The reference signatures below cover every Result*Extensions(Async) static class shipped by Trellis.Core. Each subsection lists the static class name(s), a methods table, and one representative usage example. All members live in the Trellis namespace.
Task adapter family — ResultTaskAdapterExtensions
Adapters for returning a synchronous Result<T> from an async-shaped API without target-typed new(...) wrappers.
| Signature | Returns | Description |
|---|---|---|
public static Task<Result<T>> AsTask<T>(this Result<T> result) |
Task<Result<T>> |
Wraps the exact result state in a completed Task. |
public static ValueTask<Result<T>> AsValueTask<T>(this Result<T> result) |
ValueTask<Result<T>> |
Wraps the exact result state in a completed ValueTask. |
public ValueTask<Result<OrderId>> Handle(CreateOrderCommand cmd, CancellationToken ct) =>
OrderId.TryCreate(cmd.OrderId)
.Bind(id => Order.Create(id))
.Tap(repo.Add)
.Map(order => order.Id)
.AsValueTask();
Bind family — BindExtensions, BindExtensionsAsync, BindZipExtensions, BindZipExtensionsAsync
Sequential composition of result-producing functions. Bind is the monadic flatMap; BindZip keeps the upstream value in scope by zipping it into the next stage. For no-payload steps, return Result<Unit> and use _ to ignore the Unit argument in the next lambda.
| Signature | Returns | Description |
|---|---|---|
public static Result<TResult> Bind<TValue, TResult>(this Result<TValue> result, Func<TValue, Result<TResult>> func) |
Result<TResult> |
Generic-to-generic flatMap. Short-circuits on failure. |
public static Task<Result<TResult>> BindAsync<TValue, TResult>(this Task<Result<TValue>> resultTask, Func<TValue, Task<Result<TResult>>> func) |
Task<Result<TResult>> |
Six non-tuple overloads: Result<T> receivers accept Task or ValueTask delegates; Task<Result<T>> receivers accept sync or Task delegates; ValueTask<Result<T>> receivers accept sync or ValueTask delegates. Mixed Task/ValueTask families require explicit adaptation or awaiting the receiver first. |
public static Result<(T1, T2)> BindZip<T1, T2>(this Result<T1> result, Func<T1, Result<T2>> func) |
Result<(T1, T2)> |
Zips upstream value with the bind result so downstream stages see both. Tuple arities 2–9 are generated. |
public static Task<Result<(T1, T2)>> BindZipAsync<T1, T2>(this Task<Result<T1>> resultTask, Func<T1, Task<Result<T2>>> func) |
Task<Result<(T1, T2)>> |
Async BindZip; generated for every Result/Task/ValueTask combination. |
Result<Order> Place(OrderId id) =>
LoadCustomer(id)
.BindZip(c => LoadCart(c.Id)) // Result<(Customer, Cart)>
.Bind((customer, cart) => Charge(customer, cart));
Async-lambda overload resolution. The sync-
Result<T>-receiverBindAsync/MapAsync/TapAsync/CheckAsync/EnsureAsync/MatchAsyncTask-delegate overloads carry[OverloadResolutionPriority(1)]so inlineasynclambdas whose return type would otherwise be ambiguous betweenTask<Result<R>>andValueTask<Result<R>>resolve to the Task overload. Callers who specifically want the ValueTask overload still get it by passing a strongly-typedFunc<T, ValueTask<Result<R>>>delegate (the Task overload is not applicable, so priority is not consulted). The LINQSelectManyoverloads inTrellis.Core/src/Result/Extensions/Linq.*Right.csare an exception: the priority attribute is applied for documentation but does not currently disambiguate across the two distinct extension classes; LINQ query syntax over async Result composition still benefits from a typed local delegate when the async return type is ambiguous.
Map family — MapExtensions, MapExtensionsAsync, MapIfExtensions, MapOnFailureExtensions
Pure transformation of the success value (or failure error). Use Map when the lambda returns a plain value; switch to Bind when it returns a Result.
| Signature | Returns | Description |
|---|---|---|
public static Result<TOut> Map<TIn, TOut>(this Result<TIn> result, Func<TIn, TOut> func) |
Result<TOut> |
Synchronous map on Result<T>. The selector contract requires a non-null return for reference types — Map does not null-check the result and downstream stages will see a Result<TOut> carrying a null value. Use Bind if a step can legitimately produce no value. |
public static Task<Result<TOut>> MapAsync<TIn, TOut>(this Task<Result<TIn>> resultTask, Func<TIn, Task<TOut>> func) |
Task<Result<TOut>> |
MapExtensionsAsync exposes all Task/ValueTask × sync-/async-lambda combinations (6 overloads). |
public static Result<T> MapOnFailure<T>(this Result<T> result, Func<Error, Error> map) |
Result<T> |
Replaces the failure Error. |
public static Task<Result<T>> MapOnFailureAsync<T>(this Task<Result<T>> resultTask, Func<Error, Task<Error>> mapAsync) |
Task<Result<T>> |
MapOnFailureExtensions exposes all sync/Task/ValueTask combinations of MapOnFailure/MapOnFailureAsync. |
Task<Result<OrderDto>> Pipeline(OrderId id) =>
LoadOrderAsync(id)
.MapAsync(o => OrderDto.From(o))
.MapOnFailureAsync(e => e is Error.NotFound ? new Error.Gone(ResourceRef.For<Order>(id)) : e);
Tap and TapOnFailure families — TapExtensions, TapExtensionsAsync, TapOnFailureExtensions, TapOnFailureExtensionsAsync
Side effects without altering the result. Tap runs on success; TapOnFailure runs on failure.
| Signature | Returns | Description |
|---|---|---|
public static Result<TValue> Tap<TValue>(this Result<TValue> result, Action action) |
Result<TValue> |
Sync side effect on success when the callback does not need the carried value. |
public static Result<TValue> Tap<TValue>(this Result<TValue> result, Action<TValue> action) |
Result<TValue> |
Sync side effect on success. |
public static Task<Result<TValue>> TapAsync<TValue>(this Task<Result<TValue>> resultTask, Func<TValue, Task> func) |
Task<Result<TValue>> |
TapExtensionsAsync covers all sync/Task/ValueTask × value-/no-value-lambda combinations (12 overloads). |
public static Result<TValue> TapOnFailure<TValue>(this Result<TValue> result, Action<Error> action) |
Result<TValue> |
Sync side effect on failure. |
public static Task<Result<TValue>> TapOnFailureAsync<TValue>(this Task<Result<TValue>> resultTask, Func<Error, Task> func) |
Task<Result<TValue>> |
TapOnFailureExtensionsAsync covers all sync/Task/ValueTask × error-/no-arg-lambda combinations (12 non-tuple overloads; tuple arities 2–9 generate the same set per arity). |
Task<Result<Order>> Save(Order o) =>
repo.SaveAsync(o)
.TapAsync(saved => logger.LogInformationAsync($"saved {saved.Id}"))
.TapOnFailureAsync(err => logger.LogWarningAsync($"failed: {err.Code}"));
Match family — MatchExtensions, MatchExtensionsAsync, MatchTupleExtensions, MatchTupleExtensionsAsync
Terminal branching: produce a value (Match) or run side effects (Switch).
| Signature | Returns | Description |
|---|---|---|
public static TOut Match<TIn, TOut>(this Result<TIn> result, Func<TIn, TOut> onSuccess, Func<Error, TOut> onFailure) |
TOut |
Sync match on Result<T>. |
public static void Switch<TIn>(this Result<TIn> result, Action<TIn> onSuccess, Action<Error> onFailure) |
void |
Sync side-effect terminal. |
public static Task<TOut> MatchAsync<TIn, TOut>(this Task<Result<TIn>> resultTask, Func<TIn, Task<TOut>> onSuccess, Func<Error, Task<TOut>> onFailure) |
Task<TOut> |
MatchExtensionsAsync covers all sync/Task/ValueTask × sync-/async-/cancellation-lambda combinations (~10 overloads). |
public static Task SwitchAsync<TIn>(this Task<Result<TIn>> resultTask, Func<TIn, Task> onSuccess, Func<Error, Task> onFailure) |
Task |
SwitchAsync overloads cover Task and ValueTask, with optional CancellationToken variants. |
public static Task<TOut> MatchAsync<T1, T2, TOut>(this Result<(T1, T2)> result, Func<T1, T2, Task<TOut>> onSuccess, Func<Error, Task<TOut>> onFailure) |
Task<TOut> |
MatchTupleExtensions (sync) and MatchTupleExtensionsAsync (async) generate MatchAsync / SwitchAsync for tuple arities 2–9. |
IActionResult Render(Result<Order> r) =>
r.Match(
order => Ok(OrderDto.From(order)),
err => err switch
{
Error.NotFound nf => NotFound(nf.Resource.Id),
Error.InvalidInput u => UnprocessableEntity(u.Fields),
_ => Problem(err.GetDisplayMessage()),
});
Recover family — RecoverExtensions, RecoverExtensionsAsync, RecoverOnFailureExtensions, RecoverOnFailureExtensionsAsync
Convert failures back into successes (Recover) or chain a fallback result-producing operation (RecoverOnFailure).
| Signature | Returns | Description |
|---|---|---|
public static Result<TValue> Recover<TValue>(this Result<TValue> result, Func<Error, TValue> fallbackFunc) |
Result<TValue> |
Three sync overloads on RecoverExtensions: constant fallback, Func<TValue>, Func<Error, TValue>. |
public static Task<Result<TValue>> RecoverAsync<TValue>(this Task<Result<TValue>> resultTask, Func<Error, Task<TValue>> fallbackFunc) |
Task<Result<TValue>> |
RecoverExtensionsAsync covers all Task/ValueTask × sync-/async-lambda combinations. |
public static Result<T> RecoverOnFailure<T>(this Result<T> result, Func<Error, Result<T>> func) |
Result<T> |
Four sync overloads on RecoverOnFailureExtensions: with/without Error argument, with/without predicate gate. |
public static Task<Result<T>> RecoverOnFailureAsync<T>(this Task<Result<T>> resultTask, Func<Error, Task<Result<T>>> funcAsync) |
Task<Result<T>> |
RecoverOnFailureExtensionsAsync exposes ~16 overloads for Task/ValueTask × predicate-gated/ungated × value-/error-lambda. |
Task<Result<Settings>> Load(UserId id) =>
settingsRepo.LoadAsync(id)
.RecoverOnFailureAsync(
e => e is Error.NotFound,
err => Task.FromResult(Result.Ok(Settings.Defaults)));
Ensure family — EnsureExtensions, EnsureExtensionsAsync, EnsureAllExtensions, EnsureAllExtensionsAsync
Predicate-based validation. Ensure short-circuits on the first failed predicate; EnsureAll accumulates every failure via Error.Combine (homogeneous Error.InvalidInput failures merge into a single Error.InvalidInput; heterogeneous failures fold into Error.Aggregate) for applicative-style validation.
Static guards need no receiver. Result.Ensure(condition, errorFactory) returns
Result<Unit> and invokes the factory only on failure. Construct a fresh error inside
the factory to avoid unused allocation and validation metrics. The predicate and async-predicate
static overloads also accept Func<Error>. Factories run exactly once on a false result,
never while an async predicate is pending or after a predicate throws, faults, or cancels.
Null delegates throw ArgumentNullException before evaluating the predicate, even for a
passing guard. A factory returning null on failure also throws ArgumentNullException
(parameter error), consistent with Result.Fail and the value-threaded lazy Ensure.
Delegate exceptions and cancellation propagate; async failures are carried by the returned
task. Tracing retains the static guard's Ensure activity name and success/failure status.
The eager Error overloads have higher overload-resolution priority so existing bare
null-literal calls remain unambiguous and keep their existing behavior.
var guard = Result.Ensure(end != start, () =>
Error.InvalidInput.ForField(field: "end", code: ValidationCodes.ValueMustNotEqual,
args: ValidationArgs.Of("comparisonProperty", "start"), detail: "End must differ from start."));
| Signature | Returns | Description |
|---|---|---|
public static Result<TValue> Ensure<TValue>(this Result<TValue> result, Func<TValue, bool> predicate, Error error) |
Result<TValue> |
Sync ensure with predicate + Error. Five sync overloads (with/without value arg, factory error, embedded result). |
public static Result<TValue> Ensure<TValue>(this Result<TValue> result, Func<TValue, bool> predicate, Func<TValue, Error> errorPredicate) |
Result<TValue> |
Sync ensure with lazy error factory. |
public static Result<T> EnsureNotNull<T>(this Result<T?> result, Error error) where T : class |
Result<T> |
Reference-type EnsureNotNull overload that strips the nullable annotation. |
public static Result<T> EnsureNotNull<T>(this Result<T?> result, Error error) where T : struct |
Result<T> |
Value-type EnsureNotNull overload that unwraps the nullable. |
public static Task<Result<TValue>> EnsureAsync<TValue>(this Task<Result<TValue>> resultTask, Func<TValue, Task<bool>> predicate, Error error) |
Task<Result<TValue>> |
EnsureExtensionsAsync covers all Result<T>/Task<Result<T>>/ValueTask<Result<T>> receivers × Func<TValue, bool>/Task<bool>/ValueTask<bool> predicates × constant-, factory-, async-factory- and embedded-Result<TValue> error producers (~34 overloads across the six Ensure.* partial files). |
public static Result<TValue> EnsureAll<TValue>(this Result<TValue> result, params (Func<TValue, bool> predicate, Error error)[] checks) |
Result<TValue> |
Applicative validation: runs every check and folds failures via error.Combine(...) into one Error.Aggregate. |
public static Task<Result<TValue>> EnsureAllAsync<TValue>(this Task<Result<TValue>> resultTask, params (Func<TValue, bool> predicate, Error error)[] checks) |
Task<Result<TValue>> |
Task overload of EnsureAllAsync. |
public static ValueTask<Result<TValue>> EnsureAllAsync<TValue>(this ValueTask<Result<TValue>> resultTask, params (Func<TValue, bool> predicate, Error error)[] checks) |
ValueTask<Result<TValue>> |
ValueTask overload of EnsureAllAsync. |
public static Result<TValue> EnsureAll<TValue>(this Result<TValue> result, params (Func<TValue, bool> predicate, Func<TValue, Error> errorFactory)[] checks) |
Result<TValue> |
Lazy accumulation: each failed check calls its factory exactly once with the successful value; passing checks never create errors. |
public static Task<Result<TValue>> EnsureAllAsync<TValue>(this Task<Result<TValue>> resultTask, params (Func<TValue, bool> predicate, Func<TValue, Error> errorFactory)[] checks) |
Task<Result<TValue>> |
Awaits the receiver, then applies lazy accumulation. |
public static ValueTask<Result<TValue>> EnsureAllAsync<TValue>(this ValueTask<Result<TValue>> resultTask, params (Func<TValue, bool> predicate, Func<TValue, Error> errorFactory)[] checks) |
ValueTask<Result<TValue>> |
ValueTask receiver with lazy accumulation. |
Required nonblank strings
| Signature | Returns | Description |
|---|---|---|
public static Result<string> EnsureNotNullOrWhiteSpace(this string? str, Error error) |
Result<string> |
Rejects null/empty/whitespace with an existing error; returns valid strings unchanged. |
public static Result<string> EnsureNotNullOrWhiteSpace(this string? str, Func<Error> errorFactory) |
Result<string> |
Calls the factory exactly once only on null/empty/whitespace. |
public static Result<string> EnsureNotNullOrWhiteSpace(this string? str, string? fieldName, string? detail = null) |
Result<string> |
Creates one field violation: ValueNotNull for null, or ValueNotEmpty for empty/whitespace. |
EnsureNotNullOrWhiteSpace returns the original valid string without trimming.
Prefer the field/detail overload for standard nonblank fields, or a Func<Error>
that constructs a custom error inside its body; use static when no state is captured.
The field/detail form follows the framework's absent-versus-blank convention:
ValueNotNull for null and ValueNotEmpty for empty/whitespace, matching required
primitive validation. The eager and factory forms preserve the caller's error and
reason codes; use them when both cases intentionally share a custom error.
Neither lazy form creates errors or validation violations on success. The factory is required
even on success (ArgumentNullException, errorFactory); factory exceptions propagate,
and a factory returning null on failure throws ArgumentNullException naming error.
Field names follow ForField normalization: null/empty targets the root, simple names
are escaped, and full JSON Pointers are validated only on failure. The eager overload
retains higher resolution priority, so bare-null second arguments keep binding to
Error; use named fieldName: or errorFactory: to select a null argument intentionally.
Result<string> NotBlank(string? raw) =>
raw.EnsureNotNullOrWhiteSpace(fieldName: null);
Result<string> NotBlankWithCustomError(string? raw) =>
raw.EnsureNotNullOrWhiteSpace(static () => new Error.Forbidden("name.denied"));
Accumulating predicate validation
Both EnsureAll families preserve upstream failures (including persist-on-failure intent)
without invoking predicates or factories. Empty checks preserve the original result. A
null checks array throws; on success, null predicates/factories throw with their check
index. A factory returning null throws InvalidOperationException instead of silently
dropping the failure. Delegate exceptions propagate. Factories are synchronous; the async
overloads await the receiver, not the predicates or factories. The constant-error overloads
have higher overload-resolution priority, preserving existing EnsureAll(),
EnsureAll(null!), and null-error tuple calls.
Result<Quote> Validate(Quote q) =>
Result.Ok(q).EnsureAll(
(x => x.Total > 0, _ => Error.InvalidInput.ForField(field: "total", code: ValidationCodes.ValueGreaterThan)),
(x => x.Currency.Length == 3, _ => Error.InvalidInput.ForField(field: "currency", code: ValidationCodes.StringCurrencyCode)));
Nullable-task guards — EnsureExtensionsAsync
These extend nullable-value tasks directly, not Task<Result<T>>. Each awaits the
source exactly once with ConfigureAwait(false) and then calls static
Result.EnsureNotNull. Task input returns Task<Result<T>>; ValueTask input returns
ValueTask<Result<T>>. All six sync forms above have both receiver variants:
| Signature | Returns |
|---|---|
public static Task<Result<T>> EnsureNotNullAsync<T>(this Task<T?> task, Error error) where T : class |
Task<Result<T>> |
public static Task<Result<T>> EnsureNotNullAsync<T>(this Task<T?> task, Func<Error> errorFactory) where T : class |
Task<Result<T>> |
public static Task<Result<T>> EnsureNotNullAsync<T>(this Task<T?> task, string? fieldName, string? detail = null) where T : class |
Task<Result<T>> |
public static Task<Result<T>> EnsureNotNullAsync<T>(this Task<T?> task, Error error) where T : struct |
Task<Result<T>> |
public static Task<Result<T>> EnsureNotNullAsync<T>(this Task<T?> task, Func<Error> errorFactory) where T : struct |
Task<Result<T>> |
public static Task<Result<T>> EnsureNotNullAsync<T>(this Task<T?> task, string? fieldName, string? detail = null) where T : struct |
Task<Result<T>> |
public static ValueTask<Result<T>> EnsureNotNullAsync<T>(this ValueTask<T?> task, Error error) where T : class |
ValueTask<Result<T>> |
public static ValueTask<Result<T>> EnsureNotNullAsync<T>(this ValueTask<T?> task, Func<Error> errorFactory) where T : class |
ValueTask<Result<T>> |
public static ValueTask<Result<T>> EnsureNotNullAsync<T>(this ValueTask<T?> task, string? fieldName, string? detail = null) where T : class |
ValueTask<Result<T>> |
public static ValueTask<Result<T>> EnsureNotNullAsync<T>(this ValueTask<T?> task, Error error) where T : struct |
ValueTask<Result<T>> |
public static ValueTask<Result<T>> EnsureNotNullAsync<T>(this ValueTask<T?> task, Func<Error> errorFactory) where T : struct |
ValueTask<Result<T>> |
public static ValueTask<Result<T>> EnsureNotNullAsync<T>(this ValueTask<T?> task, string? fieldName, string? detail = null) where T : struct |
ValueTask<Result<T>> |
Null Task inputs throw ArgumentNullException (parameter task). Factories are
validated before awaiting, even if the source is pending. These argument exceptions surface through
the returned Task/ValueTask. Valid factories run only after a successful source
completion with null; source faults/cancellation propagate unchanged without invoking
the factory or constructing a required error. A default ValueTask<T?> completes
with null and therefore fails the guard. Null-error overload resolution and the
success/failure EnsureNotNull activity follow the sync guards.
Task<string?> nameTask = Task.FromResult<string?>("Ada");
Task<int?> quantityTask = Task.FromResult<int?>(0);
Task<Result<string>> labelTask = nameTask.EnsureNotNullAsync("name")
.CombineAsync(quantityTask.EnsureNotNullAsync("quantity"))
.MapAsync((name, quantity) => $"{name}:{quantity}");
Check / CheckIf families — CheckExtensions, CheckExtensionsAsync, CheckIfExtensions, CheckIfExtensionsAsync
Run a side-effect validator that returns its own Result/Result<TK> while preserving the upstream success value. CheckIf adds a conditional gate.
| Signature | Returns | Description |
|---|---|---|
public static Result<T> Check<T, TK>(this Result<T> result, Func<T, Result<TK>> func) |
Result<T> |
Validator returning Result<TK>; original value is preserved on success. For no-payload validators, return Result<Unit>. |
public static Task<Result<T>> CheckAsync<T, TK>(this Task<Result<T>> resultTask, Func<T, Task<Result<TK>>> func) |
Task<Result<T>> |
CheckExtensionsAsync covers all Task/ValueTask combinations. |
public static Result<T> CheckIf<T, TK>(this Result<T> result, bool condition, Func<T, Result<TK>> func) |
Result<T> |
Boolean-gated check; runs only when condition is true. |
public static Result<T> CheckIf<T, TK>(this Result<T> result, Func<T, bool> predicate, Func<T, Result<TK>> func) |
Result<T> |
Predicate-gated check. |
public static Task<Result<T>> CheckIfAsync<T, TK>(this Task<Result<T>> resultTask, bool condition, Func<T, Task<Result<TK>>> func) |
Task<Result<T>> |
CheckIfExtensionsAsync covers all Task/ValueTask × bool-/predicate-gated combinations. |
Result<Quote> q = Result.Ok(quote)
.Check(QuoteValidators.AllItemsInStock)
.CheckIf(quote.IsExpedited, QuoteValidators.HonorsCutoff);
Combine family — CombineExtensions, CombineExtensionsAsync, CombineErrorExtensions
Aggregates results into tuples (success-track) or merges errors via Error.Aggregate (failure-track). Tuple arities 2–9 are generated.
| Signature | Returns | Description |
|---|---|---|
public static Result<(T1, T2)> Combine<T1, T2>(this Result<T1> t1, Result<T2> t2) |
Result<(T1, T2)> |
Tuple combine; failures fold via Error.Combine (two Error.InvalidInput merge into one Error.InvalidInput; otherwise Error.Aggregate). When either operand is Result<Unit>, Unit becomes the next tuple element (use _ in the destructuring lambda to ignore it). |
public static Task<Result<(T1, T2)>> CombineAsync<T1, T2>(this Task<Result<T1>> tt1, Task<Result<T2>> tt2) |
Task<Result<(T1, T2)>> |
CombineExtensionsAsync covers every Task/ValueTask × Task/ValueTask combination. Task overloads validate null Task inputs before awaiting either side. |
public static Error Combine(this Error? left, Error right) |
Error |
On CombineErrorExtensions: if both errors are Error.InvalidInput, merges their Fields/Rules into a single Error.InvalidInput; otherwise combines into an Error.Aggregate (flattening nested aggregates). Treats null left as right. |
return Result.Combine(streetCity, contact)
.Map(_ => new Address(cmd.Street, cmd.City));
Discard family — DiscardExtensions, DiscardTaskExtensions, DiscardValueTaskExtensions
Drop the success value entirely (returns void/Task/ValueTask) for intentional fire-and-forget pipelines.
| Signature | Returns | Description |
|---|---|---|
public static void Discard<T>(this Result<T> result) |
void |
Documents intent that the success value is intentionally ignored. |
public static Task DiscardAsync<T>(this Task<Result<T>> resultTask) |
Task |
Awaits and discards; on DiscardTaskExtensions. |
public static ValueTask DiscardAsync<T>(this ValueTask<Result<T>> resultTask) |
ValueTask |
ValueTask variant on DiscardValueTaskExtensions. |
await SendEmailAsync(msg).DiscardAsync(); // intentionally fire-and-forget the value
AsUnit family — AsUnitExtensions
Async wrappers around Result<T>.AsUnit() that strip the value while preserving success/failure state.
| Signature | Returns | Description |
|---|---|---|
public static Task<Result<Unit>> AsUnitAsync<T>(this Task<Result<T>> resultTask) |
Task<Result<Unit>> |
Awaits and projects to Result<Unit>. |
public static ValueTask<Result<Unit>> AsUnitAsync<T>(this ValueTask<Result<T>> resultTask) |
ValueTask<Result<Unit>> |
ValueTask variant. |
Task<Result<Unit>> done = pipeline.RunAsync(input).AsUnitAsync();
Debug family — ResultDebugExtensions, ResultDebugExtensionsAsync
Non-allocating diagnostic taps gated by ResultDebugSettings. They never alter the result; they only emit through Debug.WriteLine / configured sinks.
| Signature | Returns | Description |
|---|---|---|
public static Result<TValue> Debug<TValue>(this Result<TValue> result, string message = "") |
Result<TValue> |
Logs success or failure with the optional message. |
public static Result<TValue> DebugDetailed<TValue>(this Result<TValue> result, string message = "") |
Result<TValue> |
Includes the success value and full error in the log. |
public static Result<TValue> DebugWithStack<TValue>(this Result<TValue> result, string message = "", bool includeStackTrace = true) |
Result<TValue> |
Adds the current stack trace. |
public static Result<TValue> DebugOnSuccess<TValue>(this Result<TValue> result, Action<TValue> action) |
Result<TValue> |
Custom sink invoked only on success. |
public static Result<TValue> DebugOnFailure<TValue>(this Result<TValue> result, Action<Error> action) |
Result<TValue> |
Custom sink invoked only on failure. |
public static Task<Result<TValue>> DebugAsync<TValue>(this Task<Result<TValue>> resultTask, string message = "") |
Task<Result<TValue>> |
ResultDebugExtensionsAsync mirrors every sync overload (DebugDetailedAsync, DebugWithStackAsync, DebugOnSuccessAsync, DebugOnFailureAsync) for Task<Result<T>> — including Func<T, Task> / Func<Error, Task> async sinks. |
return await LoadAsync(id)
.DebugAsync("after-load")
.BindAsync(ChargeAsync)
.DebugDetailedAsync("after-charge");
LINQ query-syntax family — ResultLinqExtensions, ResultLinqExtensionsTaskAsync, ResultLinqExtensionsTaskLeftAsync, ResultLinqExtensionsTaskRightAsync, ResultLinqExtensionsValueTaskAsync, ResultLinqExtensionsValueTaskLeftAsync, ResultLinqExtensionsValueTaskRightAsync
LINQ query expression support for Result<T>, Task<Result<T>>, and ValueTask<Result<T>>. The async overloads let from ... in ... clauses chain async result-producing operations directly — without await-ing each step into a sync block. Failures short-circuit subsequent steps with the same semantics as Bind / Map / Ensure.
| Signature | Returns | Description |
|---|---|---|
public static Result<TOut> Select<TIn, TOut>(this Result<TIn> result, Func<TIn, TOut> selector) |
Result<TOut> |
Sync Select — projects a successful value (delegates to Map). |
public static Result<TResult> SelectMany<TSource, TCollection, TResult>(this Result<TSource> source, Func<TSource, Result<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
Result<TResult> |
Sync SelectMany — enables multi-from chains. |
public static Result<TSource> Where<TSource>(this Result<TSource> source, Func<TSource, bool> predicate) |
Result<TSource> |
Sync Where — converts to a generic "filtered out" failure when the predicate is false. Prefer Ensure for meaningful errors. |
public static Task<Result<TOut>> Select<TIn, TOut>(this Task<Result<TIn>> resultTask, Func<TIn, TOut> selector) |
Task<Result<TOut>> |
Select over an async receiver. |
public static Task<Result<TResult>> SelectMany<TSource, TCollection, TResult>(this Task<Result<TSource>> source, Func<TSource, Task<Result<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
Task<Result<TResult>> |
SelectMany — async source, async continuation. |
public static Task<Result<TResult>> SelectMany<TSource, TCollection, TResult>(this Task<Result<TSource>> source, Func<TSource, Result<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
Task<Result<TResult>> |
SelectMany — async source, sync continuation (.Left). |
public static Task<Result<TResult>> SelectMany<TSource, TCollection, TResult>(this Result<TSource> source, Func<TSource, Task<Result<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
Task<Result<TResult>> |
SelectMany — sync source, async continuation (.Right). |
public static Task<Result<TSource>> Where<TSource>(this Task<Result<TSource>> source, Func<TSource, bool> predicate) |
Task<Result<TSource>> |
Where over an async receiver. |
public static ValueTask<Result<TOut>> Select<TIn, TOut>(this ValueTask<Result<TIn>> resultTask, Func<TIn, TOut> selector) |
ValueTask<Result<TOut>> |
Select over a ValueTask receiver. |
public static ValueTask<Result<TResult>> SelectMany<TSource, TCollection, TResult>(this ValueTask<Result<TSource>> source, Func<TSource, ValueTask<Result<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
ValueTask<Result<TResult>> |
SelectMany — ValueTask source and continuation. |
public static ValueTask<Result<TResult>> SelectMany<TSource, TCollection, TResult>(this ValueTask<Result<TSource>> source, Func<TSource, Result<TCollection>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
ValueTask<Result<TResult>> |
SelectMany — ValueTask source, sync continuation (.Left). |
public static ValueTask<Result<TResult>> SelectMany<TSource, TCollection, TResult>(this Result<TSource> source, Func<TSource, ValueTask<Result<TCollection>>> collectionSelector, Func<TSource, TCollection, TResult> resultSelector) |
ValueTask<Result<TResult>> |
SelectMany — sync source, ValueTask continuation (.Right). |
public static ValueTask<Result<TSource>> Where<TSource>(this ValueTask<Result<TSource>> source, Func<TSource, bool> predicate) |
ValueTask<Result<TSource>> |
Where over a ValueTask receiver. |
// All-async LINQ — Task<Result<T>> participates in query syntax directly.
var orderDto = await (
from user in GetUserAsync(id) // Task<Result<User>>
from order in GetOrderAsync(user) // Task<Result<Order>>
select new OrderDto(user, order));
// Mixed sync/async — sync source flows into an async continuation (.Right).
var summary = await (
from u in LoadCachedUser(id) // Result<User>
from o in FetchOrderAsync(u) // Task<Result<Order>>
select new Summary(u, o));
// And the reverse — async source with a sync validation step (.Left).
var validated = await (
from u in LoadUserAsync(id) // Task<Result<User>>
from p in ValidatePermissions(u) // Result<Permissions>
select new Authorized(u, p));
CancellationToken pattern. Closure-capture a
CancellationTokenfrom the surrounding method and callct.ThrowIfCancellationRequested()inside any async selector that needs to honor cancellation; the query expression itself does not introduce a token parameter.Exceptions. Exceptions thrown inside selectors propagate through the
await(matchingBindAsync/MapAsyncsemantics). They are not converted toResult.Fail.
Traverse — TraverseExtensions
Folds a sequence of inputs through a Result-producing selector into a single Result<IReadOnlyList<TOut>>.
| Signature | Returns | Description |
|---|---|---|
public static Result<IReadOnlyList<TOut>> Traverse<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, Result<TOut>> selector) |
Result<IReadOnlyList<TOut>> |
Sync traversal; short-circuits on the first failure. |
public static Task<Result<IReadOnlyList<TOut>>> TraverseAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, Task<Result<TOut>>> selector) |
Task<Result<IReadOnlyList<TOut>>> |
Async traversal; sequential evaluation. |
public static Task<Result<IReadOnlyList<TOut>>> TraverseAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, CancellationToken, Task<Result<TOut>>> selector, CancellationToken cancellationToken = default) |
Task<Result<IReadOnlyList<TOut>>> |
Cancellation-token overload. |
public static ValueTask<Result<IReadOnlyList<TOut>>> TraverseAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, ValueTask<Result<TOut>>> selector) |
ValueTask<Result<IReadOnlyList<TOut>>> |
ValueTask variant. |
public static ValueTask<Result<IReadOnlyList<TOut>>> TraverseAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, CancellationToken, ValueTask<Result<TOut>>> selector, CancellationToken cancellationToken = default) |
ValueTask<Result<IReadOnlyList<TOut>>> |
ValueTask + cancellation-token variant. |
public static Task<Result<Unit>> TraverseAsync<TIn>(this IEnumerable<TIn> source, Func<TIn, CancellationToken, Task<Result<Unit>>> selector, CancellationToken cancellationToken = default) |
Task<Result<Unit>> |
No-payload selector overload — short-circuits on the first failure and returns Result<Unit> for void-flavoured fan-out. |
public static Result<IReadOnlyList<T>> Sequence<T>(this IEnumerable<Result<T>> source) |
Result<IReadOnlyList<T>> |
Identity-selector form of Traverse. Lifts an IEnumerable<Result<T>> to Result<IReadOnlyList<T>>; short-circuits on the first failure. |
public static Result<Unit> Sequence(this IEnumerable<Result<Unit>> source) |
Result<Unit> |
No-payload Sequence overload for void-flavoured pipelines; short-circuits on the first failure. |
Task<Result<IReadOnlyList<Order>>> orders =
ids.TraverseAsync((id, ct) => repo.LoadAsync(id, ct), cancellationToken);
// Sequence: when you already have IEnumerable<Result<T>> from a Select.
Result<IReadOnlyList<Money>> subtotals =
lineItems.Select(item => item.ComputeSubtotal()).Sequence();
TraverseAll / SequenceAll — TraverseAllExtensions, SequenceAllExtensions
Accumulating-error counterparts to Traverse / Sequence. Run the selector over every item (no short-circuit) and fold failures via the existing Error.Combine extension. A single failure returns unchanged (no Error.Aggregate wrap); multiple InvalidInput failures merge their fields/rules; heterogeneous failures flatten into Error.Aggregate. Use these when you need to surface every failure (form-style validation) rather than the first.
| Signature | Returns | Description |
|---|---|---|
public static Result<IReadOnlyList<TOut>> TraverseAll<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, Result<TOut>> selector) |
Result<IReadOnlyList<TOut>> |
Accumulating sync traversal; folds every failure via Error.Combine. |
public static Result<IReadOnlyList<TOut>> TraverseAll<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, int, Result<TOut>> selector) |
Result<IReadOnlyList<TOut>> |
Indexed sync traversal; the selector receives the item's zero-based position in source enumeration order, including failed items. |
public static Task<Result<IReadOnlyList<TOut>>> TraverseAllAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, Task<Result<TOut>>> selector) |
Task<Result<IReadOnlyList<TOut>>> |
Accumulating async traversal; selectors are awaited sequentially. |
public static Task<Result<IReadOnlyList<TOut>>> TraverseAllAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, CancellationToken, Task<Result<TOut>>> selector, CancellationToken cancellationToken = default) |
Task<Result<IReadOnlyList<TOut>>> |
Accumulating async traversal with cancellation; mirrors TraverseAsync shape. |
public static ValueTask<Result<IReadOnlyList<TOut>>> TraverseAllAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, ValueTask<Result<TOut>>> selector) |
ValueTask<Result<IReadOnlyList<TOut>>> |
Accumulating ValueTask traversal for zero-allocation scenarios. |
public static ValueTask<Result<IReadOnlyList<TOut>>> TraverseAllAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, CancellationToken, ValueTask<Result<TOut>>> selector, CancellationToken cancellationToken = default) |
ValueTask<Result<IReadOnlyList<TOut>>> |
Accumulating ValueTask traversal with cancellation. |
public static Task<Result<Unit>> TraverseAllAsync<TIn>(this IEnumerable<TIn> source, Func<TIn, CancellationToken, Task<Result<Unit>>> selector, CancellationToken cancellationToken = default) |
Task<Result<Unit>> |
Accumulating Result<Unit> traversal with cancellation; void-flavoured pipelines. |
public static Task<Result<IReadOnlyList<TOut>>> TraverseAllAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, int, CancellationToken, Task<Result<TOut>>> selector, CancellationToken cancellationToken = default) |
Task<Result<IReadOnlyList<TOut>>> |
Indexed Task traversal; sequentially awaits every selector, forwarding its source index and token. |
public static ValueTask<Result<IReadOnlyList<TOut>>> TraverseAllAsync<TIn, TOut>(this IEnumerable<TIn> source, Func<TIn, int, CancellationToken, ValueTask<Result<TOut>>> selector, CancellationToken cancellationToken = default) |
ValueTask<Result<IReadOnlyList<TOut>>> |
Indexed ValueTask traversal with the same ordering, accumulation, and cancellation semantics. |
public static Task<Result<Unit>> TraverseAllAsync<TIn>(this IEnumerable<TIn> source, Func<TIn, int, CancellationToken, Task<Result<Unit>>> selector, CancellationToken cancellationToken = default) |
Task<Result<Unit>> |
Indexed no-payload Task traversal; returns no list of Unit values. |
public static Result<IReadOnlyList<T>> SequenceAll<T>(this IEnumerable<Result<T>> source) |
Result<IReadOnlyList<T>> |
Identity-selector accumulating sequence; visits every item, folds failures. |
public static Result<Unit> SequenceAll(this IEnumerable<Result<Unit>> source) |
Result<Unit> |
Accumulating Sequence over Result<Unit> for void-flavoured pipelines. |
// Form-style validation: collect every field error in one pass.
Result<IReadOnlyList<EmailAddress>> emails =
raw.TraverseAll(value => EmailAddress.TryCreate(value));
// ↳ on multiple invalid entries, returns one Error.InvalidInput
// whose Fields/Rules concatenate every per-item violation.
// Heterogeneous failures flatten into Error.Aggregate:
Result<IReadOnlyList<Order>> orders =
operations.SequenceAll(); // Result<NotFound> + Result<Conflict> → Error.Aggregate
// Original positions remain available for per-item validation pointers.
Result<IReadOnlyList<EmailAddress>> indexedEmails =
raw.TraverseAll((value, index) => EmailAddress.TryCreate(value,
InputPointer.Root.AppendProperty("emails").AppendIndex(index).Path));
Indexed traversal. The source is enumerated once, and indices start at zero for each call
and advance for every item, whether that item succeeds or fails. Empty input succeeds without
invoking the selector. Values and accumulated errors retain source order; a single error is
preserved unchanged, and persist-on-failure intent follows the existing aggregation rules.
An index beyond int.MaxValue throws OverflowException rather than wrapping.
Indexed async selectors always take (item, index, cancellationToken). The call-site token
is optional; ignore the third selector parameter with _ when it is not needed. There is
deliberately no indexed two-parameter async selector: it would collide with the existing
(item, cancellationToken) shape. Inline async lambdas prefer the indexed Task overload;
pass a ValueTask-returning delegate to select that overload.
Async selectors are awaited sequentially, not run in parallel. Cancellation is checked
before each invocation and the token is forwarded to the selector. Null sources/selectors
throw ArgumentNullException before enumeration; async overloads carry these exceptions
through their returned awaitables. Selector/enumeration exceptions and cancellation propagate
rather than becoming validation failures, abandoning accumulated state. The unindexed sync
overload has higher overload-resolution priority to preserve existing null-literal calls.
The unindexed TraverseAll family matches Traverse's full async surface: sync, Task, Task + CancellationToken, ValueTask, ValueTask + CancellationToken, plus a Task<Result<Unit>> + CancellationToken overload. Indexed traversal additionally supports sync, Task, ValueTask, and no-payload Task selectors as listed above. SequenceAll is sync-only because the existing Sequence is sync-only; if Sequence ever gains async siblings, SequenceAll follows at the same time.
When / WhenAll — WhenExtensions, WhenExtensionsAsync, WhenAllExtensionsAsync
Conditional execution and async fan-in.
| Signature | Returns | Description |
|---|---|---|
public static Result<T> When<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Result<T>> operation) |
Result<T> |
Runs operation only when the predicate holds. |
public static Result<T> Unless<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Result<T>> operation) |
Result<T> |
Inverse of When. |
public static Task<Result<T>> WhenAsync<T>(this Result<T> result, Func<T, bool> predicate, Func<T, Task<Result<T>>> operation) |
Task<Result<T>> |
WhenExtensionsAsync covers Task/ValueTask × predicate-/no-predicate × Result/Task-Result combinations for both WhenAsync and UnlessAsync. |
public static Task<Result<T>> UnlessAsync<T>(this Task<Result<T>> resultTask, Func<T, bool> predicate, Func<T, Task<Result<T>>> operation) |
Task<Result<T>> |
Async inverse-When (a bool condition overload also exists). |
public static Task<Result<(T1, T2)>> WhenAllAsync<T1, T2>(this (Task<Result<T1>> t1, Task<Result<T2>> t2) tasks) |
Task<Result<(T1, T2)>> |
WhenAllExtensionsAsync runs tasks concurrently via Task.WhenAll and folds the results. Tuple arities 2–9 are generated. |
Task<Result<(Profile, Preferences)>> bundle =
(LoadProfileAsync(id), LoadPreferencesAsync(id)).WhenAllAsync();
ToMaybe — ToMaybeExtensions, ToMaybeExtensionsAsync
Project a Result<T> to a Maybe<T> (failure → None).
| Signature | Returns | Description |
|---|---|---|
public static Maybe<TValue> ToMaybe<TValue>(this Result<TValue> result) where TValue : notnull |
Maybe<TValue> |
Sync projection. |
public static Task<Maybe<TValue>> ToMaybeAsync<TValue>(this Task<Result<TValue>> resultTask) where TValue : notnull |
Task<Maybe<TValue>> |
Awaits and projects. |
public static ValueTask<Maybe<TValue>> ToMaybeAsync<TValue>(this ValueTask<Result<TValue>> resultTask) where TValue : notnull |
ValueTask<Maybe<TValue>> |
ValueTask variant. |
Maybe<Order> maybe = await repo.TryLoadAsync(id).ToMaybeAsync();
Choosing a Result entry point
Core's unconstrained, no-argument ToResult lift and all twelve nullable-value
ToResult / ToResultAsync overloads have been removed. An omitted failure must not
silently wrap a nullable or Maybe<T> in a successful result.
| Intent | Use |
|---|---|
| Deliberately wrap a successful payload | Result.Ok(value); this does not check for null or inspect a wrapper's state. |
| Check a boolean condition without carrying a value | Result.Ensure(condition, errorFactory); returns Result<Unit>. Construct custom errors inside the callback. |
| Require a nullable reference or struct | Result.EnsureNotNull(value, fieldName, detail) for standard required fields; Result.EnsureNotNull(value, errorFactory) for custom failures. |
| Require a nonblank string without trimming | value.EnsureNotNullOrWhiteSpace(fieldName, detail) for ValueNotNull on null and ValueNotEmpty on blank; use errorFactory for a custom failure. |
| Require a nullable task result | task.EnsureNotNullAsync(fieldName, detail) / valueTask.EnsureNotNullAsync(errorFactory). |
Turn ordinary Maybe<T> absence into failure |
maybe.ToResult(errorFactory); Task/ValueTask ToResultAsync forms remain supported. |
The null guards preserve the reference or unwrap the struct, and lazy errors run
only on absence. Continue typed Combine / Map chains without !; async chains
use CombineAsync / MapAsync. See the exact
sync and
async guard contracts.
The eager Error overloads remain valid for existing/reused errors. A factory must
construct the error inside its body to avoid allocating an unused instance; use
static when no captured state is needed. Capturing callbacks can still allocate
closures, so lazy error construction is not a blanket allocation-free guarantee.
Migration edge cases. The guards validate factories before awaiting, whereas the
retired nullable async conversions validated them after awaiting. An invalid factory
therefore takes precedence over a pending, faulted, or cancelled source; the argument
exception surfaces through the returned Task/ValueTask. Null Task receivers now
produce ArgumentNullException naming task. Activities are named EnsureNotNull.
These are deliberate guard semantics, not a behavior-identical rename for invalid
arguments. The Maybe<T> conversion contracts are unchanged.
private ValueTask<Result<Order>> LoadOrderAsync(OrderId id, CancellationToken ct) =>
_orderRepository.FindByIdAsync(id, ct)
.EnsureNotNullAsync(() => new Error.NotFound(ResourceRef.For<Order>(id)));
Pagination
Storage-neutral request validation, typed continuation state, and page assembly. Cursor, PageSize, PageRequest, and Page<T> are sealed record classes, not structs: default is null, not a valid empty page or size. Use Page.Empty<T>(...) for an empty result. No pagination DI registration is required.
public sealed record Cursor
public sealed record Cursor
{
public Cursor(string token);
public string Token { get; }
public static Result<Cursor> TryCreate(string? token, string? fieldName = null);
}
Token is opaque: clients echo it unchanged. The trusted constructor throws ArgumentException for null/empty strings; TryCreate additionally rejects whitespace-only input with Error.InvalidInput, field fieldName ?? "cursor", reason cursor.malformed. Neither validates a codec-specific payload. Absence belongs to Cursor? / PageRequest; Cursor.TryCreate(null) is a failure.
public sealed record PageRequest
public sealed record PageRequest
{
public PageSize Size { get; }
public Cursor? Cursor { get; }
public static Result<PageRequest> TryCreate(
string? cursor, int? limit,
int max = PageSize.Max, int defaultSize = PageSize.Default,
PageSizeLimitPolicy policy = PageSizeLimitPolicy.Clamp,
string? cursorFieldName = null, string? limitFieldName = null);
public Result<Maybe<TState>> Decode<TState>(
ICursorCodec<TState> codec, string? fieldName = null)
where TState : notnull;
}
There is no public constructor. TryCreate validates size first, then token presence/shape. Missing (null) cursor means the first page; an empty or whitespace cursor fails rather than restarting pagination. Missing limit uses the configured default; zero and negative limits fail. Explicit above-cap limits clamp by default; opt into PageSizeLimitPolicy.Reject to reject instead. Fields default to "cursor" and "limit" respectively.
Decode(codec) returns success with Maybe<TState>.None for the first page and decodes only supplied cursors through CursorCodec.TryDecodeOptional. It preserves codec failures; a codec returning successful null state violates its contract and throws InvalidOperationException, rather than silently becoming first-page absence. Pass fieldName again when decoding a differently named cursor field: field-name overrides are not stored in the request. A null codec throws ArgumentNullException.
public sealed record PageSize
public sealed record PageSize
{
public const int Default = 50;
public const int Max = 100;
public const int MaxApplied = int.MaxValue - 1;
public PageSize(int requested, int applied);
public int Requested { get; }
public int Applied { get; }
public bool WasCapped { get; }
public static PageSize FromRequested(int? requested, int max = Max);
public static Result<PageSize> TryCreate(
int? requested, int max = Max, string? fieldName = null,
PageSizeLimitPolicy policy = PageSizeLimitPolicy.Clamp,
int defaultSize = Default);
}
public enum PageSizeLimitPolicy { Clamp, Reject }
| Input / member | Behavior |
|---|---|
| Missing requested size | Requested = defaultSize, Applied = min(defaultSize, max), including under Reject. |
Explicit size <= 0 |
Failed result, Error.InvalidInput, reason page-size.out-of-range, field fieldName ?? "pageSize". |
Explicit size above max |
Clamp preserves Requested and caps Applied; Reject returns the same field-specific reason code. |
WasCapped |
Applied < Requested; survives page assembly and DTO projection. |
PageSize(int requested, int applied) |
Trusted constructor: requested > 0, 0 < applied <= requested, and applied <= MaxApplied; otherwise ArgumentOutOfRangeException. |
FromRequested(int?, int) |
Trusted, throwing convenience using the same default/clamp policy as TryCreate. Non-positive input throws, rather than becoming the default. Use TryCreate / PageRequest.TryCreate for request data. |
| Server configuration | Invalid max (<= 0 or > MaxApplied), non-positive defaultSize, or undefined policy throws ArgumentOutOfRangeException; these are not client failures. |
The MaxApplied bound keeps Applied + 1 safe for over-fetching. Requested may be larger than MaxApplied when the applied size is capped safely.
public sealed record Page<T>
public sealed record Page<T>
{
public Page(
IReadOnlyList<T> Items, Cursor? Next, Cursor? Previous,
int RequestedLimit, int AppliedLimit);
public IReadOnlyList<T> Items { get; }
public Cursor? Next { get; }
public Cursor? Previous { get; }
public int RequestedLimit { get; }
public int AppliedLimit { get; }
public int DeliveredCount { get; }
public bool WasCapped { get; }
public Page<TOut> Map<TOut>(Func<T, TOut> selector);
}
The constructor snapshots items into immutable storage; equality/hash code use item sequence contents, both cursors, and both limits. Null Items throws ArgumentNullException. Limits must be positive, AppliedLimit <= RequestedLimit, and AppliedLimit <= PageSize.MaxApplied; violations throw ArgumentOutOfRangeException. DeliveredCount is Items.Count; WasCapped means AppliedLimit < RequestedLimit.
Next and Previous are independently optional. Construct a page directly when a provider supplies continuation tokens — wrap each present provider token in Cursor and do not decode it with a Trellis codec or manufacture an over-fetch batch. A provider may supply a continuation even with no items; item count alone does not establish exhaustion for such stores.
public static class Page
| Signature | Returns | Description |
|---|---|---|
public static Page<T> Empty<T>(int requestedLimit, int appliedLimit) |
Page<T> |
Validated empty page with neither cursor. |
Page<T>.Map<TOut>
public Page<TOut> Map<TOut>(Func<T, TOut> selector) projects items while preserving Next, Previous, RequestedLimit, and AppliedLimit. A null selector throws ArgumentNullException. It does not encode new cursors from projected DTOs.
ICursorCodec<TState>
public interface ICursorCodec<TState> where TState : notnull
{
Cursor Encode(TState state);
Result<TState> TryDecode(Cursor? cursor, string? fieldName = null);
}
The state is the complete continuation boundary, not necessarily an ID. Implementations own serialization, validation, and any query-context binding/protection. TryDecode(null) on a built-in codec is malformed; use PageRequest.Decode to model optional absence.
public static class CursorCodec
public static class CursorCodec
{
public const int MaxEncodedTokenLength = 1024;
public static ICursorCodec<T> Scalar<T>() where T : notnull, IParsable<T>;
public static ICursorCodec<(TPrimary Primary, TSecondary Secondary)>
Composite<TPrimary, TSecondary>()
where TPrimary : notnull, IParsable<TPrimary>
where TSecondary : notnull, IParsable<TSecondary>;
public static ICursorCodec<(TPrimary Primary, TSecondary Secondary)>
Composite<TPrimary, TSecondary>(
ICursorCodec<TPrimary> primary, ICursorCodec<TSecondary> secondary)
where TPrimary : notnull where TSecondary : notnull;
public static ICursorCodec<TState> Create<TState>(
string schema, Func<TState, string> format,
Func<string, string?, Result<TState>> parse) where TState : notnull;
public static ICursorCodec<TState> Map<TWire, TState>(
ICursorCodec<TWire> wireCodec, Func<TState, TWire> toWire,
Func<TWire, string?, Result<TState>> fromWire)
where TWire : notnull where TState : notnull;
public static Cursor Encode<T>(T id) where T : notnull, IParsable<T>;
public static Cursor Encode<TPrimary, TSecondary>(TPrimary primary, TSecondary secondary)
where TPrimary : notnull, IParsable<TPrimary>
where TSecondary : notnull, IParsable<TSecondary>;
public static Result<T> TryDecode<T>(Cursor? cursor, string? fieldName = null)
where T : notnull, IParsable<T>;
public static Result<Maybe<TState>> TryDecodeOptional<TState>(
Cursor? cursor, ICursorCodec<TState> codec, string? fieldName = null)
where TState : notnull;
public static Result<(TPrimary Primary, TSecondary Secondary)>
TryDecodeComposite<TPrimary, TSecondary>(Cursor? cursor, string? fieldName = null)
where TPrimary : notnull, IParsable<TPrimary>
where TSecondary : notnull, IParsable<TSecondary>;
public static Result<(DateTimeOffset CreatedAt, TKey Id)> TryDecodeComposite<TKey>(
Cursor? cursor, string? fieldName = null) where TKey : notnull, IParsable<TKey>;
}
| Factory | Contract |
|---|---|
Scalar<T>() |
Invariant parsing/formatting. Requires IParsable<T> at compile time and IFormattable (or string) at factory creation; unsupported formatting throws NotSupportedException before a query runs. Generated scalar IDs satisfying both contracts work; otherwise project .Value or supply an explicit codec. |
Composite<TPrimary,TSecondary>() |
Uses scalar codecs for any supported primary/secondary types, not only timestamps and IDs. |
Composite(primary, secondary) |
Composes arbitrary codecs, including nested composites. Each component is encoded by its own codec; length-prefix framing keeps delimiters inside strings unambiguous. |
Create(schema, format, parse) |
Explicit serializer/parser, not an implicit JSON fallback. Schema must be 1–64 ASCII letters, digits, or hyphens. The parser validates domain bounds/context and returns field-specific invalid-input errors for bad client state. |
Map(wireCodec, toWire, fromWire) |
Maps a wire representation into named, validated domain state without rewriting serialization. fromWire runs on decode and during encode validation. See Recipe 40. |
TryDecodeOptional(cursor, codec, fieldName) is the shared optional-state adapter used by PageRequest.Decode and EF pagination: only a null cursor means Maybe<TState>.None; otherwise codec failures propagate and successful non-null state becomes present. A null codec throws ArgumentNullException; a codec returning successful null state throws InvalidOperationException. This is distinct from scalar TryDecode, which rejects an absent cursor.
Wire contract. The entire UTF-8 frame is URL-safe base64 without padding. Decoded frames start with 1:s: (scalar), 1:c: (composite), or 1:x-<schema>: (explicit codec). Composite payloads contain the decimal length of the first component token, :, that token, and the second component token. Nesting is supported within the overall size limit. Old unversioned tokens are intentionally rejected; no migration decoder is provided.
Round-trip contract. Encode parses the emitted state and verifies equality against the input; invalid server state or a lossy formatter throws ArgumentException instead of emitting a cursor that cannot continue correctly. Use state with appropriate value equality (for example a record). Scalar floating-point values must be finite; NaN and infinities are rejected. DateTime / DateTimeOffset use round-trip "O" formatting and parsing to preserve ticks and kind/offset, not a rounded display timestamp. Built-in codecs enforce MaxEncodedTokenLength = 1024 on both encoding and decoding, including the outer frame of nested composites.
Failure boundary. Malformed base64, invalid UTF-8/framing/version, oversized input, and unparseable scalar state return Error.InvalidInput with cursor.malformed on fieldName ?? "cursor". Custom parsers must return failures for expected bad input; arbitrary parser/formatter and infrastructure exceptions are not silently converted to input errors. A parser, mapped-state converter, or component codec returning successful null state throws InvalidOperationException on decode or encode validation: this is a broken codec contract, not invalid client input or first-page absence. Create, Map, and composite decoding enforce that distinction.
Opacity is not protection. Built-ins are AOT-friendly and require no JSON serializer, but do not sign or encrypt tokens. Context binding (tenant, filters, origin, sort direction, algorithm version) belongs to the application codec. Wrap/replace ICursorCodec<TState> for signing/protection; authorization filters still apply to every query.
public static class PageBuilder
public static class PageBuilder
{
public static Page<T> FromOverFetch<T>(
IReadOnlyList<T> overFetched, PageSize pageSize,
Func<T, Cursor> cursorSelector);
}
Pure assembly only. The caller filters, seeks, orders, and fetches pageSize.Applied + 1 rows first. The builder keeps at most Applied rows and invokes cursorSelector on the last retained item exactly once, only when more rows exist. On an empty, short, or exactly full final batch, the callback is never called and Next is null. Previous is always null. A null argument throws ArgumentNullException; a callback returning null throws InvalidOperationException.
The callback returns a Cursor, not a raw key. There are no scalar-key or timestamp-selector overloads:
var codec = CursorCodec.Composite<DateTimeOffset, Guid>();
var page = PageBuilder.FromOverFetch(
orderedAndSoughtRows, pageSize,
row => codec.Encode((row.CreatedAt, row.Id)));
The encoded boundary must match the upstream ordering and tie-breaker. Use EF SeekDefinition / PaginationQueryableExtensions for provider-translatable ordering/seek, or Recipe 40 for application-owned computed pagination. Provider-owned continuation tokens should use the direct Page<T> constructor instead.
Wire shape. Trellis.Asp maps Result<Page<T>> to 200 OK with a JSON envelope and RFC 8288 Link header; see pagination response mapping. Neither Core assembly nor EF helpers implement reverse-seek, signing, geospatial queries, or snapshot isolation. A direct Page<T> can still preserve a provider-supplied Previous.
Error Cases (closed ADT)
| Case | Constructor | Code | Kind slug |
|---|---|---|---|
Error.InvalidInput |
(EquatableArray<FieldViolation> Fields, EquatableArray<RuleViolation> Rules = default) |
error.unspecified (reasons live on the violations) |
invalid-input |
Error.InvariantViolation |
(string Code, ResourceRef? Resource = null) |
required, positional | invariant-violation |
Error.NotFound |
(ResourceRef Resource) |
optional, { Code = ... } or the factories' leading code argument |
not-found |
Error.Forbidden |
(string Code, ResourceRef? Resource = null) |
required, positional; also readable as PolicyId |
forbidden |
Error.Conflict |
(string Code, ResourceRef? Resource = null) — plus [JsonIgnore] init-only ConstraintName/ConstraintTableName (telemetry-only, set by EF Core helpers such as TryInsertUniqueAsync) |
required, positional | conflict |
Error.Gone |
(ResourceRef Resource) |
optional, { Code = ... } or the factories' leading code argument |
gone |
Error.AuthenticationRequired |
(string? Scheme = null) |
optional, { Code = ... } |
authentication-required |
Error.Unavailable |
(RetryAdvice? Retry = null) |
optional, { Code = ... } |
unavailable |
Error.RateLimited |
(RetryAdvice? Retry = null) |
optional, { Code = ... } |
rate-limited |
Error.Unexpected |
(string Code, string? FaultId = null) |
required, positional | unexpected |
Error.Aggregate |
(EquatableArray<Error> Errors) (IEnumerable<Error> errors) (params Error[] errors) |
error.unspecified (reasons live on the children) |
aggregate |
Error.TransportFault |
(ITransportFault Fault) |
the wrapped ICodedTransportFault's code, else error.unspecified |
transport-fault |
Examples
Result flow
using Trellis;
Result<int> Divide(int left, int right) =>
Result.Ensure(right != 0, () => Error.InvalidInput.ForRule(code: "divisor.must-not-be-zero", detail: "Right operand must not be zero"))
.Map(_ => left / right);
Maybe to Result
using Trellis;
Maybe<string> maybeEmail = Maybe.From("user@example.com");
Result<string> emailResult = maybeEmail.ToResult(
() => Error.InvalidInput.ForField(field: "email", code: ValidationCodes.ValueNotNull, detail: "Email is required"));
Reading errors without throwing
using Trellis;
Result<Order> result = await mediator.SendAsync(new PlaceOrder(...));
// Pattern-matching: result.Error is null on success, never throws
if (!result.TryGetValue(out var order, out var error))
{
return error switch
{
Error.NotFound nf => NotFound(nf.Resource.Id),
Error.InvalidInput uc => UnprocessableEntity(uc.Fields),
Error.Conflict c => Conflict(c.Code),
_ => Problem(error.GetDisplayMessage()),
};
}
return Ok(order);
Multi-field validation
using Trellis;
var streetCity = MaybeInvariant.AllOrNone(cmd.Street, cmd.City, "street", "city");
var contact = MaybeInvariant.ExactlyOne(cmd.Email, cmd.Phone, "email", "phone");
// Combine merges any InvalidInput.Fields/Rules from multiple results
return Result.Combine(streetCity, contact)
.Map(_ => new Address(cmd.Street, cmd.City));
Domain-Driven Design
The DDD primitives (Aggregate<T>, Entity<T>, ValueObject, Specification<T>, ...) live in Trellis.Core. They share the Trellis namespace.
Types
IEntity
public interface IEntity
| Name | Type | Description |
|---|---|---|
CreatedAt |
DateTimeOffset |
UTC timestamp for the first successful persistence of the entity. |
LastModified |
DateTimeOffset |
UTC timestamp for the latest successful persistence update. |
Entity<TId>
public abstract class Entity<TId> : IEntity where TId : notnull
| Name | Type | Description |
|---|---|---|
Id |
TId |
Immutable identity value for the entity. |
CreatedAt |
DateTimeOffset |
Infrastructure-managed creation timestamp. |
LastModified |
DateTimeOffset |
Infrastructure-managed last-modified timestamp. |
| Signature | Returns | Description |
|---|---|---|
protected Entity(TId id) |
— | Initializes the entity identity. |
public override bool Equals(object? obj) |
bool |
Returns true for the same reference before checking default IDs; otherwise compares exact runtime type and non-default IDs. |
public static bool operator ==(Entity<TId>? a, Entity<TId>? b) |
bool |
Identity-based equality operator. |
public static bool operator !=(Entity<TId>? a, Entity<TId>? b) |
bool |
Identity-based inequality operator. |
public override int GetHashCode() |
int |
Combines runtime type and Id. |
IAggregate
public interface IAggregate : IChangeTracking
| Name | Type | Description |
|---|---|---|
ETag |
string |
Optimistic concurrency token for the aggregate. |
IsChanged |
bool |
Inherited from IChangeTracking; implemented by Aggregate<TId> as domain-event-based change tracking by default. |
| Signature | Returns | Description |
|---|---|---|
IReadOnlyList<IDomainEvent> UncommittedEvents() |
IReadOnlyList<IDomainEvent> |
Returns the domain events raised since the last AcceptChanges(). |
void AcceptChanges() |
void |
Inherited from IChangeTracking; marks the aggregate as committed. |
Aggregate<TId>
public abstract class Aggregate<TId> : Entity<TId>, IAggregate, IETagStampable, IReconstitutionStampable where TId : notnull
| Name | Type | Description |
|---|---|---|
DomainEvents |
List<IDomainEvent> |
Protected mutable event buffer for derived aggregate methods. |
ETag |
string |
Persistence-managed optimistic concurrency token. |
IsChanged |
bool |
[JsonIgnore] virtual change-tracking flag; default implementation is DomainEvents.Count > 0. |
| Signature | Returns | Description |
|---|---|---|
protected Aggregate(TId id) |
— | Initializes the aggregate identity. |
public IReadOnlyList<IDomainEvent> UncommittedEvents() |
IReadOnlyList<IDomainEvent> |
Returns a read-only snapshot of current domain events. |
public void AcceptChanges() |
void |
Clears DomainEvents. |
IETagStampable
public interface IETagStampable
Persistence-infrastructure seam for stamping an aggregate's optimistic-concurrency token (IAggregate.ETag) without reflection. Aggregate<TId> implements it explicitly, so the method stays off the domain surface and is reachable only by casting to IETagStampable. The EF Core integration stamps the ETag through its change-tracker interceptor; non-EF persistence adapters (Dapper, raw ADO, Cosmos SDK) use this seam instead — on load to restore the stored token, on save to apply a fresh one.
| Signature | Returns | Description |
|---|---|---|
void StampETag(string etag) |
void |
Sets IAggregate.ETag to etag, which must be a valid unquoted RFC 9110 opaque tag (e.g. Guid.NewGuid().ToString("N")). Throws ArgumentNullException for null and ArgumentException for empty/whitespace or non-opaque-tag characters. |
IReconstitutionStampable
public interface IReconstitutionStampable
Persistence-infrastructure seam for restoring a reconstituted aggregate's persistence-managed metadata (audit timestamps and the optimistic-concurrency token) and clearing its uncommitted domain events, through an explicit infra-only method. Aggregate<TId> implements it explicitly. The aggregate author rebuilds domain state via its own Reconstitute(...) factory (private constructor + assigning get-only properties and private child collections); a non-EF adapter then casts to IReconstitutionStampable to restore the infrastructure metadata it loaded from storage. Post-conditions: timestamps and ETag restored, no uncommitted domain events (so IsChanged == false under the default event-based change tracking). The EF Core integration does the equivalent via its materializer and interceptors.
| Signature | Returns | Description |
|---|---|---|
void StampReconstitutedState(DateTimeOffset createdAt, DateTimeOffset lastModified, string etag) |
void |
Restores audit timestamps and the optimistic-concurrency token, and clears uncommitted domain events. etag must be a valid unquoted RFC 9110 opaque tag; throws ArgumentNullException for null and ArgumentException for empty/whitespace or non-opaque-tag characters. |
IDomainEvent
public interface IDomainEvent
| Name | Type | Description |
|---|---|---|
OccurredAt |
DateTimeOffset |
Timestamp (with explicit UTC offset) for when the domain event occurred. |
IIntegrationEvent
public interface IIntegrationEvent
Represents an integration event - the stable, published contract a bounded context emits to the outside world (other services or bounded contexts), as distinct from an IDomainEvent which stays inside the context and is raised by aggregates.
Domain events are internal: they are raised by aggregates, dispatched in-process to IDomainEventHandler<T>, and free to expose the domain's ubiquitous language because only the owning context observes them. Integration events are external: they are versioned wire contracts other systems depend on, so they should be deliberately shaped, stable, and free of internal domain types.
Integration events are typically translated from domain events: a domain-event handler observes a domain event and produces one or more integration events describing the same business fact in contract terms. Publish them through the transactional outbox so external delivery is atomic with the state change and survives a crash. The outbox relays integration events through IIntegrationEventPublisher, whose default implementation fans out to in-process IIntegrationEventHandler<T> registrations and can be replaced with a message-broker adapter.
| Name | Type | Description |
|---|---|---|
OccurredAt |
DateTimeOffset |
Timestamp (with explicit UTC offset) for when the business fact this integration event describes occurred. Use this as the single event timestamp. |
IntegrationEventNameAttribute
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class IntegrationEventNameAttribute(string name) : Attribute
Declares the stable, on-the-wire name of an IIntegrationEvent so producers and consumers in different services can agree on the type a message carries.
[IntegrationEventName("orders.order-placed.v1")]
public sealed record OrderPlaced(Guid OrderId, DateTimeOffset OccurredAt) : IIntegrationEvent;
In-process relaying identifies an event by Type.AssemblyQualifiedName, which is fine while producer and consumer share a process. On a message broker it is unusable: the consumer's assemblies differ from the producer's, and the string embeds an assembly version, so it can stop resolving after a routine version bump. A logical name is owned by the contract rather than by the CLR layout, so each side maps it to whatever local type it likes.
Choose a versioned name. Once a message carrying the name exists on a queue or in another team's code, changing it is a breaking change. Include a version segment so an incompatible payload change can ship as a new name consumed side-by-side rather than as a silent break.
Broker transports resolve the name through IntegrationEventNameMap. Names compare with the ordinal comparer — casing is significant.
| Name | Type | Description |
|---|---|---|
Name |
string |
The stable wire name identifying this event's contract. |
ITrackedAggregateSource
public interface ITrackedAggregateSource
Sidecar contract for a unit-of-work that snapshots the aggregates it persisted during the most recent successful commit. Read by Trellis.Mediator.TrackedAggregateDomainEventDispatchBehavior<,> so it can drain events from each tracked aggregate after the commit succeeds, regardless of the command's response shape. Trellis.EntityFrameworkCore.EfUnitOfWork<TContext> implements this interface; custom IUnitOfWork implementations that need to compose with the tracked dispatcher must also implement it (AddTrellisUnitOfWork<TContext>() forwards via cast from IUnitOfWork and throws InvalidOperationException at first resolve if the cast fails).
| Name | Type | Description |
|---|---|---|
CommittedAggregates |
IReadOnlyList<IAggregate> |
The aggregates the unit-of-work persisted on its most recent successful commit. Empty before any commit, empty after a failed or thrown commit (cleared before save and only repopulated on success), and unchanged during deferred nested commits — only the outermost commit owns the snapshot. |
ValueObject
public abstract class ValueObject : IComparable<ValueObject>, IComparable, IEquatable<ValueObject>
| Name | Type | Description |
|---|---|---|
| — | — | No public or protected properties. Equality and ordering are driven by methods. |
| Signature | Returns | Description |
|---|---|---|
protected abstract void GetEqualityComponents(ref EqualityComponents components) |
void |
Appends the ordered components used for equality, comparison, and hash-code generation onto components. Components at the same position with different runtime types are ordered by type name before same-typed components use their native comparison. Allocation-free: the base class supplies stack storage for up to 8 components and rents from ArrayPool<IComparable?>.Shared beyond that. |
protected static IComparable? MaybeComponent<T>(Maybe<T> maybe) where T : notnull, IComparable |
IComparable? |
Converts Maybe<T> to an equality component by returning the inner value or null. Prefer components.Add(maybe), which does the same conversion inline. |
public override bool Equals(object? obj) |
bool |
Delegates to Equals(ValueObject? other). |
public bool Equals(ValueObject? other) |
bool |
Structural equality check against the same runtime type. |
public override int GetHashCode() |
int |
Computes and caches a hash code from the equality components. |
public virtual int CompareTo(ValueObject? other) |
int |
Compares equality components in order. Null sorts before non-null (value.CompareTo(null) > 0). Throws ArgumentException for a non-null value object of a different runtime type. |
public static bool operator ==(ValueObject? a, ValueObject? b) |
bool |
Structural equality operator. |
public static bool operator !=(ValueObject? a, ValueObject? b) |
bool |
Structural inequality operator. |
public static bool operator <(ValueObject? left, ValueObject? right) |
bool |
Ordering operator based on CompareTo(ValueObject?): null sorts first; different non-null runtime types throw ArgumentException. |
public static bool operator <=(ValueObject? left, ValueObject? right) |
bool |
Ordering operator based on CompareTo(ValueObject?): null sorts first; different non-null runtime types throw ArgumentException. |
public static bool operator >(ValueObject? left, ValueObject? right) |
bool |
Ordering operator based on CompareTo(ValueObject?): null sorts first; different non-null runtime types throw ArgumentException. |
public static bool operator >=(ValueObject? left, ValueObject? right) |
bool |
Ordering operator based on CompareTo(ValueObject?): null sorts first; different non-null runtime types throw ArgumentException. |
EqualityComponents
public ref struct EqualityComponents
The sink passed to ValueObject.GetEqualityComponents(ref EqualityComponents). It is a ref struct, so it cannot be boxed, captured in a lambda, stored in a field, or used across an await — collect components and return.
| Signature | Returns | Description |
|---|---|---|
public void Add(IComparable? component) |
void |
Appends one component. Order is significant: equality compares positionally and CompareTo returns on the first differing position. |
public void Add<T>(Maybe<T> component) where T : notnull, IComparable |
void |
Appends an optional component, adding the inner value when present and null when empty. |
public readonly int Count |
int |
Number of components added so far. |
public sealed class Address(string street, string city) : ValueObject
{
public string Street { get; } = street;
public string City { get; } = city;
public Maybe<string> Apartment { get; private set; } = Maybe<string>.None;
protected override void GetEqualityComponents(ref EqualityComponents components)
{
components.Add(Street);
components.Add(City);
components.Add(Apartment);
}
}
Derived types forward to their base before adding their own components, so base components keep the lower positions:
protected override void GetEqualityComponents(ref EqualityComponents components)
{
base.GetEqualityComponents(ref components);
components.Add(Country);
}
Breaking change.
GetEqualityComponentspreviously returnedIEnumerable<IComparable?>and was written withyield return. The iterator allocated on everyEquals,CompareTo, and uncachedGetHashCodecall. Migrate by changing the signature toprotected override void GetEqualityComponents(ref EqualityComponents components), replacing eachyield return X;withcomponents.Add(X);, and replacingforeach (var c in base.GetEqualityComponents()) yield return c;withbase.GetEqualityComponents(ref components);. Equality and ordering results are unchanged, and the hash combination algorithm is unchanged with one edge case: the hash cache now uses0as its "not yet computed" sentinel, so an instance whose components combine to exactly0caches and returns1instead. Equal instances still produce equal hash codes.GetHashCodevalues were never stable across processes (HashCode.Combineandstring.GetHashCodeare seeded per process), so they must not be persisted or asserted as literals.
ScalarValueObject<TSelf, T>
public abstract class ScalarValueObject<TSelf, T> : ValueObject, IConvertible, IFormattable
where TSelf : ScalarValueObject<TSelf, T>, IScalarValue<TSelf, T>
where T : IComparable
| Name | Type | Description |
|---|---|---|
Value |
T |
Wrapped scalar value. |
| Signature | Returns | Description |
|---|---|---|
protected ScalarValueObject(T value) |
— | Stores the wrapped scalar value. |
protected override void GetEqualityComponents(ref EqualityComponents components) |
void |
Default scalar equality uses only Value. |
public override string ToString() |
string |
Returns Value?.ToString() ?? string.Empty. |
public static implicit operator T(ScalarValueObject<TSelf, T> valueObject) |
T |
Unwraps the scalar value object to its primitive value. |
public static TSelf Create(T value) |
TSelf |
Calls TSelf.TryCreate(value) and throws InvalidOperationException on failure. This is the concrete-type dispatch entry point — invoked when the call site names a derived class (e.g. EmailAddress.Create("…")). The IScalarValue<TSelf, TPrimitive>.Create(TPrimitive) static-virtual member on the interface is the generic-constraint dispatch entry point used from generic code (T.Create(value) where T : IScalarValue<T, P>); the two methods coexist because C# does not route concrete-type calls through interface static-virtual defaults. |
public TypeCode GetTypeCode() |
TypeCode |
Returns Type.GetTypeCode(typeof(T)). |
public bool ToBoolean(IFormatProvider? provider) |
bool |
Converts Value with Convert.ToBoolean. |
public byte ToByte(IFormatProvider? provider) |
byte |
Converts Value with Convert.ToByte. |
public char ToChar(IFormatProvider? provider) |
char |
Converts Value with Convert.ToChar. |
public DateTime ToDateTime(IFormatProvider? provider) |
DateTime |
Converts Value with Convert.ToDateTime. |
public decimal ToDecimal(IFormatProvider? provider) |
decimal |
Converts Value with Convert.ToDecimal. |
public double ToDouble(IFormatProvider? provider) |
double |
Converts Value with Convert.ToDouble. |
public short ToInt16(IFormatProvider? provider) |
short |
Converts Value with Convert.ToInt16. |
public int ToInt32(IFormatProvider? provider) |
int |
Converts Value with Convert.ToInt32. |
public long ToInt64(IFormatProvider? provider) |
long |
Converts Value with Convert.ToInt64. |
public sbyte ToSByte(IFormatProvider? provider) |
sbyte |
Converts Value with Convert.ToSByte. |
public float ToSingle(IFormatProvider? provider) |
float |
Converts Value with Convert.ToSingle. |
public string ToString(IFormatProvider? provider) |
string |
Converts Value with Convert.ToString. |
public string ToString(string? format, IFormatProvider? formatProvider) |
string |
Uses IFormattable when the wrapped value supports it; otherwise uses Convert.ToString. |
public object ToType(Type conversionType, IFormatProvider? provider) |
object |
Converts Value to an arbitrary type via Convert.ChangeType. |
public ushort ToUInt16(IFormatProvider? provider) |
ushort |
Converts Value with Convert.ToUInt16. |
public uint ToUInt32(IFormatProvider? provider) |
uint |
Converts Value with Convert.ToUInt32. |
public ulong ToUInt64(IFormatProvider? provider) |
ulong |
Converts Value with Convert.ToUInt64. |
AggregateETagExtensions
Defined in Trellis.Http.Abstractions; listed here because the extension methods remain in namespace Trellis and are commonly used alongside aggregates.
public static class AggregateETagExtensions
| Signature | Returns | Description |
|---|---|---|
public static Result<T> OptionalETag<T>(this Result<T> result, EntityTagValue[]? expectedETags) where T : IAggregate |
Result<T> |
If expectedETags is null, returns the original result unchanged; otherwise enforces strong ETag matching. Failure modes wrap HttpError.PreconditionFailed in Error.TransportFault; EntityTagValue.Wildcard() short-circuits to success. |
public static Result<T> RequireETag<T>(this Result<T> result, EntityTagValue[]? expectedETags) where T : IAggregate |
Result<T> |
Requires an If-Match value and enforces strong ETag matching. Missing headers now produce Error.TransportFault(new HttpError.PreconditionRequired(PreconditionKind.IfMatch)); all other failure modes wrap HttpError.PreconditionFailed. |
public static Task<Result<T>> OptionalETagAsync<T>(this Task<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate |
Task<Result<T>> |
Async Task wrapper for OptionalETag<T>. |
public static ValueTask<Result<T>> OptionalETagAsync<T>(this ValueTask<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate |
ValueTask<Result<T>> |
Async ValueTask wrapper for OptionalETag<T>. |
public static Task<Result<T>> RequireETagAsync<T>(this Task<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate |
Task<Result<T>> |
Async Task wrapper for RequireETag<T>. |
public static ValueTask<Result<T>> RequireETagAsync<T>(this ValueTask<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate |
ValueTask<Result<T>> |
Async ValueTask wrapper for RequireETag<T>. |
Specification<T>
public abstract class Specification<T>
| Name | Type | Description |
|---|---|---|
CacheCompilation |
bool |
Protected virtual switch that controls whether IsSatisfiedBy(T entity) reuses a lazily compiled delegate. |
| Signature | Returns | Description |
|---|---|---|
protected Specification() |
— | Initializes the lazy compiled delegate cache. |
public abstract Expression<Func<T, bool>> ToExpression() |
Expression<Func<T, bool>> |
Returns the canonical expression tree for the specification. |
public bool IsSatisfiedBy(T entity) |
bool |
Evaluates the specification in memory. |
public Specification<T> And(Specification<T> other) |
Specification<T> |
Returns a composed AND specification. |
public Specification<T> Or(Specification<T> other) |
Specification<T> |
Returns a composed OR specification. |
public Specification<T> Not() |
Specification<T> |
Returns a negated specification. |
public static implicit operator Expression<Func<T, bool>>(Specification<T> spec) |
Expression<Func<T, bool>> |
Converts the specification directly to its expression tree. |
TrellisJsonValidationException
namespace Trellis;
public sealed class TrellisJsonValidationException : System.Text.Json.JsonException
| Signature | Returns | Description |
|---|---|---|
public TrellisJsonValidationException() |
— | Default constructor. |
public TrellisJsonValidationException(string message) |
— | Creates an instance with a curated, user-safe message. |
public TrellisJsonValidationException(string message, Exception innerException) |
— | Wraps an inner exception with the supplied message. |
| Property | Type | Description |
|---|---|---|
InvalidInput |
Error.InvalidInput? (init-only) |
Optional structured payload describing per-field violations recovered during deserialization. Populated by CompositeValueObjectJsonConverter<T> when a composite VO's TryCreate returns an Error.InvalidInput. When non-null with at least one FieldViolation, Trellis.Asp's ScalarValueValidationMiddleware emits one wire entry per FieldViolation keyed <parentPath>.<leaf> (MVC dot+bracket convention) instead of collapsing all leaves into the single ;-joined Message. When null or Fields is empty (e.g., rules-only Error.InvalidInput), the middleware falls back to a single entry under the translated parent path with Message as the value, preserving the curated message. |
Marker subclass of System.Text.Json.JsonException thrown by Trellis JSON converters when a structured value object's invariants are violated during deserialization (e.g., CompositeValueObjectJsonConverter<Money> rejecting a negative amount). Trellis.Asp's ScalarValueValidationMiddleware recognizes this subtype and surfaces its content in the resulting Problem Details payload — preferring the structured per-field shape from Error.InvalidInput when present (one entry per FieldViolation), and falling back to surfacing Message and JsonException.Path as a single entry otherwise. Plain JsonException instances are deliberately not surfaced because their messages can include internal type names; converters opt in to message surfacing by throwing this subclass with a curated message (e.g., error.GetDisplayMessage() from a Result failure).
TrellisValidationFormatException
namespace Trellis;
public sealed class TrellisValidationFormatException : FormatException
| Signature | Returns | Description |
|---|---|---|
public TrellisValidationFormatException() |
— | Default constructor. |
public TrellisValidationFormatException(string? message) |
— | The flattened parse message, unchanged from what callers saw before. |
public TrellisValidationFormatException(string? message, Exception? innerException) |
— | Wraps an inner exception. |
public TrellisValidationFormatException(string? message, Error.InvalidInput? invalidInput) |
— | Carries the structured failure alongside the flattened message. |
| Property | Type | Description |
|---|---|---|
InvalidInput |
Error.InvalidInput? (init-only) |
The structured failure the parse produced, when there was one. |
A parse failure has to satisfy two contracts at once and no single exception type satisfies both: IParsable<TSelf> requires malformed input to be signalled with a FormatException, while the ASP boundary recognizes a structured failure only through TrellisJsonValidationException, which is a JsonException. So the failure crosses in two hops — Parse throws this type, and ParsableJsonConverter<T> rethrows it as a TrellisJsonValidationException carrying the same InvalidInput.
Deriving from FormatException rather than adding a sibling type is what keeps existing catch (FormatException) sites matching, and the base message is the unchanged flattened message, so a caller that catches and logs sees exactly what it saw before. The structure is strictly additional. It lives in Trellis.Core and is public because it is thrown from Trellis.Primitives and caught in Trellis.Core: internal would put it out of reach of the throwing sites, and moving it to Trellis.Primitives would make Core's converter reference Primitives and invert the dependency.
Status side effect. Routing
RequiredEnum<TSelf>and collection wrong-token parse failures through this carrier moves them from 400 to 422. That is a correction, not a regression: 400 is for bytes that are not valid JSON, whereas these parsed fine and failed semantic validation — and the identical failure already returned 422 when it arrived throughCompositeValueObjectJsonConverter<T>.
Primitive value object base classes
These types ship in Trellis.Core. They are the building blocks for strongly-typed primitive value objects — derive a partial class from one of the Required*<TSelf> bases and the bundled Trellis.Core.Generator source generator emits the TryCreate / Create / Parse / TryParse / JsonConverter boilerplate. The validation attributes (StringLengthAttribute, RangeAttribute, EnumValueAttribute), opt-in behavior attributes (NotDefaultAttribute, TrimAttribute), and numeric sign attributes (PositiveAttribute, NonNegativeAttribute, NegativeAttribute, NonPositiveAttribute) attach declarative metadata that the generator wires into validation. The concrete primitives that derive from these bases (EmailAddress, Money, etc.) live in Trellis.Primitives — see trellis-api-primitives.md.
Required*<TSelf> default behavior and opt-outs
Every Required*<TSelf> base is lenient by default. The generated TryCreate rejects null for every base and accepts every concrete value. Use [NotDefault] to opt into sentinel rejection for a given base, and [Trim] to opt into string trimming on RequiredString<TSelf>.
| Base | Default rejects | Opt-in attributes |
|---|---|---|
RequiredString<TSelf> |
null only (accepts "", whitespace; no auto-trim) |
[NotDefault] rejects ""; [Trim] enables trimming; combine for strict trim-then-reject-empty |
RequiredGuid<TSelf> |
null only (accepts Guid.Empty) |
[NotDefault] rejects Guid.Empty |
RequiredDateTime<TSelf> |
null only (accepts DateTime.MinValue) |
[NotDefault] rejects DateTime.MinValue |
RequiredDateTimeOffset<TSelf> |
null only (accepts DateTimeOffset.MinValue) |
[NotDefault] rejects DateTimeOffset.MinValue |
RequiredInt<TSelf> |
null only (accepts 0) |
[NotDefault] rejects 0 |
RequiredLong<TSelf> |
null only (accepts 0L) |
[NotDefault] rejects 0L |
RequiredDecimal<TSelf> |
null only (accepts 0m) |
[NotDefault] rejects 0m |
RequiredBool<TSelf> |
null |
(no sentinel — false remains valid) |
RequiredEnum<TSelf> |
null, undeclared member names |
(smart-enum lookup via TryCreate) |
RequiredString<TSelf> validation order: null check → trim (only if [Trim] present) → empty check (only if [NotDefault] present) → [StringLength] → ValidateAdditional. [Trim] without [NotDefault] trims the value before storage but does not reject "" or whitespace-only input. Combine [Trim, NotDefault] for trim-then-reject-empty behavior.
Numeric Required bases (RequiredInt, RequiredLong, RequiredDecimal) accept four convenience sign-check attributes — [Positive], [NonNegative], [Negative], [NonPositive] — mutually exclusive with each other. RequiredInt and RequiredLong translate them into the equivalent [Range] bounds; RequiredDecimal emits a direct sign comparison (the full decimal range exceeds what double-backed [Range] could express). Applying a convenience attribute to a non-numeric base is TRLS043; applying more than one is TRLS044. Note: a [Range(1, 100)] RequiredInt rejects 0 via the range message ("must be at least 1"), not a sentinel message — no [NotDefault] needed when a range already excludes zero.
The lenient defaults also drive the EF Core TrellisScalarConverter read path: only null triggers TrellisPersistenceMappingException during materialization. When [NotDefault] is present, persisted sentinel values also throw. Add [NotDefault] for columns where the sentinel is never valid persisted domain state.
Overriding a constraint's reason code
RangeAttribute, StringLengthAttribute, NotDefaultAttribute, PositiveAttribute, NonNegativeAttribute, NegativeAttribute, and NonPositiveAttribute each carry an optional Code that replaces the framework reason code on the resulting FieldViolation. TrimAttribute carries none, because trimming normalizes and cannot fail. The four sign attributes share RangeAttribute's slot, since they synthesize into the same range emission. An empty or whitespace Code is TRLS060.
The framework vocabulary stays frozen and stays the default; the freeze constrains Trellis, not the application. ValidateAdditional also has a four-argument overload — (T value, string fieldName, ref string? errorMessage, ref string? errorCode) — that names a custom rule's failure instead of reporting error.unspecified; declaring both overloads is TRLS061. See trellis-api-primitives.md for the full treatment.
ResultRequiresExplicitHttpMappingConverter
public sealed class ResultRequiresExplicitHttpMappingConverter : JsonConverterFactory
Default [JsonConverter] factory attached to Result<T>, IResult, and IResult<T>. Throws NotSupportedException on any direct JsonSerializer.Serialize / Deserialize call, with an actionable message that names the canonical fix:
- HTTP path — call
.ToHttpResponse()(Trellis.Asp) on the result. The returnedMicrosoft.AspNetCore.Http.IResultwrites the body itself; the struct never reaches STJ. - Non-HTTP path — unwrap the value with
Match/TryGetValuebefore serialization. - Explicit override — register a converter (or a
JsonConverterFactory) inJsonSerializerOptions.Converters. Option-registered converters take precedence over the type-level[JsonConverter]attribute. The override must match the declared static type: aJsonConverter<Result<T>>only coversResult<T>-declared values;IResult<T>-declared values needJsonConverter<IResult<T>>;IResult-declared values needJsonConverter<IResult>. Use aJsonConverterFactorywhoseCanConvertmatches every shape to cover the mixed case in one registration.
The attribute lives on both the struct AND the interfaces because STJ resolves [JsonConverter] against the static declared type: an endpoint declared as Task<IResult<int>> GetAsync() would otherwise bypass a converter attached only to the struct, silently producing a public-state dump (for example, {"IsSuccess": true, "IsFailure": false, "Error": null} for a success, with no success value) instead of using the explicit HTTP mapping the converter exists to enforce.
| Signature | Returns | Description |
|---|---|---|
public override bool CanConvert(Type typeToConvert) |
bool |
true for Result<T>, IResult<T>, and the non-generic IResult interface. |
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) |
JsonConverter |
Always throws NotSupportedException directly — the factory is terminal and never returns a typed converter. Throwing here (instead of returning a converter that throws on Read / Write) keeps the path AOT-safe: no MakeGenericType / Activator.CreateInstance reflection is needed, so Native AOT consumers see the actionable Trellis message instead of a "native code not available" error before the message can fire. The exception message names the declared shape (Result<T>, IResult<T>, or IResult) so the consumer sees the exact type to register an override for. |
RangeAttribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class RangeAttribute : Attribute
| Name | Type | Description |
|---|---|---|
Code |
string? |
Replaces the framework reason code on both directional failures. null keeps value.greater-than-or-equal / value.less-than-or-equal. |
| Signature | Returns | Description |
|---|---|---|
public RangeAttribute(int minimum, int maximum) |
RangeAttribute |
Range metadata for RequiredInt<TSelf> and whole-number RequiredDecimal<TSelf>. |
public RangeAttribute(long minimum, long maximum) |
RangeAttribute |
Range metadata for RequiredLong<TSelf>. |
public RangeAttribute(double minimum, double maximum) |
RangeAttribute |
Fractional range metadata for RequiredDecimal<TSelf>. |
StringLengthAttribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class StringLengthAttribute : Attribute
| Name | Type | Description |
|---|---|---|
MaximumLength |
int |
Inclusive maximum length. |
MinimumLength |
int |
Inclusive minimum length; defaults to 0. |
Code |
string? |
Replaces the framework reason code on both length failures. null keeps string.min-length / string.max-length. |
| Signature | Returns | Description |
|---|---|---|
public StringLengthAttribute(int maximumLength) |
StringLengthAttribute |
Length metadata for RequiredString<TSelf>. |
NotDefaultAttribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class NotDefaultAttribute : Attribute
Opt-in attribute consumed by Trellis.Core.Generator. When present, the generated TryCreate rejects the type's sentinel value in addition to null. On RequiredString<TSelf>, rejects "" (after any trimming applied by [Trim]). On RequiredGuid<TSelf>, rejects Guid.Empty. On numeric bases, rejects 0 / 0L / 0m. On date bases, rejects DateTime.MinValue or DateTimeOffset.MinValue.
| Signature | Returns | Description |
|---|---|---|
public NotDefaultAttribute() |
NotDefaultAttribute |
Marker only — no constructor arguments. |
| Name | Type | Description |
|---|---|---|
Code |
string? |
Replaces the framework reason code on the sentinel rejection. null keeps value.not-default, or value.not-empty on RequiredString<TSelf>. |
TrimAttribute
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class TrimAttribute : Attribute
Opt-in attribute consumed by Trellis.Core.Generator. When present on RequiredString<TSelf>, the generated TryCreate trims leading and trailing whitespace before applying further checks. When combined with [NotDefault], whitespace-only input trims to "" and is rejected. Without [NotDefault], trimming normalizes the stored value but does not reject "" or whitespace-only input.
| Signature | Returns | Description |
|---|---|---|
public TrimAttribute() |
TrimAttribute |
Marker only — no constructor arguments. |
EnumValueAttribute
[AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = false)]
public sealed class EnumValueAttribute : Attribute
| Name | Type | Description |
|---|---|---|
Value |
string |
Canonical symbolic name for a RequiredEnum<TSelf> member. |
| Signature | Returns | Description |
|---|---|---|
public EnumValueAttribute(string value) |
EnumValueAttribute |
Overrides the default field-name-based symbolic value. |
ResourceCollectionNameAttribute
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
public sealed class ResourceCollectionNameAttribute : Attribute
Overrides the default URI collection name ({TypeName.ToLowerInvariant()}s) used when Trellis.Asp synthesises ProblemDetails.Instance from a failing ResourceRef. Apply to the aggregate type whose default naive plural is wrong or unwanted (for example [ResourceCollectionName("people")] on Person, or [ResourceCollectionName("statuses")] on Status).
The attribute validates the supplied name at construction time so misconfiguration fails fast at host start when services.AddResourceCollectionNames(...) scans the assembly. The name must be a non-empty single URL path segment composed entirely of RFC 3986 unreserved characters: ASCII letters and digits plus -, ., _, and ~. Reserved characters (/, ?, #), percent (%), +, and whitespace are rejected because the value is emitted unencoded into the synthesised URI. When the attribute is absent and no DI override is registered, the writer falls back to {TypeName.ToLowerInvariant()}s.
| Name | Type | Description |
|---|---|---|
Name |
string |
The URL collection segment (for example "people"). |
| Signature | Returns | Description |
|---|---|---|
public ResourceCollectionNameAttribute(string name) |
ResourceCollectionNameAttribute |
Throws ArgumentNullException if name is null; throws ArgumentException if name is empty/whitespace or contains any character outside RFC 3986 unreserved. |
public static bool IsSafePathSegment(string? name) |
bool |
Predicate used by the attribute constructor and by Trellis.Asp.ResourceCollectionNameRegistry to validate names from DI helpers. Returns false for null, empty, or whitespace input. Exposed publicly so other layers can apply the same rule. |
StringExtensions
public static class StringExtensions
| Name | Type | Description |
|---|---|---|
| — | — | Static helper type. |
| Signature | Returns | Description |
|---|---|---|
public static string NormalizeFieldName(this string? fieldName, string defaultName) |
string |
Returns defaultName verbatim only when fieldName is null; otherwise camel-cases fieldName. An empty string is preserved as the root/current-pointer sentinel used by pointer-aware validation pipelines. |
public static T ParseScalarValue<T>(string? s) where T : class, IScalarValue<T, string> |
T |
Throws FormatException based on T.TryCreate. |
public static bool TryParseScalarValue<T>([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out T result) where T : class, IScalarValue<T, string> |
bool |
Safe parsing helper based on T.TryCreate. |
public static string ToCamelCase(this string? str) |
string |
Lowercases the first character only. |
RequiredEnumJsonConverter<TRequiredEnum>
public sealed class RequiredEnumJsonConverter<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TRequiredEnum> : JsonConverter<TRequiredEnum>
where TRequiredEnum : RequiredEnum<TRequiredEnum>, IScalarValue<TRequiredEnum, string>
| Name | Type | Description |
|---|---|---|
| — | — | Converter type; no public properties. |
| Signature | Returns | Description |
|---|---|---|
public override bool HandleNull |
bool |
true; routes JSON null tokens through the converter so required enums can reject them explicitly. |
public override TRequiredEnum? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) |
TRequiredEnum? |
Accepts only JSON string; string values are resolved through TRequiredEnum.TryCreate(name), and null throws JsonException. |
public override void Write(Utf8JsonWriter writer, TRequiredEnum value, JsonSerializerOptions options) |
void |
Writes value.Value as a JSON string. |
RequiredString<TSelf>
public abstract class RequiredString<TSelf> : ScalarValueObject<TSelf, string>
where TSelf : RequiredString<TSelf>, IScalarValue<TSelf, string>
| Name | Type | Description |
|---|---|---|
Value |
string |
Inherited scalar value. |
Length |
int |
Convenience access to Value.Length. |
| Signature | Returns | Description |
|---|---|---|
public bool StartsWith(string value) |
bool |
Delegates to string.StartsWith(string) for EF Core-translatable query predicates. Culture-sensitive in memory — see Comparison semantics. |
public bool StartsWith(string value, StringComparison comparisonType) |
bool |
Delegates to string.StartsWith(string, StringComparison). This overload is not EF Core translatable; use the single-argument overload for queries. |
public bool Contains(string value) |
bool |
Delegates to string.Contains(string) for EF Core-translatable query predicates. Ordinal in memory — see Comparison semantics. |
public bool Contains(string value, StringComparison comparisonType) |
bool |
Delegates to string.Contains(string, StringComparison). This overload is not EF Core translatable; use the single-argument overload for queries. |
public bool Contains(char value, StringComparison comparisonType) |
bool |
Delegates to string.Contains(char, StringComparison). This overload is not EF Core translatable; use the single-argument overload for queries. |
public bool EndsWith(string value) |
bool |
Delegates to string.EndsWith(string) for EF Core-translatable query predicates. Culture-sensitive in memory — see Comparison semantics. |
public bool EndsWith(string value, StringComparison comparisonType) |
bool |
Delegates to string.EndsWith(string, StringComparison). This overload is not EF Core translatable; use the single-argument overload for queries. |
public static TSelf Create(string value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
Comparison semantics of the single-argument query helpers
The single-argument StartsWith, Contains, and EndsWith exist so EF Core's
ScalarValueExpressionRewriter can translate them: it maps Name.StartsWith(x) to
((string)Name).StartsWith(x), and a two-argument call carrying a StringComparison
would not translate. The consequence is that they inherit the BCL's comparison
semantics verbatim — and the BCL is not self-consistent here:
| Helper | In-memory comparison |
|---|---|
StartsWith(string) |
Culture-sensitive (StringComparison.CurrentCulture) |
EndsWith(string) |
Culture-sensitive (StringComparison.CurrentCulture) |
Contains(string) |
Ordinal |
The divergence is observable on any data containing characters a culture treats as
ignorable. With the soft hyphen U+00AD, the same receiver and the same argument give
opposite answers:
var name = ProductName.Create("cooper\u00ADative");
name.StartsWith("coopera"); // true — culture-sensitive, ignores the soft hyphen
name.EndsWith("rative"); // true — likewise
name.Contains("rative"); // false — ordinal, the soft hyphen is a real character
Two further consequences worth planning for:
- In-memory and SQL evaluation can disagree. Translated to SQL, the comparison is
performed by the database using the provider's own string-matching functions,
operators, and collations — which may match neither the culture-sensitive nor the
ordinal .NET result. The rules are provider-specific rather than universally the
column's collation: SQL Server translates these to
LIKE, which honours the column collation, whereas the SQLite provider translatesContainstoinstr, which does not. The same specification can therefore return different results when evaluated against aDbSetversus an in-memory list — which matters for tests that use in-memory collections to stand in for the database. - Host configuration changes the answer. A host running with
InvariantGlobalization=truecollapses culture-sensitive comparison to ordinal-like behavior, soStartsWith/EndsWithchange results whileContainsdoes not.
When the semantics must be explicit and the call is not part of an EF Core query, use the
two-argument overload and pass StringComparison.Ordinal explicitly.
RequiredGuid<TSelf>
public abstract class RequiredGuid<TSelf> : ScalarValueObject<TSelf, Guid>
where TSelf : RequiredGuid<TSelf>, IScalarValue<TSelf, Guid>
| Name | Type | Description |
|---|---|---|
Value |
Guid |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public static TSelf Create(Guid value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredInt<TSelf>
public abstract class RequiredInt<TSelf> : ScalarValueObject<TSelf, int>
where TSelf : RequiredInt<TSelf>, IScalarValue<TSelf, int>
| Name | Type | Description |
|---|---|---|
Value |
int |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public static TSelf Create(int value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredDecimal<TSelf>
public abstract class RequiredDecimal<TSelf> : ScalarValueObject<TSelf, decimal>
where TSelf : RequiredDecimal<TSelf>, IScalarValue<TSelf, decimal>
| Name | Type | Description |
|---|---|---|
Value |
decimal |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public static TSelf Create(decimal value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredLong<TSelf>
public abstract class RequiredLong<TSelf> : ScalarValueObject<TSelf, long>
where TSelf : RequiredLong<TSelf>, IScalarValue<TSelf, long>
| Name | Type | Description |
|---|---|---|
Value |
long |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public static TSelf Create(long value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredBool<TSelf>
public abstract class RequiredBool<TSelf> : ScalarValueObject<TSelf, bool>
where TSelf : RequiredBool<TSelf>, IScalarValue<TSelf, bool>
| Name | Type | Description |
|---|---|---|
Value |
bool |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public static TSelf Create(bool value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredDateTime<TSelf>
public abstract class RequiredDateTime<TSelf> : ScalarValueObject<TSelf, DateTime>
where TSelf : RequiredDateTime<TSelf>, IScalarValue<TSelf, DateTime>
| Name | Type | Description |
|---|---|---|
Value |
DateTime |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public override string ToString() |
string |
Formats Value using invariant round-trip format "O". |
public static TSelf Create(DateTime value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredDateTimeOffset<TSelf>
public abstract class RequiredDateTimeOffset<TSelf> : ScalarValueObject<TSelf, DateTimeOffset>
where TSelf : RequiredDateTimeOffset<TSelf>, IScalarValue<TSelf, DateTimeOffset>
| Name | Type | Description |
|---|---|---|
Value |
DateTimeOffset |
Inherited scalar value. |
| Signature | Returns | Description |
|---|---|---|
public override string ToString() |
string |
Formats Value using invariant round-trip format "O". |
public static TSelf Create(DateTimeOffset value) |
TSelf |
Inherited throwing scalar factory. Source-generated overloads are listed below. |
RequiredEnum<TSelf>
public abstract class RequiredEnum<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] TSelf>
: IEquatable<RequiredEnum<TSelf>>, IComparable<RequiredEnum<TSelf>>, IComparable
where TSelf : RequiredEnum<TSelf>, IScalarValue<TSelf, string>
| Name | Type | Description |
|---|---|---|
Value |
string |
Canonical symbolic identity; defaults to the public static field name unless [EnumValue] overrides it. |
Ordinal |
int |
Declaration-order metadata; not a wire/storage identity. |
| Signature | Returns | Description |
|---|---|---|
public static IReadOnlyCollection<TSelf> GetAll() |
IReadOnlyCollection<TSelf> |
Returns all discovered public static readonly members. |
public bool Is(params TSelf[] values) |
bool |
True when this instance matches any provided member. |
public bool IsNot(params TSelf[] values) |
bool |
Negation of Is(params TSelf[]). |
public override string ToString() |
string |
Returns Value. |
public override int GetHashCode() |
int |
Case-insensitive hash of Value. |
public override bool Equals(object? obj) |
bool |
Case-insensitive symbolic equality. |
public bool Equals(RequiredEnum<TSelf>? other) |
bool |
Case-insensitive symbolic equality. |
public static bool operator ==(RequiredEnum<TSelf>? left, RequiredEnum<TSelf>? right) |
bool |
Equality operator. |
public static bool operator !=(RequiredEnum<TSelf>? left, RequiredEnum<TSelf>? right) |
bool |
Inequality operator. |
public static implicit operator string(RequiredEnum<TSelf> value) |
string |
Unwraps the member to its symbolic Value, matching the implicit unwrap on ScalarValueObject<TSelf, T>-derived primitives so every Required* value object converts to its primitive transparently. Member-to-member == keeps its case-insensitive Value semantics — the exact-match operator above wins over a double conversion to string. |
public int CompareTo(RequiredEnum<TSelf>? other) |
int |
Orders by Ordinal (declaration order), like the C# enum it replaces; stays consistent with Value-based equality (Value and Ordinal are both unique per member); null sorts first. |
int IComparable.CompareTo(object? obj) |
int |
Non-generic comparison; enables members to be used as composite ValueObject equality components and sorted by the default comparer. Throws ArgumentException when obj is non-null and not a RequiredEnum<TSelf> (consistent with Equals(object?)). |
public static bool operator <(RequiredEnum<TSelf>? left, RequiredEnum<TSelf>? right) |
bool |
Less-than by declaration order. |
public static bool operator <=(RequiredEnum<TSelf>? left, RequiredEnum<TSelf>? right) |
bool |
Less-than-or-equal by declaration order. |
public static bool operator >(RequiredEnum<TSelf>? left, RequiredEnum<TSelf>? right) |
bool |
Greater-than by declaration order. |
public static bool operator >=(RequiredEnum<TSelf>? left, RequiredEnum<TSelf>? right) |
bool |
Greater-than-or-equal by declaration order. |
ParsableJsonConverter<T>
public class ParsableJsonConverter<T> : JsonConverter<T>
where T : IParsable<T>
Core-owned JSON converter emitted by the Required*<TSelf> source generator for non-enum generated primitives. It reads JSON strings, numbers, and booleans; JSON null is rejected outright with a JsonException, before any parse is attempted, because generated scalar value objects are non-nullable. Numeric scalar value objects write JSON numbers when their string representation parses invariantly as a decimal, bool-backed scalars write JSON true/false, and all other values write JSON strings.
| Signature | Returns | Description |
|---|---|---|
public override bool HandleNull => true |
bool |
Opts the converter into receiving null tokens. Without it, System.Text.Json bypasses Read for a null token on a reference-type target and yields a null reference, silently violating the primitive's non-nullable contract. |
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) |
T? |
Converts supported JSON token types to invariant strings and calls T.Parse(raw, CultureInfo.InvariantCulture). A parse or validation failure is wrapped in a JsonException whose InnerException is the original parse exception — a FormatException for the generated primitives, or an ArgumentException / OverflowException from a hand-written IParsable<T>. |
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) |
void |
Writes numeric scalar primitives as numbers and bool-backed scalars as JSON booleans when possible; otherwise writes value.ToString() as a JSON string. |
Failures surface as
JsonException.Parseitself still throwsFormatException(theIParsable<T>contract), but deserialization wraps it so every converter failure — bad token type,nullfor a non-nullable target, malformed value, failed validation — reaches the caller asJsonException. This matters at HTTP boundaries: minimal APIs andReadFromJsonAsynctranslateJsonExceptioninto a400 Bad Request, whereas an escapingFormatExceptionbecomes a500. OnlyFormatException,ArgumentException, andOverflowExceptionare translated; any other exception from a customParsepropagates unchanged.
nullis rejected, not silently accepted. A JSONnull— whether at the top level or in a property position such as{"Number":null}— throwsJsonExceptionrather than producing a null reference. This depends onHandleNullbeingtrue; a customJsonConverter<T>for a non-nullable value object that omits that override will silently deserializenullinto the reference, defeating the primitive's invariant. Serialization is unaffected: a null reference still writes JSONnull.
PrimitiveValueObjectTrace
public static class PrimitiveValueObjectTrace
Core-owned trace source used by generated Required*<TSelf> value objects and concrete primitives. The activity source name remains "Trellis.Primitives" for telemetry compatibility and is exposed as PrimitiveValueObjectTrace.ActivitySourceName; register it with AddTrellisPrimitivesInstrumentation() from Trellis.Primitives when using the primitives package, or call TracerProviderBuilder.AddSource(PrimitiveValueObjectTrace.ActivitySourceName) directly for Core-only generated primitives.
Versioning: the OpenTelemetry activity source
Versionis stamped from the assembly that physically contains this type —Trellis.Core— even when consumers depend onTrellis.Primitives. The two packages ship lockstep from a singleversion.json, so the version reported in spans is the version of both packages.
| Name | Type | Description |
|---|---|---|
ActivitySource |
ActivitySource |
Activity source used by generated primitive creation/parsing/validation operations. |
ActivitySourceName |
string |
Public activity source name for Core-only OpenTelemetry wiring. |
Source-generated members
The incremental generator at Trellis.Core/generator/RequiredPartialClassGenerator.cs (bundled inside Trellis.Core.nupkg at analyzers/dotnet/cs/Trellis.Core.Generator.dll) augments partial classes that inherit a Required*<TSelf> base type.
Generated Guid, numeric, boolean, and date/time nullable-input factories use
Result.EnsureNotNull(value, field, detail): the standard required-field error is
created only when the input is null. The normalized field, value.not-null code,
and existing type-specific detail are preserved; later empty, parse, range, and
custom-validation checks keep their existing lazy factories.
RequiredString<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(string? value, string? fieldName = null)
public static TSelf Create(string? value, string? fieldName = null)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(string value)
static partial void ValidateAdditional(string value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
null.""and whitespace-only input are accepted without trimming by default. Add[NotDefault]to also reject"",[Trim]to enable trimming, or[Trim, NotDefault]for strict trim-then-reject-empty.[StringLength]operates after any applied trim.
RequiredGuid<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static TSelf NewUniqueV4()
public static TSelf NewUniqueV7()
public static TSelf NewUniqueV7(TimeProvider timeProvider)
public static Result<TSelf> TryCreate(Guid value, string? fieldName = null)
public static Result<TSelf> TryCreate(Guid? requiredGuidOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static new TSelf Create(Guid value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(Guid value)
static partial void ValidateAdditional(Guid value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
null;Guid.Emptyis accepted. Add[NotDefault]to also rejectGuid.Emptywhen the all-zero GUID is not valid domain state. NewUniqueV7(TimeProvider timeProvider)usestimeProvider.GetUtcNow()as the Version 7 timestamp and throwsArgumentNullExceptionwhentimeProviderisnull.
RequiredInt<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(int value, string? fieldName = null)
public static Result<TSelf> TryCreate(int? valueOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static Result<TSelf> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null)
public static new TSelf Create(int value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(int value)
static partial void ValidateAdditional(int value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
nullfor nullable inputs;0is accepted. Optional[Range(int, int)]and sign-check attributes run after the null check. Add[NotDefault]to also reject0.
RequiredDecimal<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(decimal value, string? fieldName = null)
public static Result<TSelf> TryCreate(decimal? valueOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static Result<TSelf> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null)
public static new TSelf Create(decimal value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(decimal value)
static partial void ValidateAdditional(decimal value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
nullfor nullable inputs;0mis accepted. Optional[Range(int, int)],[Range(double, double)], and sign-check attributes run after the null check. Add[NotDefault]to also reject0m. - String parsing: the plain
TryCreate(string?, string?)overload uses invariant culture; use theIFormatProvideroverload for culture-aware decimal formats.
RequiredLong<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(long value, string? fieldName = null)
public static Result<TSelf> TryCreate(long? valueOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static Result<TSelf> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null)
public static new TSelf Create(long value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(long value)
static partial void ValidateAdditional(long value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
nullfor nullable inputs;0Lis accepted. Optional[Range(long, long)]and sign-check attributes run after the null check. Add[NotDefault]to also reject0L.
RequiredBool<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(bool value, string? fieldName = null)
public static Result<TSelf> TryCreate(bool? valueOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static new TSelf Create(bool value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(bool value)
static partial void ValidateAdditional(bool value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
nullfor nullable inputs; bothtrueandfalseare valid. There is no strictness opt-out forRequiredBool<TSelf>. - JSON wire format: serializes as a JSON boolean (
true/false). Deserialization accepts both JSON booleans and the JSON strings"true"/"false".
RequiredDateTime<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(DateTime value, string? fieldName = null)
public static Result<TSelf> TryCreate(DateTime? valueOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static Result<TSelf> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null)
public static new TSelf Create(DateTime value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(DateTime value)
static partial void ValidateAdditional(DateTime value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
null;DateTime.MinValueis accepted. Add[NotDefault]to also rejectDateTime.MinValuewhen the BCL minimum value is not valid domain state.
RequiredDateTimeOffset<TSelf>
[JsonConverter(typeof(ParsableJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(DateTimeOffset value, string? fieldName = null)
public static Result<TSelf> TryCreate(DateTimeOffset? valueOrNothing, string? fieldName = null)
public static Result<TSelf> TryCreate(string? stringOrNull, string? fieldName = null)
public static Result<TSelf> TryCreate(string? value, IFormatProvider? provider, string? fieldName = null)
public static new TSelf Create(DateTimeOffset value)
public static TSelf Create(string stringValue)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static explicit operator TSelf(DateTimeOffset value)
static partial void ValidateAdditional(DateTimeOffset value, string fieldName, ref string? errorMessage)
- Built-in validation: rejects
null;DateTimeOffset.MinValueis accepted. Add[NotDefault]to also rejectDateTimeOffset.MinValuewhen the BCL minimum value is not valid domain state.
RequiredEnum<TSelf>
[JsonConverter(typeof(RequiredEnumJsonConverter<TSelf>))]
public static Result<TSelf> TryCreate(string value)
public static Result<TSelf> TryCreate(string? value, string? fieldName = null)
public static TSelf Parse(string s, IFormatProvider? provider)
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result)
public static TSelf Create(string value)
TryCreate(string)/TryCreate(string?, string?)are inherited from theRequiredEnum<TSelf>base — an enum's creation is a uniform symbolic lookup, so it is not generated per type.Create,Parse, andTryParseare generated and route throughTryCreate.- The enum JSON converter resolves through
TryCreate; there is noTryFromValueorTryFromNameAPI.
Building your own primitive
using System.Globalization;
using Trellis;
namespace Demo;
[StringLength(50)]
public partial class CustomerName : RequiredString<CustomerName> { }
public partial class OrderId : RequiredGuid<OrderId> { }
[Range(1, 999)]
public partial class LineCount : RequiredInt<LineCount> { }
public partial class SubmittedAt : RequiredDateTime<SubmittedAt> { }
public partial class OrderState : RequiredEnum<OrderState>
{
public static readonly OrderState Draft = new();
[EnumValue("submitted")]
public static readonly OrderState Submitted = new();
}
public static class Example
{
public static void Run()
{
var orderId = OrderId.NewUniqueV7();
var name = CustomerName.Create("Ada");
var lines = LineCount.TryCreate("42", CultureInfo.InvariantCulture).GetValueOrThrow();
var submittedAt = SubmittedAt.Parse("2026-01-15T12:00:00Z", CultureInfo.InvariantCulture);
var state = OrderState.Create("submitted");
_ = (orderId, name, lines, submittedAt, state);
}
}
Extension methods
AggregateETagExtensions
Defined in Trellis.Http.Abstractions; the signatures stay the same in namespace Trellis.
public static Result<T> OptionalETag<T>(this Result<T> result, EntityTagValue[]? expectedETags) where T : IAggregate
public static Result<T> RequireETag<T>(this Result<T> result, EntityTagValue[]? expectedETags) where T : IAggregate
public static Task<Result<T>> OptionalETagAsync<T>(this Task<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate
public static ValueTask<Result<T>> OptionalETagAsync<T>(this ValueTask<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate
public static Task<Result<T>> RequireETagAsync<T>(this Task<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate
public static ValueTask<Result<T>> RequireETagAsync<T>(this ValueTask<Result<T>> resultTask, EntityTagValue[]? expectedETags) where T : IAggregate
Notes:
- Matching is always strong RFC 9110 comparison.
expectedETags is nullmeans "noIf-Matchheader supplied".OptionalETagreturns success unchanged;RequireETagfails withError.TransportFault(new HttpError.PreconditionRequired(PreconditionKind.IfMatch))whoseDetailis"If-Match header is required.".expectedETags.Length == 0fails withError.TransportFault(new HttpError.PreconditionFailed(ResourceRef.For<T>(), PreconditionKind.IfMatch))whoseDetailis"If-Match header is empty.".- A non-empty array containing only weak tags fails with the same wrapped
HttpError.PreconditionFailed, withDetail="If-Match contains only weak ETags. Strong comparison is required."(RFC 9110 forbids weak comparison forIf-Match). - A non-empty array of strong tags with no match fails with the same wrapped
HttpError.PreconditionFailed, withDetail="Resource has been modified. Please reload and retry.". EntityTagValue.Wildcard()bypasses value comparison and succeeds immediately.
Internal types
AndSpecification<T>,OrSpecification<T>, andNotSpecification<T>are internal implementation types returned by the public combinators onSpecification<T>.
Code examples
Aggregate, entity, and ETag validation
using System;
using Trellis;
public sealed partial class OrderId : RequiredGuid<OrderId>;
public sealed record OrderPlaced(OrderId OrderId, DateTimeOffset OccurredAt) : IDomainEvent;
public sealed class Order : Aggregate<OrderId>
{
public string Description { get; private set; }
private Order(OrderId id, string description) : base(id) => Description = description;
public static Result<Order> Create(string description)
{
var order = new Order(OrderId.NewUniqueV7(), description);
order.DomainEvents.Add(new OrderPlaced(order.Id, DateTimeOffset.UtcNow));
return Result.Ok(order);
}
}
Result<Order> orderResult = Order.Create("starter-order");
if (orderResult.TryGetValue(out var order))
{
var guarded = Result.Ok(order).OptionalETag(new[] { EntityTagValue.Strong(order.ETag) });
}
Specification composition
using System;
using System.Linq.Expressions;
using Trellis;
public sealed class Subscription
{
public DateTimeOffset ExpiresAt { get; init; }
public bool IsCancelled { get; init; }
}
public sealed class ExpiredSubscriptionSpec(DateTimeOffset now) : Specification<Subscription>
{
public override Expression<Func<Subscription, bool>> ToExpression() =>
subscription => subscription.ExpiresAt < now;
}
public sealed class ActiveSubscriptionSpec : Specification<Subscription>
{
public override Expression<Func<Subscription, bool>> ToExpression() =>
subscription => !subscription.IsCancelled;
}
var spec = new ExpiredSubscriptionSpec(DateTimeOffset.UtcNow)
.And(new ActiveSubscriptionSpec());
Cross-references
- Trellis.Core API reference —
Result<T>,Maybe<T>,Error,ITransportFault,IScalarValue<TSelf, TPrimitive>, andIFormattableScalarValue<TSelf, TPrimitive> - Trellis.Http.Abstractions API reference —
HttpError,EntityTagValue,RetryAfterValue,RepresentationMetadata,WriteOutcome<T>, andAggregateETagExtensions - Trellis.Primitives API reference — built-in scalar and composite value objects that build on these DDD primitives
- Trellis.EntityFrameworkCore API reference — EF Core conventions and interceptors for
IEntity,IAggregate,ValueObject, andMaybe<T>