Table of Contents

Trellis Cross-Package Cookbook

Where routing lives

The task lookup, the load-the-smallest-reference-set preflight and the conventions every recipe follows are in trellis-start-here.md, the required reading for Trellis work. Open a recipe body here when that lookup sends you to one; every live recipe has a row there.

Read the selected recipe's problem, constraints and solution together. For a focused subtask in a large recipe, follow its section links instead of loading unrelated alternatives. Expand to the complete recipe when composing its domain, HTTP and persistence surfaces.

Primary solution blocks follow the router's preferred-pattern defaults: required-field shorthand, lazy custom errors, and typed mappings where available. Fallbacks are labelled with the condition that justifies them.

Recipe 1 — CRUD aggregate (DDD value objects + entity + repository contract)

Problem. Model an Order aggregate with a typed identifier, a value-object money type, and a repository contract that returns Result<T> for not-found.

using Trellis;
using Trellis.Authorization;

// Strongly-typed ID: source-generated factory, equality, parsing, JSON converter.
public sealed partial class OrderId : RequiredGuid<OrderId>;

// Value object backed by a 3-letter ISO 4217 currency code.
[StringLength(3, MinimumLength = 3)]
public sealed partial class CurrencyCode : RequiredString<CurrencyCode>;

// Composite value object — must be a class (records can't inherit ValueObject).
public sealed class Money : ValueObject
{
    private Money() { } // EF materialization without an EF package dependency.
    public Money(decimal amount, CurrencyCode currency) { Amount = amount; Currency = currency; }
    public decimal Amount { get; private set; }
    public CurrencyCode Currency { get; private set; } = null!;
    protected override void GetEqualityComponents(ref EqualityComponents components)
    {
        components.Add(Amount);
        components.Add(Currency);
    }
}

// Aggregate root.
public sealed class Order : Aggregate<OrderId>
{
    public Money Total { get; private set; } = default!;
    public OrderStatus Status { get; private set; }
    public ActorId OwnerId { get; private set; } = default!;

    private Order(OrderId id) : base(id) { }   // EF Core ctor

    // Nullable guards carry the values; Combine accumulates missing-field errors.
    // Combine aggregates per-field errors into a single Error.InvalidInput; Map's
    // tuple-deconstructing overload lets the lambda bind the validated non-null values
    // directly as id/total/ownerId.
    public static Result<Order> TryCreate(OrderId? id, Money? total, ActorId? ownerId) =>
        Result.EnsureNotNull(id, "id", "Order id is required.")
            .Combine(Result.EnsureNotNull(total, "total", "Total is required."))
            .Combine(Result.EnsureNotNull(ownerId, "ownerId", "Owner id is required."))
            .Map((id, total, ownerId) => new Order(id) { Total = total, Status = OrderStatus.Draft, OwnerId = ownerId });
}

// Trellis convention: model finite domain states as RequiredEnum<TSelf>
// (NOT C# enums). The partial keyword triggers the source generator.
public partial class OrderStatus : RequiredEnum<OrderStatus>
{
    public static readonly OrderStatus Draft     = new();
    public static readonly OrderStatus Submitted = new();
    public static readonly OrderStatus Cancelled = new();
}

// Repository contract — uses Maybe<T> for "may legitimately find nothing"
// Reserve Result<T> for failures the caller can act on.
public interface IOrderRepository
{
    Task<Maybe<Order>> FindAsync(OrderId id, CancellationToken ct);
    void Add(Order order);
}

What it shows. Each base class in this recipe supplies a complete surface — your derived type adds only domain-specific state. Do not redeclare members that are already inherited; that is the most common Recipe 1 mistake.

  • RequiredGuid<TSelf> source-generates TryCreate overloads, Parse/TryParse, an explicit Guid → TSelf operator, the Value accessor, equality / GetHashCode / IComparable, JSON and EF Core converters, plus the NewUniqueV4(), NewUniqueV7(), and NewUniqueV7(TimeProvider) factories. It rejects null only by default; Guid.Empty is accepted. Add [NotDefault] to also reject Guid.Empty. Do not write your own TryCreate, equality members, parse/convert helpers, or JSON/EF converters.
  • RequiredString<TSelf> source-generates TryCreate(string?, string?), Parse/TryParse, an explicit string → TSelf operator, the Value accessor, equality, JSON and EF Core converters, plus Length/StartsWith/Contains/EndsWith pass-throughs. It rejects null only by default; "" and whitespace-only input are accepted and stored as-is (no auto-trim). Add [NotDefault] to also reject "", [Trim] to enable trimming, or both for strict trim-then-reject-empty behavior. Same rule applies: derived classes add only domain-specific helpers (e.g., a custom TryCreateWithValidation that layers extra rules on top of the generated TryCreate).
  • ValueObject (the base of Money, Address, etc.) supplies Equals(object?), Equals(ValueObject?), GetHashCode, CompareTo, and the ==/!=/</<=/>/>= operators — all derived from GetEqualityComponents(). Your derived type implements only protected override void GetEqualityComponents(ref EqualityComponents components). Do not override Equals/GetHashCode/CompareTo or write equality operators yourself — that breaks the contract the base class establishes. For Maybe<T> components, use the inherited protected static IComparable? MaybeComponent<T>(Maybe<T>) helper rather than unwrapping manually.
  • Aggregate<TId> already supplies inherited infrastructure members: Id, protected DomainEvents, persistence-managed ETag, IsChanged based on pending domain events, and the CreatedAt/LastModified timestamps (inherited from Entity<TId>, managed by EntityTimestampInterceptor). Do not redeclare those members on every aggregate; use the inherited surface and add only domain-specific state. Domain events are added via DomainEvents.Add(...) from inside the aggregate; the public read-only view is IAggregate.UncommittedEvents().

Compiled contract. The exact signatures of every member listed above are exercised in Examples/CookbookSnippets/Recipe01_CrudAggregate.cs → Recipe1InheritedSurface. That file is compiled in CI, so if a signature changes in the framework, the build fails and this callout MUST be updated to match. When you need to confirm an exact overload, read the demonstrator — never paraphrase signatures from memory.

Required*<TSelf> primitives are lenient by default (rejects null only). Use [NotDefault] to opt into rejecting the type's sentinel value, and [Trim] to opt into string trimming. Both attributes are meaningful; see trellis-api-primitives.md for the full behavior table.

[StringLength] and [Range] come from the Trellis namespace and are placed on the class declaration. The System.ComponentModel.DataAnnotations attributes of the same name target properties/fields/parameters, so applying them to a value object is a compile error — CS0104 (ambiguous reference) for an unqualified attribute when both namespaces are in scope, otherwise CS0592.

Anti-pattern → fix (wrong attribute namespace).

// WRONG — importing System.ComponentModel.DataAnnotations alongside Trellis brings a
// second [StringLength] into scope, so the attribute no longer resolves to Trellis.
using Trellis;
using System.ComponentModel.DataAnnotations;     // ← wrong namespace also in scope
[StringLength(3, MinimumLength = 3)]             // CS0104: ambiguous between Trellis and DataAnnotations
public sealed partial class CurrencyCode : RequiredString<CurrencyCode>;

// FIX — keep only the Trellis namespace in scope.
using Trellis;                                   // ← Trellis attributes only
[StringLength(3, MinimumLength = 3)]             // resolves to Trellis.StringLength
public sealed partial class CurrencyCode : RequiredString<CurrencyCode>;

Recipe 2 — Command + handler + FluentValidation + EF persistence

Problem. Wire a PlaceOrderCommand end-to-end: validation via FluentValidation, mediator handler that uses an EF repository, transactional commit on success.

using FluentValidation;
using Mediator;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Asp;
using Trellis.Authorization;
using Trellis.EntityFrameworkCore;
using Trellis.Mediator;
using Trellis.Mediator.FluentValidation;
using Trellis.Primitives;

public sealed record PlaceOrderRequest(Guid OrderId, decimal Amount, string Currency, string OwnerId);

public sealed record PlaceOrderCommand(OrderId OrderId, Money Total, ActorId OwnerId)
    : ICommand<Result<OrderId>>
{
    public static Result<PlaceOrderCommand> TryCreate(PlaceOrderRequest request) =>
        Result.Combine(
                OrderId.TryCreate(request.OrderId, nameof(request.OrderId)),
                MonetaryAmount.TryCreate(request.Amount, nameof(request.Amount)),
                CurrencyCode.TryCreate(request.Currency, nameof(request.Currency)),
                ActorId.TryCreate(request.OwnerId, nameof(request.OwnerId)))
            .Map((orderId, amount, currency, ownerId) =>
                new PlaceOrderCommand(orderId, new Money(amount.Value, currency), ownerId));
}

public sealed class PlaceOrderValidator : AbstractValidator<PlaceOrderCommand>
{
    public PlaceOrderValidator()
    {
        RuleFor(x => x.Total.Amount)
            .LessThanOrEqualTo(10_000m)
            .WithMessage("Orders over 10,000 require manual approval.");
    }
}

public sealed class PlaceOrderHandler(IOrderRepository repo)
    : ICommandHandler<PlaceOrderCommand, Result<OrderId>>
{
    public ValueTask<Result<OrderId>> Handle(PlaceOrderCommand cmd, CancellationToken cancellationToken) =>
        Order.TryCreate(cmd.OrderId, cmd.Total, cmd.OwnerId)
            .Tap(repo.Add)
            .Map(o => o.Id)
            .AsValueTask();
}

[ApiController]
[Route("orders")]
public sealed class OrdersController(ISender sender) : ControllerBase
{
    [HttpPost]
    public ValueTask<ActionResult<OrderId>> Place([FromBody] PlaceOrderRequest request, CancellationToken ct) =>
        PlaceOrderCommand.TryCreate(request)
            .BindAsync(command => sender.Send(command, ct))
            .ToHttpResponseAsync()
            .AsActionResultAsync<OrderId>();
}

// Composition root
public static class OrdersDi
{
    public static IServiceCollection AddOrdersFeature(this IServiceCollection services) =>
        services
            .AddTrellisBehaviors()                              // canonical mediator behaviors (exception, tracing, logging, authorization, validation)
            .AddTrellisFluentValidation(typeof(PlaceOrderValidator).Assembly)
            .AddTrellisUnitOfWork<AppDbContext>()               // Innermost: commits on success
            .AddScoped<IOrderRepository, EfOrderRepository>();
}

What it shows. The mediator pipeline already runs ValidationBehavior<TMessage, TResponse> before the handler — AddTrellisFluentValidation plugs every IValidator<T> into it via the open-generic IMessageValidator<T> adapter. AddTrellisUnitOfWork<TContext> registers TransactionalCommandBehavior<,> after the others, so it lands innermost and commits only when the handler returns success. The handler itself is pure: no try/catch, no primitive parsing, no await db.SaveChangesAsync() — that's the unit of work's job.

Multiple independent resources in the handler? Reach for Recipe 21 when the loads hit different stores (HTTP + DB, two upstream services, factory-created DbContexts). For two repository reads against the same scoped DbContext, stay sequential — that case races EF Core. The Recipe explains the rule and shows both shapes.

Validation ownership. Primitive→VO conversion happens at the transport seam. FluentValidation validates VO-shaped commands for cross-field rules and business invariants. Handlers receive value-object-shaped commands and must not parse primitives. See Recipe 18 for the canonical controller-seam adapter.

Anti-pattern → fix (TRLS010).

// WRONG — sync-over-async (.Result deadlocks) + throwing inside the Result chain.
.Bind(id => repo.FindAsync(id, ct).Result is { HasValue: true }
    ? throw new InvalidOperationException("already exists")  // TRLS010 + TRLS005
    : Result.Ok(id))

// FIX — MatchAsync awaits the Maybe carrier and dispatches without leaving the Result chain.
.BindAsync(id => repo.FindAsync(id, ct)
    .MatchAsync(
        some: _  => Result.Fail<OrderId>(new Error.Conflict(Resource: ResourceRef.For<Order>(id), Code: "already-exists")),
        none: () => Result.Ok(id)))

Recipe 3 — Query handler returning Page<T> (paginated list with cursor)

Problem. Expose a list endpoint that paginates Order rows by cursor, exposes the requested vs. applied limit, and projects a DTO.

using Mediator;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Trellis;
using Trellis.Asp;
using Trellis.EntityFrameworkCore;

public sealed record ListOrdersQuery(PageRequest Pagination) : IQuery<Result<Page<OrderListItem>>>;

public sealed record OrderListItem(Guid Id, decimal Amount, string Currency);

public sealed class ListOrdersHandler(AppDbContext db)
    : IQueryHandler<ListOrdersQuery, Result<Page<OrderListItem>>>
{
    public async ValueTask<Result<Page<OrderListItem>>> Handle(ListOrdersQuery q, CancellationToken ct)
    {
        var seek = SeekDefinition.Ascending<Order, Guid>(o => o.Id.Value);
        var page = await db.Orders.AsNoTracking()
            .ToPageAsync(q.Pagination, seek, cursorFieldName: "cursor",
                cancellationToken: ct);

        return page.Map(p => p.Map(o =>
            new OrderListItem(o.Id.Value, o.Total.Amount, o.Total.Currency.Value)));
    }
}

At an ASP.NET Core boundary, construct the validated request from raw query values before dispatching the application query:

app.MapGet("/orders", (HttpContext context, ISender sender, CancellationToken ct) =>
    context.Request.TryCreatePageRequest()
        .BindAsync(pagination => sender.Send(new ListOrdersQuery(pagination), ct))
        .ToHttpResponseAsync(
            nextUrlBuilder: context.PageUrl(
                "Orders_List",
                (cursor, applied) => new Microsoft.AspNetCore.Routing.RouteValueDictionary
                {
                    ["cursor"] = cursor.Token,
                    ["limit"] = applied,
                }),
            body: item => item))
    .WithName("Orders_List")
    .WithInputOrigin(InputLocation.Query);

What it shows. HttpRequest.TryCreatePageRequest is the ASP untrusted-input boundary: only a missing cursor means first page; present empty/whitespace and repeated cursors fail with cursor.malformed. A missing limit uses PageSize.Default; empty, malformed, overflowing, or repeated values fail with format.integer; zero/negative limits fail; above-cap limits clamp while retaining Requested (or fail with policy: PageSizeLimitPolicy.Reject). Every parser failure is located at the actual query parameter and maps to HTTP 422. BindAsync does not dispatch the query on parse failure, so no database query runs. The endpoint's query input-origin metadata also promotes a later, transport-neutral /cursor decode failure to the cursor query parameter.

HttpContext.PageUrl(...) belongs to Trellis.Asp, so the same endpoint expression works without a versioning package. In versioned hosts, enable UseVersionedPageUrls once through AddTrellisAsp / UseAsp as in Recipe 12. Identically routed namespace-versioned list actions may share a name: the enabled policy resolves self-pagination against the active endpoint, preserving next/previous versions independently of registration order.

The handler receives the validated, transport-neutral PageRequest. A non-HTTP adapter constructs the same type with PageRequest.TryCreate(rawCursor, rawLimit) before creating the application query. SeekDefinition owns ordering, extraction, and the matching predicate; ToPageAsync decodes typed state, seeks, over-fetches, and delegates pure assembly to PageBuilder. The final Map preserves both the railway and the page metadata.

Do not replace the raw parser with bound string? cursor / int? limit endpoint parameters: MVC can normalize ?cursor= to null, and host parsing can return 400 before Trellis produces its coded 422. TryCreatePageRequest deliberately adds no ApiExplorer/OpenAPI parameters; declare the two query parameters separately in endpoint metadata when documentation is required.

For non-unique primary sorts, change the definition rather than hand-writing a second predicate:

var seek = SeekDefinition.Descending<Order, DateTimeOffset>(o => o.CreatedAt)
    .ThenAscending(o => o.Id.Value);

End with a stable unique key. Every key must be non-null and provider-comparable; verify translation/collation semantics. .Id.Value projection requires AddTrellisInterceptors() on the context options. Apply tenant/authorization/business filters before ToPageAsync; the sample assumes the order source is already appropriately scoped.

EF projects boundary values alongside each row using the same expressions as ordering and seeking; the cursor is encoded from that projected state, not from selectors compiled or re-evaluated after materialization. Provider-translated functions such as EF.Functions.Collate are usable when the provider translates the complete query. Do not substitute an assumed-equivalent C# calculation for a SQL boundary value.

Failure and consistency boundaries. Malformed state returns Error.InvalidInput with cursor.malformed (HTTP 422 through ASP mapping). Invalid server state, provider failures, and cancellation propagate rather than becoming cursor errors. Built-in tokens are versioned and unsigned; old unversioned tokens are rejected. Previous is null from the EF helper, and descending traversal is not reverse pagination. There is no snapshot guarantee across mutable data. Use Recipe 40 for query-bound computed state; see the Core pagination reference and EF seek contract.


Recipe 4 — Minimal-API endpoint wiring Result<T> → HttpResponseOptionsBuilder → ToHttpResponse

Problem. Map a Result<Order> to a fully-conformant HTTP response: 200 with strong ETag and Last-Modified, 404/422 Problem Details on failure, 304 on If-None-Match match.

using Microsoft.AspNetCore.Builder;
using Trellis;
using Trellis.Asp;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTrellisAspWithScalarValidation();  // error → status mapping + scalar-value validation
builder.Services.AddOrdersFeature();       // from Recipe 2

var app = builder.Build();

app.MapGet("/orders/{id:guid}", async (Guid id, IMediator mediator, CancellationToken ct) =>
{
    if (!OrderId.TryCreate(id, nameof(id)).TryGetValue(out var orderId, out var idError))
        return idError.ToHttpResponse();

    Result<Order> result = await mediator.Send(new GetOrderQuery(orderId), ct);

    return result.ToHttpResponse(opts => opts
        .WithETag(o => o.ETag)                         // strong ETag from aggregate
        .WithLastModified(o => o.LastModified)         // RFC 1123
        .Vary("Accept", "Accept-Language")
        .EvaluatePreconditions());                     // 304 / 412 handling
});

app.Run();

What it shows. ToHttpResponse returns Microsoft.AspNetCore.Http.IResult and is the only supported response verb. The fluent HttpResponseOptionsBuilder<TDomain> configures protocol semantics (WithETag, WithLastModified, Vary, EvaluatePreconditions) without leaking HTTP into the handler. Failures (Error.NotFound, Error.InvalidInput, …) round-trip through Problem Details using the TrellisAspOptions mapping registered by AddTrellisAsp.

PUT upserts. The application returns Result.Ok(WriteOutcome.Created(order)) when it adds the resource, or Result.Ok(WriteOutcome.Updated(order)) when it updates one. No URL is needed in the application layer. In this endpoint, IOrderWriter.UpsertAsync is an application-owned service returning Task<Result<WriteOutcome<Order>>>:

app.MapPut("/orders/{id:guid}", async (
    Guid id, PutOrderRequest request, IOrderWriter writer, CancellationToken ct) =>
{
    Result<WriteOutcome<Order>> result = await writer.UpsertAsync(id, request, ct);
    return result.ToHttpResponse(
        body: order => new { Id = order.Id.Value },
        configure: options => options.WithETag(order => order.ETag));
});

Created returns 201 without a Location when the new resource is at the PUT request URL; Updated returns 200. To generate a Location at the endpoint instead, configure Created, CreatedAtRoute, CreatedAtAction, or WithLocation. Null, empty, or whitespace outcome locations use that fallback; a nonblank outcome Location wins. The outcome controls status, so WithLocation cannot turn Created into 200 or Updated into 201. Updated and Accepted do not use these fallbacks. An unresolved configured fallback returns response.location-unresolved (500 by default); callback exceptions propagate.

Versioned Location links. For CreatedAtRoute / CreatedAtAction (201) or WithLocation (normal 2xx), load target-aware API versioning before chaining the existing .WithVersionedRoute() / .WithVersionedRoute(ApiVersion) APIs. They now inspect the final destination, honor actual action mappings and segment pins, and reject missing/ambiguous targets or unsupported pins. The optional ASP WithLocationRouteResolver hook runs after all legacy callbacks on a cloned dictionary; last registration wins. Literal Created and WriteOutcome URIs are unchanged. Do not transfer Location segment behavior to PageUrl: its implicit ambient routing and explicit segment-pin rejection remain.


Recipe 5 — MVC controller using AsActionResult

Problem. Same payload as Recipe 4 but with a typed MVC ActionResult<OrderDto>.

using Microsoft.AspNetCore.Mvc;
using Trellis;
using Trellis.Asp;

[ApiController]
[Route("orders")]
public sealed class OrdersController(IMediator mediator) : ControllerBase
{
    [HttpGet("{id:guid}")]
    public async Task<ActionResult<OrderDto>> Get(Guid id, CancellationToken ct)
    {
        if (!OrderId.TryCreate(id, nameof(id)).TryGetValue(out var orderId, out var idError))
            return idError.ToHttpResponse().AsActionResult<OrderDto>();

        Result<Order> result = await mediator.Send(new GetOrderQuery(orderId), ct);

        return result
            .ToHttpResponse(
                body: o => new OrderDto(o.Id.Value, o.Total.Amount, o.Total.Currency.Value),
                configure: opts => opts.WithETag(o => o.ETag).EvaluatePreconditions())
            .AsActionResult<OrderDto>();
    }
}

public sealed record OrderDto(Guid Id, decimal Amount, string Currency);

What it shows. .AsActionResult<TBody>() projects an IResult into a typed ActionResult<TBody>, so MVC clients still get OpenAPI/Swagger-friendly typed responses while the response itself executes through the same IResult pipeline as Minimal API.

The same adapter supports the Created/Updated outcomes in Recipe 4. For Result<WriteOutcome<Order>>, project the body with ToHttpResponse(...) and chain .AsActionResult<OrderDto>(); route/action location fallbacks run against the Order, not the projected DTO. A nonblank outcome Location takes precedence.


Recipe 6 — Conditional GET with EntityTagValue

Problem. Serve a resource with strong-ETag conditional GET so clients can revalidate cheaply.

using Trellis;
using Trellis.Asp;

app.MapGet("/blobs/{id:guid}", async (Guid id, IBlobRepository repo, CancellationToken ct) =>
{
    Result<BlobContent> result = await repo.FindAsync(new BlobId(id), ct);

    return result.ToHttpResponse(opts => opts
        .WithETag(b => EntityTagValue.Strong(b.Sha256Hex))
        .WithLastModified(b => b.UploadedAt)
        .EvaluatePreconditions());
});

What it shows. EntityTagValue.Strong(...) and EntityTagValue.Weak(...) build typed ETags; WithETag accepts either a string (always strong) or an EntityTagValue. .EvaluatePreconditions() honors If-Match/If-None-Match/If-Modified-Since/If-Unmodified-Since against the configured ETag and Last-Modified selectors, returning 304 Not Modified or 412 Precondition Failed as appropriate. For byte-range responses, call ASP.NET Core's Results.File(stream, enableRangeProcessing: true) directly — Trellis does not wrap that surface.


Recipe 7 — Authorization: IActorProvider + IAuthorize + resource-based auth

Problem. Resource-based ownership check on an update command (the 90% case) plus a static permission gate on a delete command, all via the mediator pipeline.

using Mediator;
using Trellis;
using Trellis.Asp.Authorization;
using Trellis.Authorization;
using Trellis.Mediator;
using Trellis.Primitives;

// CANONICAL OWNER CHECK — the 90% case. Implement IAuthorizeResource<TResource> for the
// owner rule and IIdentifyResource<TResource, TId> so the framework reuses the shared
// SharedResourceLoaderById<TResource, TId> instead of requiring a per-command loader.
public sealed record UpdateOrderCommand(OrderId OrderId, Money NewTotal)
    : ICommand<Result<Unit>>, IAuthorizeResource<Order>, IIdentifyResource<Order, OrderId>
{
    // Typed VO carried straight through — no parse, no throw.
    // ASP.NET model binding (via IScalarValue<OrderId, string>) handles the
    // string→OrderId conversion at the API edge.
    public OrderId GetResourceId() => OrderId;

    public Trellis.IResult Authorize(Actor actor, Order resource) =>
        Result.Ensure(
            resource.OwnerId == actor.Id || actor.HasPermission("orders:write"),
            () => new Error.Forbidden(Code: "orders.owner", Resource: ResourceRef.For<Order>(OrderId)));
}

// Static permission gate (no resource load needed): every actor with the named permission
// and no matching forbidden permission can run the command. Use IAuthorize when the decision does not depend on
// any resource state.
public sealed record DeleteOrderCommand(OrderId OrderId) : ICommand<Result<Unit>>, IAuthorize
{
    public IReadOnlyList<string> RequiredPermissions => ["orders:delete"];
}

// DI wiring
services.AddTrellisBehaviors();
services.AddClaimsActorProvider();               // ClaimsActorProvider for ASP.NET Core
// Pass every assembly that contains command/query types AND every assembly that contains
// IResourceLoader<,> implementations. In a layered app the loader typically lives in the
// ACL assembly, not the Application assembly — passing only the Application assembly will
// register ResourceAuthorizationBehavior<,,> without discovering the shared loader and the
// pipeline will fail at runtime when it cannot resolve IResourceLoader<TMessage, TResource>.
// Note: the scanner de-duplicates the `assemblies` parameter with a first-seen-order
// HashSet, and closed pipeline-behavior registrations are idempotent across repeated
// scans and explicit-plus-scanned overlap when service type and implementation type match.
// Passing the same assembly twice is safe.
services.AddResourceAuthorization(
    typeof(UpdateOrderCommand).Assembly,        // Application assembly (commands + IAuthorizeResource)
    typeof(OrderResourceLoader).Assembly);      // ACL assembly (IResourceLoader<,> implementations)

What it shows. Lead with IAuthorizeResource<TResource> + IIdentifyResource<TResource, TId> for the owner-on-loaded-resource case — that pair covers most domain authorization decisions, and the framework wires up SharedResourceLoaderById<TResource, TId> automatically so no per-command loader is needed. Fall back to IAuthorize for static permission gates that do not require a resource load. IAuthorizeResource<TResource> runs after the resource loader produces the loaded resource, then calls Authorize(actor, resource); IAuthorize enforces an AND-permission gate via AuthorizationBehavior<,> before the handler runs.

Checked actor/resource parameters. Derive from ActorCommandHandler / ActorQueryHandler for actor-only business logic, or ActorResourceCommandHandler / ActorResourceQueryHandler for direct resource logic, and override protected Handle. The standard context supplies the identical Actor instance checked by this dispatch's authorization stages, after every declared gate passes. Resource bases additionally supply the exact loaded resource, without provider/accessor constructor dependencies or a second load. They do not require IAuthorize on a resource-only message.

public sealed record ReadOrderQuery(OrderId OrderId)
    : IQuery<Result<Order>>, IAuthorizeResource<Order>, IIdentifyResource<Order, OrderId>
{
    public OrderId GetResourceId() => OrderId;
    public IResult Authorize(Actor actor, Order resource) =>
        Result.Ensure(resource.OwnerId == actor.Id, static () => new Error.Forbidden("orders.owner"));
}

public sealed class ReadOrderHandler : ActorResourceQueryHandler<ReadOrderQuery, Order, Result<Order>>
{
    protected override ValueTask<Result<Order>> Handle(
        ReadOrderQuery query, Actor actor, Order order, CancellationToken cancellationToken)
        => new(Result.Ok(order));
}

Keep the existing shared-loader registration and add the query's resource registration. Ordinary accessor-based handlers remain valid; an actor-free business body need not adopt these bases. See all six bases and migration.

Existing provider lookup. await actorProvider.RequireActorAsync(cancellationToken) still performs another lookup, not a dispatch-snapshot read. It requires established actor presence and a stable or explicitly cached provider when its result must match authorization. For claims-backed hosts, register AddCachingActorProvider<ClaimsActorProvider>() after AddClaimsActorProvider; wrap the same scoped provider used by authorization. The helper throws when the presence invariant fails; it does not authenticate, check permissions, or cache independently. Endpoints without that invariant still use GetCurrentActorAsync and handle None.

Pipeline migration. Normal helpers install AuthorizationContextBehavior automatically. Hand-built pipelines must place it before authorization; missing context is a diagnosed configuration fault, not an implicit provider lookup. Nested sends have their own snapshots, even inside the same DI scope. Native AOT hosts use the literal closed generator configuration, not open behaviors closed dynamically over struct Result responses.

For the same shared-loader shape without assembly scanning, register the implementation once and the behavior/accessor/adapter together per message:

services.AddScoped<SharedResourceLoaderById<Order, OrderId>, OrderResourceLoader>();
services.AddSharedResourceAuthorization<UpdateOrderCommand, Order, OrderId, Result<Trellis.Unit>>();

The equivalent builder slot is UseSharedResourceAuthorization<TMessage,TResource,TId,TResponse>(); it also enables the standard Mediator behaviors. Both helpers preserve existing per-message loaders and leave the shared-loader implementation explicitly registered. The lower-level AddResourceAuthorization<TMessage,TResource,TResponse>() and UseResourceAuthorization<TMessage,TResource,TResponse>() remain available for custom loaders; neither adds the shared-loader bridge. See trellis-api-servicedefaults.md. For multi-hop authorization, see Recipe 24.

Microservices. The setup above works identically behind a reverse proxy / API gateway — the simplest microservices pattern is token pass-through: the gateway validates the incoming external JWT (Auth0 / Entra / Keycloak / etc.) and forwards it as-is, and each microservice configures AddJwtBearer(o => o.Authority = "https://idp") against the SAME external IDP. No new Trellis packages required.

// Microservice composition — token pass-through path.
// Gateway validated the JWT; this service validates again against the same IDP and
// hydrates the Actor from the standard claims.
builder.Services.AddAuthentication("Bearer").AddJwtBearer(o =>
{
    o.Authority = "https://your-idp.example";   // SAME IDP the gateway validated against
    o.Audience = "your-service";                // pin per-service audience
    o.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true, ValidIssuer = "https://your-idp.example",
        ValidateAudience = true, ValidAudience = "your-service",
        ValidateLifetime = true, RequireSignedTokens = true,
        ClockSkew = TimeSpan.FromSeconds(30),
    };
});

builder.Services.AddTrellis(o => o
    .UseClaimsActorProvider(c => { c.ActorIdClaim = "sub"; c.PermissionsClaim = "permissions"; })
    .UseResourceAuthorization()
    .UseResourceAuthorization<UpdateOrderCommand, Order, Result<Unit>>());

This pattern works today with current Trellis. Choose it when the external IDP is stable, all microservices share the same trust root, and you don't need per-cluster permission projection or shorter-than-IDP token lifetimes.

Path B — Trellis internal JWT (AddTrellisInternalJwtActorProvider from Trellis.Microservices.AspNetCore). When the constraints above don't hold — you need per-cluster audience isolation, gateway-side permission projection, shorter token lifetimes than the external IDP allows, or you want downstream services decoupled from the external IDP's claim shape — switch to the Trellis internal-JWT contract. A trusted gateway (typically Trellis.Yarp, but any gateway implementing the same minting contract works) re-mints a fresh per-cluster JWT carrying the FULL resolved Actor shape, including ForbiddenPermissions and ABAC Attributes. The minter, downstream actor provider, and shared contract constants ship in the xavierjohn/Trellis.Microservices repository (packages: Trellis.Yarp, Trellis.Microservices.AspNetCore, Trellis.Microservices.Abstractions). Downstream microservices add Trellis.Microservices.AspNetCore and call services.AddTrellisInternalJwtActorProvider(...) directly:

// Microservice composition — Path B (Trellis internal JWT).
// Gateway minted a fresh internal JWT for this cluster; this service validates it against
// the gateway's signing key and hydrates the FULL Actor surface (including forbidden
// permissions + ABAC attributes) from the gateway-controlled claim shape.
using Trellis.Microservices.AspNetCore;  // ServiceCollectionExtensions.AddTrellisInternalJwtActorProvider

builder.Services.AddAuthentication("Bearer").AddJwtBearer(o =>
{
    o.Authority = "https://gateway.internal";
    o.Audience = "incidents-service";
    o.MapInboundClaims = false;                       // keep raw JWT claim names (e.g. "tid", not the Microsoft tenant URI)
    o.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true, ValidIssuer = "https://gateway.internal",
        ValidateAudience = true, ValidAudience = "incidents-service",
        ValidateLifetime = true, RequireSignedTokens = true,
        ValidAlgorithms = ["RS256"],                  // gateway uses asymmetric signing
        ClockSkew = TimeSpan.FromSeconds(30),         // tight skew for internal network
        TryAllIssuerSigningKeys = false,              // never fall back to "try every key" — see microservices cookbook Recipe 1
    };
});

builder.Services.AddTrellisInternalJwtActorProvider(c =>
{
    c.RequiredAttributes = ["tenant_id"];          // fail closed on missing tenant
    c.AttributeClaimMap["tenant_id"] = "tid";
    c.AttributeClaimMap["mfa"] = "amr_normalized";
    c.ExpectedIssuer = "https://gateway.internal"; // defense-in-depth runtime check
    c.ExpectedAudience = "incidents-service";
});

builder.Services.AddTrellis(o => o
    .UseResourceAuthorization()
    .UseResourceAuthorization<UpdateOrderCommand, Order, Result<Unit>>());

The internal-JWT contract requires the gateway to mint three sentinel claims (trellis_actor_contract_version=1, trellis_permissions_count, trellis_forbidden_permissions_count) so a misbehaving proxy cannot strip the deny set silently — the deny-overrides-allow contract integrity invariant. The strict validation profile shown above is mandatory; the microservices cookbook Recipe 1 spells out the air-gapped (static-key-ring) variant, the tenant-isolation defense-in-depth check, and the key-rotation runbook; Recipe 2 covers the gateway-side end-to-end.

Path C — OAuth2 token exchange / OBO is out of scope for Trellis v1. Use Microsoft.Identity.Web's OBO support directly when an enterprise multi-tenant SaaS needs RFC 8693 token exchange against the IDP.

The three-way decision matrix (when to choose each path) is documented in the upcoming authorization-microservices.md article.


Recipe 8 — EF Core: MaybePropertyMapping for nullable value objects

Problem. Persist a Maybe<EmailAddress> property with the EF Core MaybeConvention, then verify the generated mapping in a startup diagnostics check.

using Trellis;
using Trellis.EntityFrameworkCore;

public sealed partial class EmailAddress : RequiredString<EmailAddress>;

public sealed partial class Customer : Aggregate<CustomerId>
{
    public Customer(CustomerId id) : base(id) { }

    public partial Maybe<EmailAddress> Email { get; set; }   // TRLS035 if not 'partial'
}

// Configure
public sealed class AppDbContext : DbContext
{
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
        configurationBuilder.ApplyTrellisConventions(typeof(AppDbContext).Assembly);
}

// Diagnostics — print the generated storage members for every Maybe<T> in the model
public static class ModelDiagnostics
{
    public static void DumpMaybeMappings(DbContext db)
    {
        IReadOnlyList<MaybePropertyMapping> mappings = db.GetMaybePropertyMappings();
        foreach (var m in mappings)
            Console.WriteLine($"{m.EntityTypeName}.{m.PropertyName} → {m.MappedBackingFieldName} ({m.StoreType.Name})");
    }
}

What it shows. Maybe<T> properties are routed through MaybeConvention, which generates a backing field (_email for Email) that EF Core maps to a nullable column. The CLR property remains Maybe<EmailAddress> everywhere in the domain. MaybePropertyMapping is the diagnostic record that exposes both names — useful for HasIndex on the storage member.

For storage-level inspection, the same record exposes StorageKind, TableName, Schema, and Columns.Items (property paths, actual table/column names, provider column types, and physical nullability). Composite mappings include recursively owned columns, even when those values target different tables. StorageReason records Trellis's separate-table fallback reason when known, not a guess about explicit overrides. db.ToMaybeMappingDebugString() renders all of these diagnostics.

For composite value objects (multi-field [OwnedEntity] types like ShippingAddress) — and for Maybe<T> where T is composite — see Recipe 13. Recipe 8 covers scalar Maybe<T> only.

Anti-pattern → fix (TRLS016).

// WRONG — HasIndex against the CLR Maybe<T> property silently fails
modelBuilder.Entity<Customer>().HasIndex(c => c.Email);   // TRLS016

// WRONG — explicit Property() configuration on a Maybe<T> CLR property.
// MaybeConvention generates a private backing field (e.g., _email) and maps THAT.
// Calling builder.Property(c => c.Email) tries to map Maybe<EmailAddress> as a column,
// which is not a supported store type — fails at model validation with
// "The property 'Customer.Email' could not be mapped because the database provider
//  does not support the type 'Maybe<EmailAddress>'."
internal sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.Property(c => c.Email).IsRequired();          // ❌ — runtime error
        builder.Property(c => c.Email).HasMaxLength(254);     // ❌ — runtime error
    }
}

// FIX — say nothing about Maybe<T> in IEntityTypeConfiguration. The convention owns it.
// If you need column metadata (max length, column name, etc.), configure the *backing field*
// via the diagnostic name from MaybePropertyMapping, or use HasTrellisIndex for indexes.

// FIX 1 — strongly-typed Trellis index helper
modelBuilder.Entity<Customer>().HasTrellisIndex(c => new { c.Status, c.Email });

// FIX 2 — string-based HasIndex against the storage member
modelBuilder.Entity<Customer>().HasIndex("Status", "_email");

Filtering on Maybe<T> properties in LINQ and Specification<T>

Once MaybeConvention maps the storage member, the MaybeQueryInterceptor (registered by optionsBuilder.AddTrellisInterceptors()) lets you write natural LINQ against the CLR Maybe<T> property — no EF.Property<T?>(o, "_x") boilerplate, no separate query helpers. The interceptor rewrites the expression tree before EF Core compiles it, translating o.Maybe.HasValue, o.Maybe.Value, o.Maybe.GetValueOrDefault(d), o.Maybe.HasValueWhere(t => ...), and o.Maybe == Maybe<T>.None to the storage-member access.

// Specification — exactly the shape you'd write for an aggregate query.
public sealed class OverdueOrderSpecification(DateTime asOf) : Specification<Order>
{
    private readonly DateTime _threshold = asOf.AddDays(-7);

    // Natural multi-clause guard — analyzer-clean (TRLS003 recognises HasValue
    // anywhere in the connected `&&` subtree to the left of the `.Value` access),
    // safe in FakeRepository (HasValue short-circuits before Value), and translated
    // verbatim by MaybeQueryInterceptor in EF.
    public override Expression<Func<Order, bool>> ToExpression() =>
        o => o.Status == OrderStatus.Submitted
             && o.SubmittedAt.HasValue
             && o.SubmittedAt.Value < _threshold;
}

// Repository / DbContext usage — the spec composes through IQueryable.Where.
var overdue = await context.Orders
    .Where(new OverdueOrderSpecification(timeProvider.GetUtcNow().DateTime).ToExpression())
    .ToListAsync(ct);

Why this works in both EF and FakeRepository<T, TId> — the compiled Func<Order, bool> that FakeRepository evaluates in memory uses C#'s short-circuit &&, so Value is never read when HasValue is false. In EF, the interceptor rewrites both clauses to the mapped storage member and emits idiomatic SQL (status = 'Submitted' AND submitted_at IS NOT NULL AND submitted_at < @threshold). One Specification, one predicate, identical semantics in production and in tests.

HasValueWhere shorthand. The same parity holds when the two-clause guard is collapsed into Maybe<T>.HasValueWhere(predicate). In-memory it expands to HasValue && predicate(Value) (predicate is never invoked on None); in EF the interceptor rewrites it to the same IS NOT NULL AND ... SQL. Use whichever form reads better — they are interchangeable.

// Equivalent specification using HasValueWhere — same SQL, same FakeRepository semantics.
public override Expression<Func<Order, bool>> ToExpression() =>
    o => o.Status == OrderStatus.Submitted
         && o.SubmittedAt.HasValueWhere(t => t < _threshold);

Sharing the spec with FakeRepository. Pass the same Specification<T> instance through QueryAsync — never duplicate the predicate by hand in a fake adapter. Duplicating the predicate is the most expensive class of test bug to catch in code review (the fake passes while the real query silently returns the wrong rows).

public Task<IReadOnlyList<Order>> FindOverdueAsync(DateTime asOf, CancellationToken ct) =>
    fake.QueryAsync(new OverdueOrderSpecification(asOf), ct);

Prerequisite. The interceptor only runs when the DbContext is configured with optionsBuilder.AddTrellisInterceptors(). Without it, EF Core sees Maybe<T> as an unmapped CLR type and either drops the predicate silently or fails translation — while the FakeRepository tests continue to pass. This is the failure mode that creates "fake says yes, production says no". Always wire interceptors in AddDbContext.

// Sentinel alternative — for predicates where you'd rather encode "absence acts as
// the most-permissive value" than carry the explicit HasValue clause. Reads as
// "if no SubmittedAt, treat as never overdue (DateTime.MaxValue)".
public override Expression<Func<Order, bool>> ToExpression() =>
    o => o.Status == OrderStatus.Submitted
         && o.SubmittedAt.GetValueOrDefault(DateTime.MaxValue) < _threshold;

For ad-hoc IQueryable<T> calls (outside a Specification<T>), the strongly-typed IQueryable<T> extensions in MaybeQueryableExtensions — WhereHasValue (with or without a typed predicate), WhereNone, WhereEquals, OrderByMaybe, etc. — target the storage member directly via EF.Property, without requiring the Maybe interceptor. The predicate overload accepts an expression tree, including a reusable Expression<Func<TInner, bool>>, not a compiled delegate. C# validates the chosen operators; the provider must still translate them, and translation failures never trigger client-side filtering. Scalar value-object .Value access still requires AddTrellisInterceptors().

// Equivalent ad-hoc query without a Specification (interceptor not required for this form):
var overdue = await context.Orders
    .Where(o => o.Status == OrderStatus.Submitted)
    .WhereHasValue(o => o.SubmittedAt, value => value < threshold)
    .ToListAsync(ct);

Recipe 9 — State machine: CanFire + Fire pattern with FireResult

Problem. Drive an order through Draft → Submitted → Shipped using Stateless, but expose every transition as Result<TState> so the mediator pipeline composes naturally.

using Stateless;
using Trellis;
using Trellis.StateMachine;

// States and triggers as RequiredEnum value objects (Trellis convention) —
// equality is symbolic, so Stateless's TState/TTrigger generic constraints are satisfied.
public partial class DocumentState : RequiredEnum<DocumentState>
{
    public static readonly DocumentState Draft     = new();
    public static readonly DocumentState Submitted = new();
    public static readonly DocumentState Approved  = new();
}

public partial class DocumentTrigger : RequiredEnum<DocumentTrigger>
{
    public static readonly DocumentTrigger Submit  = new();
    public static readonly DocumentTrigger Approve = new();
    public static readonly DocumentTrigger Reject  = new();
}

public sealed class DocumentService
{
    public Result<DocumentState> Submit(Document doc)
    {
        var machine = new StateMachine<DocumentState, DocumentTrigger>(doc.State);
        machine.Configure(DocumentState.Draft).Permit(DocumentTrigger.Submit, DocumentState.Submitted);
        machine.Configure(DocumentState.Submitted)
               .Permit(DocumentTrigger.Approve, DocumentState.Approved)
               .Permit(DocumentTrigger.Reject,  DocumentState.Draft);

        // FireResult pre-checks CanFire and converts invalid transitions to an
        // Error.InvariantViolation (HTTP 422) with Code
        // "state-machine.invalid-transition" — a rejected transition is a domain-invariant
        // breach, not inbound-input validation or a concurrent-modification conflict.
        Result<DocumentState> result = machine.FireResult(DocumentTrigger.Submit);
        return result.Tap(newState => doc.State = newState);
    }
}

What it shows. StateMachineExtensions.FireResult(...) honors PermitIf/IgnoreIf guards via CanFire(...) rather than parsing exception messages, so invalid-transition detection is independent of Stateless exception text. For aggregates whose state lives in a backing field (e.g., loaded from EF), use LazyStateMachine<TState, TTrigger> to defer machine creation until the first FireResult call.

Side-effect placement. Keep Stateless configuration declarative: states, triggers, permitted transitions, and pure/idempotent guards. Put business mutation, domain events, outbox writes, and other side effects after FireResult succeeds, usually in .Tap(...) as shown above. FireResult short-circuits when CanFire(...) is false and does not invoke Fire(...), so configured OnUnhandledTrigger callbacks do not run from the typed-result path. Consumers who want an OnUnhandledTrigger callback to run must call Stateless Fire(...) directly. If side effects live in OnEntry, OnExit, transition callbacks, or OnUnhandledTrigger, they can run outside the visible ROP success/failure path and make handler behavior diverge from tests.

HTTP semantics. Invalid state-machine transitions surface as Error.InvariantViolation (HTTP 422), not Error.InvalidInput or Error.Conflict (HTTP 409). The reasoning: a rejected transition ("you asked for Submit on a Cancelled order") is a breach of the aggregate's lifecycle invariant evaluated against its current state — the request itself is well-formed (so it is not inbound-input validation), and it is not a concurrent-modification conflict that a retry could resolve. Both InvalidInput and InvariantViolation map to 422 and share the on-wire ProblemDetails kind unprocessable-content, so the HTTP response is unchanged; the distinction is the domain error type (its Kind slug becomes invariant-violation). Callers that need to distinguish state-machine rejections from other 422s can match on the Code value state-machine.invalid-transition.

// Asserting on a state-machine rejection in tests:
var invariant = result.Error.Should().BeOfType<Error.InvariantViolation>().Subject;
invariant.Code.Should().Be("state-machine.invalid-transition");

Recipe 10 — Test: handler test using Trellis.Testing Should().Be(...) / UnwrapError()

Problem. Unit-test the PlaceOrderHandler from Recipe 2 using FluentAssertions extensions from Trellis.Testing.

For an actor-aware base, send the command/query through Mediator with TestActorProvider and fake business dependencies, then use the same Result assertions below. Keep the normal authorization, validation, and resource-loader registrations; supply fake commit/event dependencies when those stages are enabled. Assert denied/anonymous paths as well as business outcomes, ownership, existence hiding, and actor/resource snapshot identity. The actor/resource Handle overload is protected business logic, not a test seam. Calling the public two-argument Handle without an authorized dispatch throws before business logic, and registering a test actor provider alone does not create that dispatch. For pipeline-free unit tests, exercise aggregates, policies, or application services directly rather than unsealing a handler or accessing its protected hook by reflection. The direct test below is for Recipe 2's ordinary handler, not an actor-aware base.

using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Authorization;
using Trellis.Testing;
using Xunit;

public class PlaceOrderHandlerTests
{
    [Fact]
    public async Task PlaceOrder_returns_id_on_success()
    {
        var repo = new InMemoryOrderRepository();
        var sut  = new PlaceOrderHandler(repo);

        var command = new PlaceOrderCommand(
            OrderId.TryCreate(Guid.NewGuid()).Unwrap(),
            new Money(100m, CurrencyCode.TryCreate("USD").Unwrap()),
            ActorId.TryCreate("alice").Unwrap());

        var result = await sut.Handle(command, CancellationToken.None);

        result.Should().BeSuccess();
        result.Should().HaveValue(repo.Last().Id);                  // asserts the Result<T> value equals
    }

    [Fact]
    public void PlaceOrder_request_adapter_fails_when_currency_invalid()
    {
        var request = new PlaceOrderRequest(Guid.NewGuid(), 100m, "US", "alice"); // 2 chars, not 3

        var result = PlaceOrderCommand.TryCreate(request);

        result.Should().BeFailureOfType<Error.InvalidInput>()
            .Which.Should().HaveFieldError("currency");
    }
}

What it shows. ResultAssertions<TValue>.HaveValue(expected) asserts the success value equals expected (use HaveValueEquivalentTo for member-wise structural equivalence); UnwrapError() is the safe accessor that only returns the error and is intended for use after Should().BeFailure.... Calling .Should() on an Error.InvalidInput returns the specialized ValidationErrorAssertions (with HaveFieldError, HaveFieldErrorWithDetail, HaveFieldCount). Async assertions have two valid shapes: await the pipeline first and assert the resulting Result<T>, or call BeSuccessAsync / BeFailureAsync directly on Task<Result<T>> or ValueTask<Result<T>>. The unsupported shape is await result.Should().BeSuccessAsync() because .Should() returns synchronous ResultAssertions<TValue>.


The anti-pattern catalog moved to its own file so that AI sessions and human readers can load it independently when debugging an analyzer warning. See trellis-api-anti-patterns.md for each common analyzer trigger and its idiomatic Trellis fix (TRLS001, TRLS003, TRLS010, TRLS016, TRLS018, TRLS019).

If you are looking up a specific analyzer by ID, the standalone file is faster than scanning this cookbook. The cookbook recipes still link to the relevant sections of that file where they apply.

Enabling these analyzers. Standalone analyzer rules are opt-in: they ship in a separate Trellis.Analyzers package that Trellis.Core does not pull in. Add <PackageReference Include="Trellis.Analyzers" PrivateAssets="all" /> to every project that uses Trellis Result/Maybe/value objects to enable those analyzer-emitted diagnostics. The TRLS### prefix also includes source-generator diagnostics bundled with Trellis.Core, Trellis.EntityFrameworkCore, and Trellis.Asp; those do not require Trellis.Analyzers. See trellis-api-analyzers.md for the emitter table, install snippet, and .editorconfig severity control.


Recipe 12 — DI wiring playbook: AddTrellis composition builder

Problem. Compose Trellis service modules in the correct order so behaviors stack properly without forcing simple apps to install every package.

Preferred: tiered builder. Use Trellis.ServiceDefaults from the API/composition root. The builder records intent first, then applies modules in the canonical order. UseEntityFrameworkUnitOfWork<TContext>(), when selected, is always applied last so TransactionalCommandBehavior<,> lands innermost.

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Trellis.EntityFrameworkCore;
using Trellis.ServiceDefaults;

public static class CompositionRoot
{
    public static IServiceCollection AddApp(this IServiceCollection services, string connectionString)
    {
        // App-owned: provider, connection string, migrations, pooling, and Mediator registration.
        services.AddDbContext<AppDbContext>(opts => opts
            .UseSqlServer(connectionString)
            .AddTrellisInterceptors());

        services.AddMediator(options =>
        {
            options.Assemblies = [typeof(PlaceOrderCommand).Assembly];
            options.ServiceLifetime = ServiceLifetime.Scoped;
        });

        services.AddTrellis(options => options
            .UseAsp()
            .UseScalarValueValidation()
            .UseMediator()
            .UseFluentValidation(typeof(PlaceOrderValidator).Assembly)
            .UseClaimsActorProvider()
            .UseResourceAuthorization(typeof(UpdateOrderCommand).Assembly)
            .UseEntityFrameworkUnitOfWork<AppDbContext>());

        services.AddScoped<IOrderRepository, EfOrderRepository>();

        return services;
    }
}

Builder modules, summarized.

Module What it applies Notes
UseAsp() AddTrellisAsp() Error → status mapping and ResourceCollectionNameRegistry. Does NOT register scalar-value JSON / model-binding validation — compose with UseScalarValueValidation() when binding value-object DTOs.
UseScalarValueValidation() AddScalarValueValidation() Configures both MVC and Minimal API JSON pipelines (model binders + JSON converters + SuppressModelStateInvalidFilter toggle). Independent of UseAsp(). Minimal API hosts must still call app.UseScalarValueValidation() middleware and chain .WithScalarValueValidation() per endpoint.
UseMediator() AddTrellisBehaviors() Registers the canonical Result-aware pipeline behaviors.
UseFluentValidation(...) AddTrellisFluentValidation(...) (from Trellis.Mediator.FluentValidation) Implies UseMediator(). Pass assemblies to scan, or omit assemblies when validators are registered explicitly.
UseClaimsActorProvider() / UseEntraActorProvider() / UseDevelopmentActorProvider() One ASP actor provider The builder rejects multiple actor providers.
UseResourceAuthorization(...) AddResourceAuthorization(...) Implies UseMediator() and scans for resource auth/loaders.
UseDomainEvents(...) AddDomainEventDispatch(...) Implies UseMediator(). Response-shape dispatch uses strict snapshot validation; handlers must be side-effect-only. Mutually exclusive with tracked dispatch.
UseTrackedAggregateDomainEvents(...) AddTrackedAggregateDomainEventDispatch(...) Implies UseMediator(). Dispatches committed aggregate snapshots for outcome-DTO commands and throws on same-aggregate or cross-aggregate cascade. Mutually exclusive with response-shape dispatch.
UseEntityFrameworkUnitOfWork<TContext>() AddTrellisUnitOfWork<TContext>() Implies UseMediator() and is always applied last.

Still app-owned. AddTrellis(...) does not call AddDbContext, AddMediator, or route-constraint registration. Those choices depend on provider, connection string, source-generator setup, migrations, route template names, and hosting style.

For versioned pagination, install Trellis.Asp.ApiVersioning, register Asp.Versioning normally, and replace .UseAsp() with .UseAsp(asp => asp.UseVersionedPageUrls()) (import Trellis.Asp.ApiVersioning). Unversioned hosts keep .UseAsp(); both use the same HttpContext.PageUrl expression. The optional policy uses the existing options slot, so ServiceDefaults does not acquire a versioning-SDK dependency. Location still uses .WithVersionedRoute() per response.

Set options.ServiceLifetime = ServiceLifetime.Scoped on AddMediator(...) in any host that creates a request/execution scope (ASP.NET Core, workers). Mediator's default lifetime is Singleton, but the Trellis pipeline behaviors depend on per-request services (IActorProvider, IUnitOfWork, IMessageValidator<>), so a singleton handler/behavior fails the DI root-scope validation the moment it resolves a scoped dependency — a build-clean service that throws at startup. (Same guidance: Trellis.Mediator README and the Mediator integration article.)


Recipe 13 — Composite value object end-to-end (Domain + API JSON binding + EF Core ownership)

Task within this recipe Read
Define and persist the composite value object Domain and persistence contract, including the solution and storage rules
Choose the JSON boundary shape JSON wire shape, then supported interiors and DTO seam
Map a read-only collection with a backing field Owned collections
Only require a nonblank string Core string guard; the composite recipe is not needed

Composite value object domain and persistence contract

Problem. Persist a multi-field value object (ShippingAddress with street/city/state/postalCode/country) as part of a Customer aggregate. Every field is required, the VO must validate at construction, and the JSON wire format must reuse the same validation as the domain TryCreate.

The unobvious bits this recipe pins down:

  • ApplyTrellisConventions already configures composite value objects as owned navigations — you do not need builder.OwnsOne(...) in your IEntityTypeConfiguration (the CompositeValueObjectConvention discovers them by inheritance from ValueObject when the assembly is passed to ApplyTrellisConventions). The [OwnedEntity] attribute is not the convention's discovery key — it drives the source generator (which emits the parameterless ctor EF Core's materializer needs) and the analyzers TRLS036 / TRLS037 / TRLS038. The convention maps any ValueObject subtype in the scanned assemblies as an owned type; without a parameterless constructor materialization fails — Trellis fails fast at model-build with an actionable TrellisPersistenceMappingException naming the value object (instead of EF Core's cryptic "No suitable constructor was found"). [OwnedEntity] generates the constructor for you. Domain-purity note (axiom A8): because [OwnedEntity] lives in Trellis.EntityFrameworkCore, annotating a domain value object with it references EF Core; to keep a domain value object EF-free, declare a private parameterless constructor yourself instead (as Money does) and the convention materializes via it — [OwnedEntity] is the convenient default, the hand-written ctor is the EF-free alternative. Referencing the package analyzer-only (OutputItemType="Analyzer" ReferenceOutputAssembly="false") pulls in the generators but does not make the layer EF-free — see trellis-api-efcore.md.
  • With [OwnedEntity], the class must be partial (TRLS036), inherit ValueObject (TRLS038), and have no hand-written parameterless constructor (TRLS037) — the source generator emits one for EF Core's materialization path. (To keep the value object EF-free instead, omit [OwnedEntity] and hand-write a private parameterless constructor, as the previous bullet describes.)
  • Recipe 1's custom Money uses that EF-free alternative with private setters; the owning aggregate also needs a materialization constructor that does not take owned navigations. The Customer(CustomerId id) constructor below serves that purpose.
  • [JsonConverter(typeof(CompositeValueObjectJsonConverter<TSelf>))] routes JSON deserialization through the public TryCreate, so the API surface and the domain agree on what's valid. Without it, model binding produces a default-constructed VO that bypasses TryCreate.
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Trellis;
using Trellis.EntityFrameworkCore;
using Trellis.Primitives;

[OwnedEntity]                                                        // TRLS036 if not partial; TRLS037 if you add a parameterless ctor; TRLS038 if not ValueObject
[JsonConverter(typeof(CompositeValueObjectJsonConverter<ShippingAddress>))]
public partial class ShippingAddress : ValueObject
{
    public string Street     { get; private set; } = null!;
    public string City       { get; private set; } = null!;
    public string State      { get; private set; } = null!;
    public string PostalCode { get; private set; } = null!;
    public string Country    { get; private set; } = null!;

    private ShippingAddress(string street, string city, string state, string postalCode, string country)
    {
        Street = street; City = city; State = state; PostalCode = postalCode; Country = country;
    }

    public static Result<ShippingAddress> TryCreate(
        string street, string city, string state, string postalCode, string country, string? fieldName = null)
    {
        var violations = new List<FieldViolation>(5);
        AddIfBlank(violations, street,     fieldName, nameof(Street));
        AddIfBlank(violations, city,       fieldName, nameof(City));
        AddIfBlank(violations, state,      fieldName, nameof(State));
        AddIfBlank(violations, postalCode, fieldName, nameof(PostalCode));
        AddIfBlank(violations, country,    fieldName, nameof(Country));
        return violations.Count > 0
            ? Result.Fail<ShippingAddress>(new Error.InvalidInput(EquatableArray.Create(violations.ToArray())))
            : Result.Ok(new ShippingAddress(street.Trim(), city.Trim(), state.Trim(), postalCode.Trim(), country.Trim()));
    }

    protected override void GetEqualityComponents(ref EqualityComponents components)
    {
        components.Add(Street); components.Add(City); components.Add(State); components.Add(PostalCode); components.Add(Country);
    }

    private static void AddIfBlank(List<FieldViolation> v, string value, string? owner, string part)
    {
        if (!string.IsNullOrWhiteSpace(value)) return;
        var leaf = char.ToLowerInvariant(part[0]) + part[1..];
        var pointer = string.IsNullOrWhiteSpace(owner)
            ? InputPointer.ForProperty(leaf)
            : new InputPointer($"/{owner}/{leaf}");
        v.Add(new FieldViolation(pointer, ValidationCodes.ValueNotEmpty) { Detail = $"{part} is required." });
    }
}

public sealed partial class CustomerId : RequiredGuid<CustomerId>;

public sealed partial class Customer : Aggregate<CustomerId>
{
    public string Name { get; private set; } = null!;
    public ShippingAddress ShippingAddress { get; private set; } = null!;     // required composite owned VO
    public partial Maybe<ShippingAddress> BillingAddress { get; set; }        // optional composite owned VO

    private Customer(CustomerId id) : base(id) { } // EF cannot bind an owned navigation in a constructor.

    private Customer(CustomerId id, string name, ShippingAddress shipping) : base(id)
    {
        Name = name; ShippingAddress = shipping;
    }

    public static Result<Customer> TryCreate(CustomerId? id, string? name, ShippingAddress? shipping) =>
        Result.EnsureNotNull(id, "id", "Customer id is required.")
            .Combine(name.EnsureNotNullOrWhiteSpace("name", "Name is required."))
            .Combine(Result.EnsureNotNull(shipping, "shipping", "Shipping address is required."))
            .Map((id, name, shipping) => new Customer(id, name, shipping));
}

// CONFIGURATION — note the absence of OwnsOne(c => c.ShippingAddress).
// CompositeValueObjectConvention discovers composite ValueObject types by their inheritance
// from ValueObject (NOT the [OwnedEntity] attribute) through the source-generated
// ApplyTrellisConventionsFor<TContext>().
internal sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
    public void Configure(EntityTypeBuilder<Customer> builder)
    {
        builder.HasKey(c => c.Id);
        builder.Property(c => c.Name).IsRequired();
        // No builder.OwnsOne(c => c.ShippingAddress) — the convention does this for you.
        // No HasConversion(...) on the inner string fields — they are mapped by EF Core directly.
    }
}

public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) =>
        configurationBuilder.ApplyTrellisConventionsFor<AppDbContext>();

    protected override void OnModelCreating(ModelBuilder modelBuilder) =>
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}

What it shows.

  • Null-only required-field guards create errors only for missing values. EnsureNotNullOrWhiteSpace("name", "Name is required.") reports value.not-null for null names and value.not-empty for empty/whitespace names, without creating violations on success or trimming valid text.
  • [OwnedEntity] + partial + ValueObject + private ctor is the contract. The three diagnostics (TRLS036/037/038) catch each violation at compile time.
  • CompositeValueObjectJsonConverter<T> makes JSON deserialization round-trip through TryCreate, so an API request body with an invalid state (one that fails the VO's rule) produces the same Error.InvalidInput shape the domain emits. A missing required inner field is caught earlier as a TrellisJsonValidationException ("required property missing") before TryCreate runs.
  • ApplyTrellisConventions removes the boilerplate OwnsOne call. You only need OwnsOne when you want to override the convention (custom column names, table splitting, indexes on inner properties).

Storage shape.

Aggregate property Storage
Required ShippingAddress (non-nullable) Table-split: 5 columns on the Customers table — ShippingAddress_Street, ShippingAddress_City, ShippingAddress_State, ShippingAddress_PostalCode, ShippingAddress_Country (all NOT NULL).
Optional Maybe<ShippingAddress> CompositeValueObjectConvention table-splits it into the Customers table as nullable columns (BillingAddress_Street, …, all NULL-able); absence is encoded as all-null. It uses a separate table {Owner}_{Property} when the composite has nested owned navigations or a non-nullable value-type inner property — table-splitting can't represent either for an optional dependent (all-null columns would make existence ambiguous, and EF Core rejects making a non-nullable value-type column optional), so row existence encodes presence instead. See the storage rules in trellis-api-efcore.md for the full decision matrix.

Composite value object JSON wire shape

The [JsonConverter(typeof(CompositeValueObjectJsonConverter<T>))] attribute on the value object controls the wire format. There is no auto-discovery — the attribute is required for the converter to engage on request bodies and response payloads.

C# property JSON request/response shape
ShippingAddress ShippingAddress { get; private set; } (required composite VO) "shippingAddress": { "street": "1 Main St", "city": "Redmond", "state": "WA", "postalCode": "98052", "country": "US" } — every field present; an invalid inner field (fails TryCreate) → Error.InvalidInput with field path /shippingAddress/<field>; a missing inner field → a "required property missing" TrellisJsonValidationException.
partial Maybe<ShippingAddress> BillingAddress { get; set; } (optional composite VO on a domain model — not used directly on a request DTO; see Recipe 14) Domain model only. On the wire, request DTOs use a nullable transport (ShippingAddress?) and the controller adapts via Maybe.From(...). Response DTOs project to ShippingAddress? for the same reason.
Money Total { get; private set; } (required composite VO with scalar inner properties — decimal Amount, Currency Currency) "total": { "amount": 49.99, "currency": "USD" } — the inner field casing is camelCase (the CompositeValueObjectJsonConverter emits camelCase property names). Inner scalar VOs (e.g., Currency : RequiredString<Currency>) serialize as their underlying primitive ("USD", not {"value":"USD"}).
Scalar VO (OrderId : RequiredGuid<OrderId>, EmailAddress : RequiredString<EmailAddress>) Always serializes as the underlying primitive ("550e8400-...", "a@b.com"). Never wrapped in { "value": ... }. This is automatic via the source-generated IScalarValue<T,P> JSON converter.

Anti-pattern → fix.

// WRONG — explicit Property() on a composite owned VO. The convention has already
// registered an OwnsOne relationship; calling builder.Property() tries to map the
// composite as a single column, which fails at model validation with
// "The property 'Order.Total' could not be mapped because the database provider
//  does not support the type 'Money'."
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.Property(o => o.Total).IsRequired();           // ❌ — runtime error
        builder.Property(o => o.ShippingAddress).IsRequired(); // ❌ — runtime error
    }
}

// FIX — say nothing about composite owned VOs in IEntityTypeConfiguration. The convention
// auto-registers them as OwnsOne. To override (rename column, add an index on an inner
// property, force table-splitting), use OwnsOne explicitly — it is additive, not duplicative,
// because the convention checks IsOwned() before re-registering.

// WRONG — manual OwnsOne after ApplyTrellisConventions duplicates the convention's work
// and silently overrides any annotations the convention set.
builder.OwnsOne(c => c.ShippingAddress, owned => { /* … */ });

// FIX — let the convention own the registration. Use OwnsOne only to override
// (e.g., to rename columns or add an index on an inner property):
builder.OwnsOne(c => c.ShippingAddress, owned =>
{
    owned.Property(a => a.PostalCode).HasColumnName("PostalCode").HasMaxLength(20);
    owned.HasIndex(a => a.Country);
});
// WRONG — non-partial class (TRLS036) so the generator can't emit the parameterless ctor.
[OwnedEntity]
public class ShippingAddress : ValueObject { /* … */ }

// WRONG — declared parameterless ctor (TRLS037) shadows the generator's emitted one.
[OwnedEntity]
public partial class ShippingAddress : ValueObject { public ShippingAddress() { } }

// WRONG — not a ValueObject (TRLS038), so equality and convention-based mapping break.
[OwnedEntity]
public partial class ShippingAddress { /* … */ }

Owned collections with a private backing field

When an aggregate or composite value object exposes a collection as an IReadOnlyList<T> (or IReadOnlyCollection<T>) facade over a private List<T> field, how you map it depends on whether the element T is a value object or an entity:

  • T is a composite value object (derives from ValueObject): no configuration is required. CompositeValueObjectConvention registers every composite VO as an owned type, and EF Core's navigation discovery binds the read-only facade to its _camelCase backing field automatically — including when the collection lives inside another composite VO, and whether the owner is required or Maybe<T>. Writing Ignore + OwnsMany for a value-object collection is redundant.
  • T is an entity (has its own identity, not a ValueObject): the element is not auto-owned — Trellis conventions auto-own value objects only. By convention EF treats an entity collection as a separate, independently-tracked relationship, so configure OwnsMany explicitly to make it an owned aggregate child, as shown below.
// VALUE-OBJECT collection — zero configuration; the convention owns it.
[OwnedEntity]
public partial class Innings : ValueObject
{
    private readonly List<FallOfWicket> _fallOfWickets = [];            // FallOfWicket : ValueObject
    public IReadOnlyList<FallOfWicket> FallOfWickets => _fallOfWickets; // auto-mapped — no Ignore/OwnsMany
}

Map an entity collection explicitly with expression-based OwnsMany. EF binds the read-only navigation to its conventionally named backing field, so the public facade stays read-only and refactoring tools can follow the mapping:

public sealed partial class Order : Aggregate<OrderId>
{
    private readonly List<LineItem> _lineItems = [];
    public IReadOnlyList<LineItem> LineItems => _lineItems;
    // ...
}

internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);

        builder.OwnsMany(o => o.LineItems, li =>
        {
            li.ToTable("LineItems");
            li.HasKey(x => x.Id);
            // Inner composite VO properties (e.g., LineItem.UnitPrice : Money) are
            // discovered by EF Core's NavigationDiscoveryConvention because
            // CompositeValueObjectConvention registers each composite VO type as Owned
            // globally at model initialization — no extra OwnsOne needed here.
        });
    }
}

Fallback: convention cannot bind the field. Only then ignore the facade and map the field explicitly. The field name is now part of the mapping contract and must be updated if the field is renamed:

builder.Ignore(o => o.LineItems);
builder.OwnsMany<LineItem>("_lineItems", li =>
{
    li.ToTable("LineItems");
    li.HasKey(x => x.Id);
});

Choose the mapping shape deliberately:

Mitigation Compile-time safety Cost
builder.OwnsMany(o => o.LineItems, cfg => cfg.HasKey(...)) directly against the facade Refactor-safe — no magic string; renaming the field is transparent while convention can bind it. Preferred. EF binds the read-only IReadOnlyList<LineItem> navigation to the backing List<LineItem> field by convention. The cfg callback still configures the owned type (cfg.ToTable(...), cfg.Property(...).HasColumnName(...), etc.).
Raw string "_lineItems" None — typo or rename breaks at runtime model-validation. Fallback only when convention cannot resolve the facade-to-field link.
internal const string LineItemsField = "_lineItems"; on Order, then builder.OwnsMany<LineItem>(Order.LineItemsField, …) in the same assembly Refactoring tools follow the constant. Still no compile check that the field actually exists. Exposes a persistence field name through the aggregate; unlike expression mapping, the string still needs updating when the field is renamed.

Why no convention for entity collections (yet). Composite value-object collections are already handled automatically (see the value-object case above) — CompositeValueObjectConvention registers each composite VO as owned, so EF Core's navigation discovery maps the IReadOnlyList<VO> facade with no extra configuration. An equivalent convention for entity collections would need to walk every aggregate, find IReadOnlyList<T> / IReadOnlyCollection<T> properties whose T is an entity, locate a matching _camelCase backing field, and register the OwnsMany against it. This is on the roadmap (tracked as the analogue of MaybeConvention for collections); for now the manual pattern above is the supported approach for entity collections.

Testing on SQLite? An OwnsMany whose per-row key is a store-generated integer inserts fine on SQL Server (int IDENTITY) but fails on SQLite with NOT NULL constraint failed: <Table>.Id, because SQLite only auto-increments a single-column INTEGER PRIMARY KEY. This is stock EF Core + SQLite behavior, not a Trellis issue. Give the collection a client-generated Guid key to make it provider-independent — see trellis-api-efcore.md.

Supported property shapes inside a composite VO — when to map to a DTO instead

CompositeValueObjectJsonConverter<T> is deliberately small. It supports a closed list of primitive types directly and routes Trellis scalar value objects through their underlying primitives. Anything else — including any Maybe<T>, arrays, collections, and nested composite VO properties — throws TrellisJsonValidationException at the first JSON access. The converter never delegates property reads/writes back to JsonSerializer, so adding [JsonConverter] to an inner composite type does not rescue the nested case. EF Core persistence is more permissive (Maybe<T>, arrays, and nested composites work) so the JSON gap only surfaces when the composite VO crosses an HTTP boundary.

The intentional split is: keep the framework converter simple; route consumers with richer shapes through a wire-shape DTO at the controller/endpoint seam (Recipe 14 generalised).

Property type on a composite VO interior JSON via CompositeValueObjectJsonConverter? Recommended path
string ✅ Supported as-is Use directly.
decimal, int, long, short, byte, double, float, bool ✅ Supported as-is Use directly.
Guid, DateTime, DateTimeOffset ✅ Supported as-is Use directly.
Trellis scalar VO (RequiredString<>, RequiredInt<>, RequiredGuid<>, RequiredEnum<>, RequiredDateTime<>, …) — and shipped concretes like EmailAddress, PhoneNumber, Money.Currency ✅ Flattens to underlying primitive on the wire Use directly. The composite converter detects IScalarValue<,> and reads/writes the inner primitive.
Nullable Trellis scalar VO (PropertyType? Optional declared as a property on the composite VO) ⚠️ Write/read asymmetric. Writing null produces JSON null; reading the same JSON throws a primitive-specific validation error (e.g. Property '...' must be a string. for a string-backed scalar like RequiredString<> / RequiredEnum<>; must be an integer. for RequiredInt<>; must be a GUID. for RequiredGuid<>). The converter doesn't propagate TryCreate parameter nullability through its metadata, so ReadPrimitive cannot distinguish a required scalar from a nullable scalar interior. Map to a DTO. Same pattern as Maybe<T> below — declare the optional field on the wire-shape DTO as nullable, lift to Maybe<TScalar> (preferred) or keep nullable on the domain VO and resolve at the seam. The asymmetry is pinned by CompositeVoBoundaryTests.Nullable_scalar_VO_interior_is_write_read_asymmetric_for_null_value.
Nested composite owned VO (e.g., a nested Money or ShippingAddress as a property of another composite VO) ❌ Not supported. CompositeValueObjectJsonConverter<TOuter> does not delegate to JsonSerializer for property values — it only reads/writes its own primitive allowed list directly. Adding [JsonConverter] to the inner type does not help, because the outer converter never asks STJ for an inner converter; it tries to treat the nested type as one of its primitives and trips on the first access. Map to a DTO — declare each nested composite as its own property on the wire-shape DTO. Nested composite VOs serialize correctly only when STJ sees them at the top of a property of a non-Trellis-composite-converter type (i.e., a plain record/class with [JsonConverter] on the nested VO type itself).
Maybe<T> for any T (primitive, scalar VO, composite VO, array) ❌ Not supported. Throws TrellisJsonValidationException: "Unsupported primitive type 'Maybe1...'" regardless of value (SomeorNone). | **Map to a wire-shape DTO.** See Recipe 14 — the same T?` + adapt-at-the-seam pattern applies whether the optional is on the DTO itself or inside a composite VO.
Array (T[]), collection (List<T>, IReadOnlyList<T>, IEnumerable<T>) ❌ Not supported. Map to a DTO with the array on the wire-shape type.

When to use a DTO at the seam (the general rule). If a composite VO's interior holds any property shape outside the supported list — most commonly Maybe<TPrimitive> or arrays — keep the VO clean as a domain type and declare a wire-shape DTO at the controller/endpoint:

// Domain VO: clean, persists fine via EF Core, but cannot serialize as-is.
[OwnedEntity]
public partial class CustomerProfile : ValueObject
{
    public OrderStatus Status { get; private set; } = null!;
    public partial Maybe<int> AgeYears { get; private set; }             // not JSON-serializable on this VO
    public partial Maybe<string> Nickname { get; private set; }          // not JSON-serializable
    public partial Maybe<DateTime[]> LoginHistory { get; private set; }  // not JSON-serializable
    // ...TryCreate, GetEqualityComponents...
}

// Wire-shape DTO at the API seam: everything as nullable transports the converter can serialize.
public sealed record CustomerProfileDto(string Status, int? AgeYears, string? Nickname, DateTime[]? LoginHistory)
{
    public Result<CustomerProfile> ToDomain() =>
        CustomerProfile.TryCreate(
            Status,
            AgeYears.AsMaybe(),
            Maybe.From(Nickname),
            Maybe.From(LoginHistory));

    public static CustomerProfileDto From(CustomerProfile vo) =>
        new(vo.Status.Value,
            vo.AgeYears.AsNullable(),
            vo.Nickname.HasValue ? vo.Nickname.Value : null,
            vo.LoginHistory.HasValue ? vo.LoginHistory.Value : null);
}

The controller takes CustomerProfileDto on inbound requests, calls .ToDomain() to lift to the domain VO, and projects back via CustomerProfileDto.From(...) on responses. The Trellis scalar Maybe<> extensions (AsMaybe / AsNullable) and Maybe.From handle the lift cleanly.

Last-resort escape hatch — write your own JsonConverter<TComposite>. If a service genuinely cannot tolerate the DTO indirection (rare — usually a sign the design wants tightening), the language's standard mechanism still applies: declare [JsonConverter(typeof(YourCustomConverter))] on the composite VO and implement Read/Write directly. The framework does not provide help for this path; the trade-off is more code per-VO in exchange for putting the domain shape directly on the wire. This is not the recommended path — favour the DTO seam unless there is a concrete reason not to.


Recipe 14 — Optional fields in request DTOs: Maybe<TScalar> vs nullable transport

Problem. A request body has an optional field — say phoneNumber on CreateCustomerRequest. The domain models it as Maybe<PhoneNumber> (the canonical Trellis pattern). What does the DTO declare it as?

The answer depends on whether the inner type is a scalar (single-primitive) value object or a composite owned value object. Trellis ships a JSON converter + model binder for the scalar case but not the composite case.

Inner type Pattern Why
Maybe<TScalar> where TScalar : IScalarValue<TScalar, TPrimitive> (e.g., Maybe<EmailAddress>, Maybe<PhoneNumber>) Use Maybe<T> directly on the DTO. AddScalarValueValidation() (or the convenience AddTrellisAspWithScalarValidation()) registers MaybeScalarValueJsonConverterFactory (JSON) and MaybeModelBinder<T,P> (route/query/header); MVC child-validation suppression for None scalar-maybe values is handled internally by the Trellis MVC integration. Call it before MVC model binding is configured. null/missing → None; valid → Maybe.From(validated); invalid → ProblemDetails with the same field path the domain emits.
Maybe<TComposite> where TComposite : ValueObject with multiple fields (e.g., Maybe<ShippingAddress>) Use a nullable transport (TComposite?) and adapt at the controller seam. No MaybeCompositeValueObjectJsonConverterFactory ships today — System.Text.Json would default-construct the inner type, bypassing TryCreate. Wrap with Maybe.From(...) inside the controller.
Maybe<TPrimitive> (e.g., Maybe<int>, Maybe<long>, Maybe<string>, Maybe<Guid>, Maybe<DateTime>) Use Maybe<T> directly on the DTO. AddScalarValueValidation() (or the convenience AddTrellisAspWithScalarValidation()) registers MaybePrimitiveJsonConverterFactory (JSON) and MaybePrimitiveModelBinder<T> (route/query/header). Same closed-primitive allowed list as CompositeValueObjectJsonConverter (string, decimal, int, long, short, byte, double, float, bool, Guid, DateTime, DateTimeOffset). null/missing → None; valid primitive → Maybe.From(value). If the primitive carries domain meaning, you may still prefer wrapping it in a scalar value object (e.g., Age : RequiredInt<Age>) for the wire-time validation TryCreate provides; both shapes are factory-handled.
Maybe<TUnsupportedPrimitive> (e.g., Maybe<DateOnly>, Maybe<TimeOnly>, Maybe<uint>) Use TUnsupportedPrimitive? on the DTO and .AsMaybe() at the seam. These types are outside both the composite-VO converter allowed list and the Maybe<TPrimitive> factory allowed list. The wire-shape DTO + adapter at the controller seam is the same pattern as Maybe<TComposite>.

The same DTO pattern applies inside a composite VO. If a composite value object's interior contains Maybe<TPrimitive> / arrays / collections, CompositeValueObjectJsonConverter rejects them too (see Recipe 13 §"Supported property shapes inside a composite VO"). Keep the composite VO clean as a domain type and declare a wire-shape DTO with nullable transports, then lift on inbound (.AsMaybe() / Maybe.From(...)) and project on outbound (.AsNullable()).

Nested collections in DTOs need TraverseAll, not inline .Match. When a DTO carries a list of items each of which becomes a value object — for example IReadOnlyList<MenuSectionDto> → IReadOnlyList<MenuSection> — use Recipe 20 (TraverseAll / SequenceAll) to accumulate per-item validation failures into one Error.InvalidInput. Inlining .Select(s => s.ToDomain().Match(c => c, e => throw …)) throws on the first invalid row and surfaces as HTTP 500 instead of HTTP 422 with per-field violations.

Pattern A — scalar Maybe<T> directly on the DTO

using Trellis;
using Trellis.Primitives;

// EmailAddress and PhoneNumber are the shipped, validating value objects from Trellis.Primitives
// (PhoneNumber validates E.164, EmailAddress validates format). Use them — do NOT redeclare bare
// RequiredString subclasses, which are lenient (reject null only) and would skip format validation.

public sealed record CreateCustomerRequest(
    EmailAddress         Email,           // required
    Maybe<PhoneNumber>   PhoneNumber);    // optional — null/missing JSON → Maybe.None

[ApiController]
[Route("customers")]
public sealed class CustomersController(ISender sender) : ControllerBase
{
    [HttpPost]
    public ValueTask<ActionResult<CustomerResponse>> Create(
        [FromBody] CreateCustomerRequest request, CancellationToken ct) =>
        sender.Send(new CreateCustomerCommand(request.Email, request.PhoneNumber), ct)
              .ToHttpResponseAsync(CustomerResponse.From)
              .AsActionResultAsync<CustomerResponse>();
}

AddTrellisAspWithScalarValidation() is the only wiring required:

services.AddTrellisAspWithScalarValidation();   // MaybeScalarValueJsonConverterFactory + MaybePrimitiveJsonConverterFactory + MaybeModelBinder + MaybePrimitiveModelBinder + ValidationVisitor patch
services.AddControllers();

Send {"email":"a@b.com","phoneNumber":null} (or omit phoneNumber entirely) → handler receives Maybe<PhoneNumber>.None. Send {"email":"a@b.com","phoneNumber":"not a phone"} → 422 with field path /phoneNumber and the validation message produced by PhoneNumber.TryCreate.

Pattern B — composite owned VO, nullable transport + controller-seam adapter

public sealed record CreateCustomerRequest(
    EmailAddress       Email,
    ShippingAddress?   ShippingAddress);   // nullable transport — NOT Maybe<ShippingAddress>

public sealed class CustomersController(ISender sender) : ControllerBase
{
    [HttpPost]
    public ValueTask<ActionResult<CustomerResponse>> Create(
        [FromBody] CreateCustomerRequest request, CancellationToken ct)
    {
        var shipping = Maybe.From(request.ShippingAddress);

        return sender.Send(new CreateCustomerCommand(request.Email, shipping), ct)
                     .ToHttpResponseAsync(CustomerResponse.From)
                     .AsActionResultAsync<CustomerResponse>();
    }
}

The composite VO must still carry [JsonConverter(typeof(CompositeValueObjectJsonConverter<ShippingAddress>))] (see Recipe 13) so its inner fields round-trip through TryCreate. The seam adapter only handles the optionality.

Why not just declare Maybe<ShippingAddress> on the DTO? MaybeScalarValueJsonConverterFactory.CanConvert checks for IScalarValue<,> on the inner type. Composite VOs do not implement IScalarValue, so the factory returns false, and Maybe<ShippingAddress> falls back to default System.Text.Json serialization — which produces a default-constructed ShippingAddress ({}) wrapped in Maybe.From, silently bypassing TryCreate. That's a correctness bug, not just an ergonomics one. TRLS020 enforces this rule at compile time: any DTO property typed Maybe<TComposite> where TComposite is [OwnedEntity] is flagged as a warning, even when TComposite itself carries [JsonConverter(typeof(CompositeValueObjectJsonConverter<TComposite>))] — the converter operates on TComposite, not on Maybe<TComposite>, so the inner attribute does not rescue the wrapper shape.

Anti-pattern → fix

// WRONG — composite Maybe<T> on DTO. Compiles, deserializes to Maybe.From(default(ShippingAddress)),
// silently skips TryCreate. Discovered only when the persisted entity has empty strings.
public sealed record CreateCustomerRequest(EmailAddress Email, Maybe<ShippingAddress> ShippingAddress);

// FIX — nullable transport + controller-seam adapter (Pattern B above).
public sealed record CreateCustomerRequest(EmailAddress Email, ShippingAddress? ShippingAddress);

// WRONG — bypassing AddScalarValueValidation() (e.g., raw services.AddControllers().AddJsonOptions(...) in isolation)
// drops the Maybe converters AND the SuppressChildValidationMetadataProvider, so MVC's ValidationVisitor
// will throw InvalidOperationException("Maybe has no value.") the moment a None reaches model validation.
services.AddControllers();   // missing scalar-value validation wiring

// FIX — call AddTrellisAspWithScalarValidation() (or AddScalarValueValidation() explicitly) before
// AddControllers(); both call paths are idempotent and configure both pipelines.
services.AddTrellisAspWithScalarValidation();
services.AddControllers();

A future MaybeCompositeValueObjectJsonConverterFactory could make Pattern B unnecessary; until then, nullable transport plus controller-seam adaptation is the supported pattern.


Recipe 15 — (retired)

This recipe previously documented a GetValueOrDefault(SENTINEL) workaround for a TRLS003 false positive on multi-clause Maybe<T> predicates inside Specification<T>.ToExpression(). The false positive was fixed in the analyzer (UnsafeMaybeValueAccess now recognises the multi-clause guard), so the natural shape

o => o.Status == OrderStatus.Submitted
     && o.SubmittedAt.HasValue
     && o.SubmittedAt.Value < _threshold;

is now both readable AND analyzer-clean inside any expression tree (specifications, FluentValidation, EF). The residual EF-Core/FakeRepository parity guidance — AddTrellisInterceptors(), ApplyTrellisConventions, and "share the same Specification<T> between EF and FakeRepository — never duplicate the predicate" — has moved into Recipe 8. Ad-hoc query operators (WhereHasValue, including its typed-predicate overload, WhereEquals, etc.) live in trellis-api-efcore.md.

The recipe number is preserved as a stub so existing bookmark and search-index entries remain stable; future content should renumber from Recipe 40 rather than reusing 15.


Recipe 16 — Unit of work in handlers: Add staging vs immediate SaveAsync

Problem. A command handler creates a new aggregate. Where does the SaveChanges call go? The first time you read a Trellis handler that ends with repo.Add(order); return Result.Ok(order.Id); the question is unavoidable: who actually saves it?

public sealed class CreateOrderHandler(IOrderRepository repo)
    : ICommandHandler<CreateOrderCommand, Result<OrderId>>
{
    public ValueTask<Result<OrderId>> Handle(CreateOrderCommand cmd, CancellationToken ct) =>
        Order.TryCreate(cmd.Total)
            .Tap(repo.Add)                  // stages — no save here
            .Map(o => o.Id)
            .AsValueTask();                 // handler returns immediately
}

repo.Add(entity) stages the aggregate for insertion via EF Core; TransactionalCommandBehavior, registered by services.AddTrellisUnitOfWork<TContext>() in your ACL composition root, automatically calls SaveChangesAsync after every successful handler — no explicit save call is needed in the handler.

What it shows. Handlers in Trellis follow a strict separation: the handler shapes domain state and the pipeline owns the commit boundary. RepositoryBase.Add (the base behind your IOrderRepository) returns void precisely to signal "staged, not yet persisted" — the void return makes it impossible to write the (wrong) await repo.Add(...).Should().BeSuccess(). The mediator pipeline wraps the handler; from innermost (closest to the handler) outward it is: TransactionalCommandBehavior → ValidationBehavior → AuthorizationBehavior → LoggingBehavior → TracingBehavior → ExceptionBehavior (the opt-in ResourceAuthorizationBehavior sits just outside ValidationBehavior). When the handler returns a successful Result<T>, the transactional behavior calls SaveChangesAsync and only then surfaces the result; on failure or exception, nothing is committed (unless the command opts into IPersistOnFailure, which deliberately commits the failure path).

Method Signature Saves immediately? When to use
RepositoryBase.Add(T) (and Remove(T), RemoveByIdAsync(TId)) void / Task<Result<Unit>> for not-found No — staged for the UoW Handlers and any production-shaped repository contract
FakeRepository.Add(T) void n/a (in-memory; visible immediately) Test setup — "put this in the store so the handler can find it"
FakeRepository.SaveAsync(T) Task<Result<Unit>> n/a (in-memory; visible immediately) Tests that explicitly assert on the Result shape, e.g., conflict-result handling

Anti-pattern → fix.

// ❌ Wrong — explicit SaveChangesAsync in the handler. Bypasses TransactionalCommandBehavior,
//   so cross-aggregate behaviors that depend on a single commit boundary (outbox writes,
//   ETag bumps, audit logs) end up in inconsistent states. Also: you've now committed even
//   if a later behavior in the pipeline fails post-handler.
public ValueTask<Result<OrderId>> Handle(CreateOrderCommand cmd, CancellationToken ct) =>
    Order.TryCreate(cmd.Total)
        .Tap(repo.Add)
        .TapAsync(_ => dbContext.SaveChangesAsync(ct));   // ❌ — duplicates UoW, racy

// ❌ Wrong — calling SaveAsync from a production handler. SaveAsync is a FakeRepository
//   convenience for tests. EF repositories don't expose it (and shouldn't).
.TapAsync(o => repo.SaveAsync(o, ct))   // ❌ — IRepository<Order>.SaveAsync doesn't exist

// ✅ Correct — stage with Add, let TransactionalCommandBehavior commit on success.
.Tap(repo.Add)

Test setup pattern. When unit-testing a handler with FakeRepository, prefer Add for setup and reserve SaveAsync for tests that specifically assert on the Result of the save (conflict handling, etc.). The void surface keeps the test intent visually honest: setup should not have a return value to assert on.

// ✅ Setup: void Add — no .GetAwaiter().GetResult(), no Result assertion in setup.
var customers = new FakeRepository<Customer, CustomerId>();
customers.Add(MakeAlice());   // helper builds a valid Customer via Customer.TryCreate(...)

// ✅ Conflict-result test: SaveAsync returns the Error.Conflict so the test can assert.
var customers = new FakeRepository<Customer, CustomerId>().WithUniqueConstraint(c => c.Email);
customers.Add(MakeAlice());                                          // first alice — accepted
var result = await customers.SaveAsync(MakeAlice());                 // intentional conflict
result.UnwrapError().Should().BeOfType<Error.Conflict>();

FakeRepository.Add enforces unique constraints eagerly by throwing InvalidOperationException — setup-time violations are almost always test bugs and should fail loud at the offending call site, not at a deferred Result assertion further down. Use SaveAsync when you specifically want to test handler behavior on conflict (where the Error.Conflict Result is the system-under-test, not a setup mistake).

DI prerequisites checklist.

services
    .AddTrellisBehaviors()                              // exception/tracing/logging/authorization/validation
    .AddTrellisFluentValidation(typeof(MyValidator).Assembly)
    .AddTrellisUnitOfWork<AppDbContext>()               // ⬅ registers TransactionalCommandBehavior
    .AddScoped<IOrderRepository, EfOrderRepository>();

Without AddTrellisUnitOfWork<TContext>(), repo.Add(order) stages the entity but nothing ever calls SaveChangesAsync — handler tests against EF (or against a real database) silently insert nothing. This is the production analogue of the fake/real divergence trap covered in Recipe 8: the tests pass against FakeRepository (which has no UoW boundary, so Add is immediately visible), and production silently commits nothing. Always wire AddTrellisUnitOfWork in the ACL composition root, not inside each handler.


Recipe 17 — Defining custom domain events: OccurredAt is the only timestamp

Problem. You're modeling an order workflow and reach for a domain event:

// ❌ Wrong — CS0535 'OrderSubmitted does not implement IDomainEvent.OccurredAt'
public sealed record OrderSubmitted(OrderId OrderId, Money Total, DateTimeOffset SubmittedAt) : IDomainEvent;

The compile error is unambiguous, but the obvious "fix" — adding OccurredAt alongside SubmittedAt — is the wrong shape:

// ❌ Wrong — duplicate timestamps. SubmittedAt and OccurredAt always carry the same value.
public sealed record OrderSubmitted(OrderId OrderId, Money Total, DateTimeOffset SubmittedAt, DateTimeOffset OccurredAt) : IDomainEvent;

Fix. OccurredAt is the canonical, only timestamp on every domain event. The semantic meaning ("when the order was submitted") is carried by the event type name (OrderSubmitted), not by a parallel timestamp field. Drop the semantic alias:

// ✅ Correct — OccurredAt is the timestamp; the event name carries the semantic.
public sealed record OrderSubmitted(OrderId OrderId, Money Total, DateTimeOffset OccurredAt) : IDomainEvent;
public sealed record OrderApproved(OrderId OrderId, ActorId ApprovedBy, DateTimeOffset OccurredAt) : IDomainEvent;
public sealed record OrderShipped(OrderId OrderId, TrackingNumber Tracking, DateTimeOffset OccurredAt) : IDomainEvent;

Raising the event. Always pass TimeProvider.GetUtcNow() (the .NET 8 testable-clock primitive returns DateTimeOffset directly — no conversion needed). The aggregate's domain method, not the event constructor, is where time enters the system:

public Result<Order> Submit(TimeProvider clock)
{
    return Result.Ok(this)
        .Ensure(static order => order.Status == OrderStatus.Draft, static _ =>
            Error.InvalidInput.ForRule(code: "order.already-submitted", detail: "Already submitted"))
        .Tap(_ =>
        {
            Status = OrderStatus.Submitted;
            DomainEvents.Add(new OrderSubmitted(Id, Total, clock.GetUtcNow()));
        });
}
Important

Domain-event handlers are side-effect-only. The mediator dispatch behaviors snapshot UncommittedEvents() at dispatch entry and publish only that snapshot. If a handler raises an event on the same aggregate, post-dispatch validation throws DomainEventHandlerCascadedException and the aggregate's events are not cleared. If a side effect needs more domain mutation, send a separate Mediator command after the originating command completes, or queue post-commit work that runs as its own top-level command — not from inside the handler.

On the aggregate. If your aggregate also exposes a public SubmittedAt property (e.g., to drive UI sort order or read-model projections), source it from the event timestamp at write time — don't track it independently:

public DateTimeOffset? SubmittedAt { get; private set; }

public Result<Order> Submit(TimeProvider clock)
{
    var occurredAt = clock.GetUtcNow();
    // ... ensure rules ...
    Status = OrderStatus.Submitted;
    SubmittedAt = occurredAt;
    DomainEvents.Add(new OrderSubmitted(Id, Total, occurredAt));
    return Result.Ok(this);
}

Why a single timestamp. Domain events flow into outbox tables, integration buses, audit projections, and event-sourced read models. Every consumer assumes OccurredAt is the occurrence time. Adding SubmittedAt/ApprovedAt/ShippedAt to individual events forces every consumer to know which field to project per event type — and the two fields can drift if the aggregate's setter and the event constructor are passed different clock.GetUtcNow() calls.

Why DateTimeOffset. OccurredAt is DateTimeOffset (not DateTime) so the explicit offset is a part of the value and round-trips unambiguously through serialization. TimeProvider.GetUtcNow() returns DateTimeOffset directly — events stored in outbox tables, integration buses, and audit projections retain their authored instant without timezone-loss bugs.

See also. The XML doc on IDomainEvent.OccurredAt (in Trellis.Core) calls this out explicitly. If your IDE shows the doc on hover, the rule is right there before you hit the compile error. For dispatch-handler cascade traps, see trellis-api-anti-patterns.md.


Recipe 18 — DTO primitives to value-object command: no test-only Unwrap()

Problem. Request DTOs often carry primitive transport fields (string email, string customerName), while commands and domain methods should receive Trellis value objects. Each TryCreate returns Result<TVO>. Do not use Unwrap() in production code — it is a Trellis.Testing helper for tests.

using Mediator;
using Microsoft.AspNetCore.Mvc;
using Trellis;
using Trellis.Asp;
using Trellis.Primitives;

public sealed record CreateCustomerRequest(string Email, string CustomerName);

public sealed partial class CustomerId : RequiredGuid<CustomerId>;

public sealed record CustomerResponse(CustomerId Id, string Email, string CustomerName);

[StringLength(200, MinimumLength = 1)]
public sealed partial class CustomerName : RequiredString<CustomerName>;

public sealed record CreateCustomerCommand(EmailAddress Email, CustomerName CustomerName)
    : ICommand<Result<CustomerResponse>>
{
    public static Result<CreateCustomerCommand> TryCreate(CreateCustomerRequest request) =>
        Result.Combine(
                EmailAddress.TryCreate(request.Email, nameof(request.Email)),
                CustomerName.TryCreate(request.CustomerName, nameof(request.CustomerName)))
            .Map((email, customerName) => new CreateCustomerCommand(email, customerName));
}

[ApiController]
[Route("customers")]
public sealed class CustomersController(ISender sender) : ControllerBase
{
    [HttpPost]
    public ValueTask<ActionResult<CustomerResponse>> Create(
        [FromBody] CreateCustomerRequest request,
        CancellationToken ct) =>
        CreateCustomerCommand.TryCreate(request)
            .BindAsync(command => sender.Send(command, ct))
            .ToHttpResponseAsync()
            .AsActionResultAsync<CustomerResponse>();
}

What it shows.

  • Keep DTOs transport-shaped and commands/domain methods value-object-shaped.
  • Pass field names into TryCreate so failures point at the request field (/email, /customerName after pointer normalization at the ASP boundary).
  • Use Result.Combine(...) to aggregate per-field Error.InvalidInput failures into one validation response.
  • Stay on the ROP track: invalid input short-circuits before sender.Send(...); valid input creates the command and continues.

Anti-pattern -> fix.

// WRONG — Unwrap() is test-only and turns validation failures into thrown exceptions.
var command = new CreateCustomerCommand(
    EmailAddress.TryCreate(request.Email).Unwrap(),
    CustomerName.TryCreate(request.CustomerName).Unwrap());

// FIX — aggregate value-object creation results and bind into the command.
var command = Result.Combine(
        EmailAddress.TryCreate(request.Email, nameof(request.Email)),
        CustomerName.TryCreate(request.CustomerName, nameof(request.CustomerName)))
    .Map((email, customerName) => new CreateCustomerCommand(email, customerName));

Already-created but nullable values. A command factory can also receive value objects that have already been validated but might be absent. Guard their presence without re-parsing, returning Result<Unit>, or recovering the values with !:

public static Result<CreateCustomerCommand> RequireValues(
    EmailAddress? email, CustomerName? customerName) =>
    Result.EnsureNotNull(email, "email", "Email is required.")
        .Combine(Result.EnsureNotNull(customerName, "customerName", "Customer name is required."))
        .Map((email, customerName) => new CreateCustomerCommand(email, customerName));

public static Task<Result<CreateCustomerCommand>> RequireValuesAsync(
    Task<EmailAddress?> email, Task<CustomerName?> customerName) =>
    email.EnsureNotNullAsync("email", "Email is required.")
        .CombineAsync(customerName.EnsureNotNullAsync("customerName", "Customer name is required."))
        .MapAsync((email, customerName) => new CreateCustomerCommand(email, customerName));

These methods belong on the command type above. Success carries the exact non-null references; Combine still reports every missing field. Nullable structs are unwrapped by the same guard, and the async forms also accept ValueTask<T?>. The field/detail overloads construct standard value.not-null violations only on failure. To choose another error, prefer a lazy Func<Error> that constructs it inside the callback; pass Error when an existing instance is intentionally reused. For a query where absence means not found rather than invalid input, choose a lazy Error.NotFound factory instead of a required-field violation.

Use Error.InvalidInput.Required(fieldName, detail) for conditional requiredness or collection-element rules that are not a plain null check. Its InputPointer form preserves input location and can be used lazily: Result.EnsureNotNull(value, () => Error.InvalidInput.Required(inputPointer, detail)). Composite validators can use FieldViolation.Required(fieldName, detail) or FieldViolation.Required(inputPointer, detail) to retain an indexed path and input location. Core's nullable ToResult APIs and universal no-argument lift have been removed: use these guards for required values, Result.Ok(value) for deliberate success wrapping, and maybe.ToResult(errorFactory) when ordinary absence becomes a failure. TRLS066 now emits the static guard shape.

Nested collections. When CreateCustomerRequest carries a List<AddressDto> whose items each need to become value objects, the Result.Combine shape above doesn't generalize to the collection — use Recipe 20 (TraverseAll) to validate every row and accumulate per-item failures into one Error.InvalidInput. Inlining .Select(item => item.ToCommand().Match(c => c, e => throw …)) throws on the first invalid row and surfaces as HTTP 500 instead of HTTP 422 with field violations.


Recipe 19 — HTTP client result safety and optional reads

Problem. Call an upstream HTTP resource safely, preserving non-success status codes as Trellis errors and treating a missing optional resource as Maybe.None.

using System.Net;
using System.Text.Json.Serialization;
using Trellis;
using Trellis.Http;

[JsonSerializable(typeof(OrderDto))]
public sealed partial class OrderJsonContext : JsonSerializerContext;

public sealed record OrderDto(Guid Id, decimal Total);

public Task<Result<OrderDto>> GetRequiredOrderAsync(HttpClient client, Guid id, CancellationToken ct) =>
    client.GetAsync($"/orders/{id}", ct)
        .ToResultAsync()
        .ReadJsonAsync(OrderJsonContext.Default.OrderDto, ct);

public Task<Result<Maybe<OrderDto>>> FindOrderAsync(HttpClient client, Guid id, CancellationToken ct) =>
    client.GetAsync($"/orders/{id}", ct)
        .ReadJsonOrNoneOn404Async(OrderJsonContext.Default.OrderDto, ct);

What it shows.

  • Bare ToResultAsync() is strict in v3: 2xx responses stay on the success track; non-2xx responses become typed Trellis errors.
  • Use ReadJsonOrNoneOn404Async(...) when 404 is expected domain absence, not failure.
  • Use explicit status mapping only when the upstream status needs a domain-specific resource or policy:
client.GetAsync($"/orders/{id}", ct)
    .ToResultAsync(status => status == HttpStatusCode.NotFound
        ? new Error.NotFound(ResourceRef.For<OrderDto>(id))
        : null)
    .ReadJsonAsync(OrderJsonContext.Default.OrderDto, ct);

Recipe 20 — Fail-fast vs accumulating: Sequence/Traverse vs SequenceAll/TraverseAll

Problem. A single pipeline contains two distinct collection-level concerns:

  1. Form-style validation of a batch payload — every invalid row should be reported in one response, not just the first.
  2. A fan-out fetch — once one upstream fetch fails, finishing the rest is wasted I/O; the first failure should win.

Trellis ships both shapes on the same surface so you can pick the semantics per call site.

using Trellis;
using Trellis.Primitives;

public sealed record CreateContactRow(string Email, string Name);

// 1) Accumulating: every row's failure surfaces in the response.
public Result<IReadOnlyList<EmailAddress>> ValidateAddresses(IEnumerable<CreateContactRow> rows) =>
    rows.TraverseAll(row => EmailAddress.TryCreate(row.Email));
//        ↑ TraverseAll runs the selector for every row.
//          - All succeed   → Ok(list)
//          - One bad email → that single Error.InvalidInput (no Aggregate wrap)
//          - Many bad      → one merged Error.InvalidInput whose
//                            Fields/Rules concatenate every per-item violation
//          - Mixed kinds   → flat Error.Aggregate of every distinct error

// Indexed accumulation: errors identify the original row, e.g. /contacts/2/email.
public static Result<IReadOnlyList<EmailAddress>> ValidateAddressesIndexed(IEnumerable<CreateContactRow> rows) =>
    rows.TraverseAll((row, index) => EmailAddress.TryCreate(row.Email,
        InputPointer.Root.AppendProperty("contacts").AppendIndex(index).AppendProperty("email").Path));

// Application-owned async validation receives each row, index, and token.
public static Task<Result<IReadOnlyList<EmailAddress>>> ValidateAddressesIndexedAsync(
    IEnumerable<CreateContactRow> rows,
    Func<CreateContactRow, int, CancellationToken, Task<Result<EmailAddress>>> validateAsync,
    CancellationToken ct) =>
    rows.TraverseAllAsync((row, index, token) => validateAsync(row, index, token), ct);

// 2) Fail-fast: stop on the first upstream miss.
public Task<Result<IReadOnlyList<Order>>> LoadOrders(IEnumerable<OrderId> ids, CancellationToken ct) =>
    ids.TraverseAsync((id, c) => repo.LoadAsync(id, c), ct);
//        ↑ TraverseAsync short-circuits on the first failure — no
//          subsequent repository calls are issued.

What it shows.

  • TraverseAll / SequenceAll exist precisely to solve "show me every error". They use the same Error.Combine extension as EnsureAll, so two InvalidInput failures merge and unrelated failures flatten into Error.Aggregate.
  • Traverse / Sequence exist precisely to solve "stop wasting work on the first failure". They never accumulate into an Error.Aggregate; they propagate the first failure as-is (which means if a selector itself returns Result.Fail<T>(new Error.Aggregate(...)), that Aggregate flows through unchanged — not because Traverse created it, but because Traverse preserves whatever the failing selector produced).
  • The unindexed TraverseAll family ships the same async surface as Traverse: sync, Task, Task + CancellationToken, ValueTask, ValueTask + CancellationToken, plus a Task<Result<Unit>> + CancellationToken overload. SequenceAll is sync-only because Sequence is sync-only; if async siblings ever land for Sequence, they land for SequenceAll at the same time.
  • Indexed TraverseAll accepts (item, index); indexed TraverseAllAsync accepts (item, index, token) for Task, ValueTask, and no-payload Task selectors. The token argument is optional, but the lambda keeps all three parameters to distinguish it from the existing (item, token) overload. Indices are zero-based source positions, not success counts; async selectors run sequentially and failures do not stop subsequent items.
  • Already have an IEnumerable<Result<T>> (e.g. from a Select over a TryCreate)? Pick .Sequence() (fail-fast) or .SequenceAll() (accumulating); they're the identity-selector forms of Traverse / TraverseAll.

Anti-pattern → fix.

// ❌ Manual loop with early return: loses every error after the first.
foreach (var row in rows)
{
    var r = EmailAddress.TryCreate(row.Email);
    if (r.IsFailure) return r.Map(_ => default(IReadOnlyList<EmailAddress>)!);
    parsed.Add(r.Unwrap());
}
// ✅ Explicit choice between fail-fast and accumulating semantics:
return rows.TraverseAll(row => EmailAddress.TryCreate(row.Email));

Recipe 21 — Parallel independent loads in handlers: Result.ParallelAsync + WhenAllAsync

Problem. A handler needs two (or more) loads that are genuinely independent — a customer record from one upstream service, a product record from another; an HTTP call to authn plus a DB read for profile; or two reads against two distinct EF Core DbContext instances. Awaiting each load before starting the next serializes their latency. Starting both before awaiting either allows concurrency. Task.WhenAll preserves their Result<T> values but does not combine them into one result; the tuple .WhenAllAsync() extension supplies that fold.

Result.ParallelAsync(...) is the framework's opinionated entry point: factory-takes-no-args, eagerly invokes each factory so both tasks actually run concurrently, returns a tuple of Task<Result<T>> that the matching .WhenAllAsync() extension awaits with Task.WhenAll and folds via Result.Combine into a single Result<(T1, T2, …)>. Failures combine through Error.Combine, so two Error.InvalidInput failures merge their fields, heterogeneous failures become an Error.Aggregate.

🛑 Danger — Result.ParallelAsync against repositories that share a DbContext instance will race and throw. The hard rule, independent of DI: never start a second operation on a DbContext before the first one has completed. EF Core's DbContext is documented as not thread-safe; the second concurrent operation throws InvalidOperationException: A second operation was started on this context instance before a previous operation completed. The typical Trellis EF wiring triggers this case by construction: services.AddDbContext<TContext>(...) registers the context with its default scoped lifetime (this is the default of the parameterless overload — AddDbContext<TContext>(..., ServiceLifetime) can override it), and services.AddTrellisUnitOfWork<TContext>() registers IUnitOfWork as scoped and consumes the scoped TContext. So every repository resolved from the same request scope shares one DbContext instance, and parallelising two repository reads parallelises two operations on that one context. Keep them sequential — see "When NOT to use it" below. Result.ParallelAsync is for genuinely cross-resource concurrency (HTTP + DB, two distinct upstream services, factory-created independent contexts), not for two repository reads against the same store.

using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Trellis;

public sealed record CheckoutCommand(UserId UserId, ProductSku Sku) : ICommand<Result<CheckoutQuote>>;

// Genuinely cross-resource: one upstream HTTP call + one read against a DIFFERENT
// store (a pricing cache). These have no shared state and run safely in parallel.
public sealed class CheckoutHandler(
    IUserDirectoryClient directory,   // outbound HTTP
    IPricingCache pricing)            // independent in-memory store
    : ICommandHandler<CheckoutCommand, Result<CheckoutQuote>>
{
    public ValueTask<Result<CheckoutQuote>> Handle(CheckoutCommand command, CancellationToken cancellationToken) =>
        new(Result.ParallelAsync(
                //  ↑ takes parameterless factory funcs — NOT pre-started tasks.
                //    Each factory is invoked eagerly here so both fetches execute concurrently.
                () => directory.FindUserAsync(command.UserId, cancellationToken),
                () => pricing.GetQuoteAsync(command.Sku, cancellationToken))
            .WhenAllAsync()
            //  ↑ awaits Task.WhenAll, folds the two Result<T> into Result<(User, Quote)>
            //    via Result.Combine. Two Error.InvalidInput failures merge their
            //    Fields/Rules; heterogeneous errors flatten into Error.Aggregate.
            .BindAsync(t => CheckoutQuote.Create(t.Item1, t.Item2, command.Sku)));
}

What it shows.

  • Result.ParallelAsync takes Func<Task<Result<T>>> factories, NOT Task<Result<T>> instances. It invokes each factory without awaiting the returned tasks. Already-started tasks can also run concurrently: (LoadUserAsync(), LoadQuoteAsync()).WhenAllAsync() is supported. Both shapes invoke the synchronous portions of the operations in order; neither makes blocking work parallel.
  • .WhenAllAsync() on the tuple is the matching extension. Without it you still have a tuple of Task<Result<T>> — which isn't awaitable on its own; you'd have to await each task individually and combine the results by hand. .WhenAllAsync() is the one-line fold.
  • The combined Result<(T1, T2)> flows back into the standard ROP chain (BindAsync, MapAsync, TapAsync) — no match / if (success) branches.
  • Result.ParallelAsync ships overloads for 2–9 factories. For dynamic collections, TraverseAsync is a sequential, fail-fast alternative, not parallel fan-in; TraverseAllAsync is also sequential but accumulates failures (Recipe 20). If collection loads need concurrency, use an explicitly bounded concurrency design over independent resources and deliberately choose failure aggregation and cancellation behavior.
  • Task faults and cancellation propagate from .WhenAllAsync() after all supplied tasks complete; they are not converted to Result failures. A factory that throws synchronously escapes from ParallelAsync immediately, so later factories are not invoked.

When NOT to use it.

  1. Two or more repositories sharing the same scoped DbContext. The most common case in a typical Trellis service. The repos look independent at the C# level, but they all derive from RepositoryBase<TAggregate, TId> over the same scoped TContext. Parallelising them races the underlying context and throws InvalidOperationException. Keep them sequential with BindZipAsync (it awaits the first, runs the second only on success, and zips both into a tuple — short-circuiting on failure). This is a correctness requirement regardless of database latency. Use independent contexts only when their consistency and unit-of-work boundaries fit the operation.
  2. The second factory's body references a value produced by the first. Not independent — keep the sequential BindAsync chain. The rule is mechanical: if the second load requires data the first one produced (an id, a filter, a cursor), the two are sequential by definition.
  3. Side-effecting writes. Result.ParallelAsync is for reads. Parallel repository.Add(...) calls against a shared context have the same race as parallel reads, plus tracker contention; parallel writes against per-scope contexts need transaction coordination outside this helper.

Anti-pattern → fix.

// ❌ UNSAFE — two repository calls against repositories that share a scoped DbContext.
// Looks "obviously parallelisable" but races the underlying EF context. `Task.WhenAll`
// waits for both returned tasks to complete before surfacing a task fault. The failure mode
// is an `InvalidOperationException("A second operation was started on this context...")`
// thrown by EF Core when the second concurrent operation hits the shared connection.
// Reproduction is timing-dependent: the throw is reliable under contention but can be
// missed on a near-instant warm cache, which lulls authors into thinking it's correct.
public ValueTask<Result<DraftOrderId>> Handle(CreateDraftOrderCommand command, CancellationToken cancellationToken) =>
    new(Result.ParallelAsync(
            () => _customers.FindByIdAsync(command.CustomerId, cancellationToken),  // shared DbContext
            () => _products.FindByIdAsync(command.ProductId, cancellationToken))    // shared DbContext
        .WhenAllAsync()
        .BindAsync(t => DraftOrder.CreateDraft(t.Item1, t.Item2, command.Quantity))
        .TapAsync(_orders.Add)
        .MapAsync(o => o.Id));

// ✅ Sequential against a shared DbContext — correct by construction, and fluent.
// `BindZipAsync` awaits the first read, runs the second ONLY if the first succeeded
// (short-circuits), and zips both into `Result<(Customer, Product)>`. The two reads
// never overlap, so the shared context is never raced. Latency = the sum of two
// reads; correctness does not depend on those reads being fast.
public ValueTask<Result<DraftOrderId>> Handle(CreateDraftOrderCommand command, CancellationToken cancellationToken) =>
    new(_customers.FindByIdAsync(command.CustomerId, cancellationToken)
        .BindZipAsync(_ => _products.FindByIdAsync(command.ProductId, cancellationToken))
        .BindAsync((customer, product) => DraftOrder.CreateDraft(customer, product, command.Quantity))
        .TapAsync(_orders.Add)
        .MapAsync(o => o.Id));

// Alternative — eager await + Result.Combine. Also sequential and safe, but it does
// NOT short-circuit: the second read runs even when the first already failed. Prefer
// this only when you deliberately want to ACCUMULATE both failures — Combine merges
// their errors (e.g. report "customer not found" AND "product not found" together).
public async ValueTask<Result<DraftOrderId>> HandleAccumulating(CreateDraftOrderCommand command, CancellationToken cancellationToken)
{
    var customerResult = await _customers.FindByIdAsync(command.CustomerId, cancellationToken);
    var productResult  = await _products.FindByIdAsync(command.ProductId, cancellationToken);

    return Result.Combine(customerResult, productResult)
        .Bind(t => DraftOrder.CreateDraft(t.Item1, t.Item2, command.Quantity))
        .Tap(_orders.Add)
        .Map(o => o.Id);
}

// ✅ Truly parallel — only when the two loads hit independent resources. The HTTP
// call and the in-memory cache have no shared state; ParallelAsync is safe here.
//
// For two EF reads in genuine parallel, inject `IDbContextFactory<TContext>` and
// open two short-lived contexts inside the handler:
//
//   await using var aCtx = await _factory.CreateDbContextAsync(ct);
//   await using var bCtx = await _factory.CreateDbContextAsync(ct);
//   // ... use aCtx and bCtx independently inside ParallelAsync factories ...
//
// The trade-off: two extra contexts and their connections per request, in exchange
// for parallel reads. Worth it only when the queries are slow.

Problem. A command needs to mutate one primary aggregate (an Order) and trigger a side effect on each element of a related-aggregate set (release reserved stock on every Product referenced by the Order's line items). The naive shape is a foreach with a dictionary lookup of the related aggregate by id. If a referenced related aggregate is missing from that lookup — invariant violation; under normal flow it should never happen — what is the correct behaviour?

The wrong answer: if (!dict.TryGetValue(id, out var related)) continue; and move on. The handler appears to succeed, the primary aggregate transitions, the unit of work commits, and the side effect is silently skipped for the missing element. The orchestration has broken its own post-condition ("stock is released for every line item") with no signal to the caller and no rollback.

The right answer: fail-loud. Return Error.NotFound referencing the missing related aggregate so the unit of work rolls back the primary aggregate's mutation. The invariant — "every line item resolves to a Product, or the command fails atomically" — is now enforced and observable.

Two operating principles drive the snippet below:

  • Preflight before mutating, not during. An in-memory aggregate mutation persists through the rest of the handler's object graph even if the later database commit rolls back. So a release-then-discover pattern leaks partially-released stock into any subsequent read of the same aggregate within the request scope. Compute the missing-set before any side effect.
  • Report the full problem. That's the value of preflighting — surface every missing id in one round-trip via Error.Aggregate, with each missing id keeping its own structured ResourceRef. (This recipe assumes product ids are non-sensitive; if your threat model differs, drop the per-id refs and emit a single generic NotFound.)
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Trellis;

public sealed class ReturnOrderHandler(
    IOrderRepository orders,
    IProductRepository products,
    TimeProvider timeProvider) : ICommandHandler<ReturnOrderCommand, Result<Order>>
{
    public async ValueTask<Result<Order>> Handle(ReturnOrderCommand command, CancellationToken cancellationToken)
    {
        // Repository find returns Result<T> — matches Recipe 21's repository shape;
        // .TryGetValue extracts the success value or short-circuits on the existing Error.
        var orderResult = await orders.FindByIdAsync(command.OrderId, cancellationToken);
        if (!orderResult.TryGetValue(out var order))
            return orderResult;

        // Batch fetch returns what it found — the set-difference is the orchestrator's job.
        var productIds = order.LineItems.Select(li => li.ProductId).Distinct().ToArray();
        var loaded = await products.GetByIdsAsync(productIds, cancellationToken);
        var byId = loaded.ToDictionary(p => p.Id);

        // Preflight: prove EVERY related aggregate is reachable BEFORE any side effect.
        // NEVER `continue` past a missing related aggregate, and NEVER mutate before the
        // set is fully reachable. Report ALL missing ids via Error.Aggregate.
        static Error NotFoundFor(ProductId id) => new Error.NotFound(ResourceRef.For<Product>(id))
        {
            Detail = "Product referenced by line item is missing — cannot release stock.",
        };
        var presence = productIds
            .Select(id => Result.Ensure(byId.ContainsKey(id), () => NotFoundFor(id)))
            .SequenceAll();
        if (presence.IsFailure)
            return Result.Fail<Order>(presence.Error);

        // All related aggregates reachable. Preflight the per-aggregate domain
        // invariants (Recipe 25: pure CanReleaseStock predicate paired with the
        // mutating ReleaseStock) before any mutation. Releasing stock on Product
        // A and then failing on Product B's release would leave A in a partially-
        // released state that TransactionalCommandBehavior cannot roll back from
        // the in-memory aggregate graph within the same request.
        var returnPreflight = order.CanReturn(command.Reason);
        if (returnPreflight.IsFailure)
            return Result.Fail<Order>(returnPreflight.Error);

        var releasePlan = order.LineItems
            .GroupBy(li => li.ProductId)
            .Select(group => (Product: byId[group.Key], Quantity: group.Sum(li => (long)li.Quantity)))
            .ToArray();
        var preflight = releasePlan
            .Select(item => item.Product.CanReleaseStock(item.Quantity))
            .SequenceAll();
        if (preflight.IsFailure)
            return Result.Fail<Order>(preflight.Error);

        // Pass 1 succeeded for every aggregate — every Pass 2 mutation below has
        // a matching Can* predicate that just returned Ok, so the mutation is
        // provably non-failing. Discard() marks the Result as consciously dropped.
        foreach (var item in releasePlan)
            item.Product.ReleaseStock(item.Quantity).Discard();

        return order.Return(command.Reason, timeProvider.GetUtcNow()).Map(_ => order);
    }
}

CanReturn(reason) is an application-defined pure predicate, also called by Return: it checks the reason, eligible order state, and positive line quantities before inventory changes. The sample's CanReleaseStock(long) / ReleaseStock(long) accept the grouped total; summing into long prevents an int overflow before validation. Both passes use the same materialized plan, once per product, with no intervening awaits or mutations that invalidate the predicates. Checking two separate quantities of 3 against 5 reserved is not equivalent to checking their total of 6. Add regression cases for duplicate product ids, invalid reasons, and an already-returned order, asserting unchanged inventory and no new domain events on failure.

The test-design principle: Partial-Failure Atomicity.

For every post-condition phrased as "X is done for every Y in a set," write a test where one Y is unreachable at operation time. The post-condition must hold under one of two regimes:

  1. All-or-nothing: the operation fails atomically; no X is performed.
  2. Best-effort with explicit signal: every reachable Y has X performed; every unreachable Y produces a recorded error in the result.

The implementation must pick one explicitly. If a "for every" handler silently continues past an unreachable Y with neither failure nor signal, it has broken the post-condition and the unit of work has committed an inconsistent state.

The test must hold the related-aggregate's pre-operation state and assert that no partial side-effect leaked through.

The generator test (the one a coverage-driven author would never produce from a happy-path-only mental model):

[Fact]
public async Task Return_with_missing_product_fails_atomically_and_does_not_release_stock()
{
    // Seed two products A and B; build a Delivered order with line items referencing both.
    var (order, productA, productB) = await SeedDeliveredWithTwoProductsAsync();
    var aStockBefore = productA.StockQuantity;
    var bStockBefore = productB.StockQuantity;

    // Disrupt the universally-quantified set: drop productB from the repository.
    _products.Remove(productB);

    var r = await _sender.Send(new ReturnOrderCommand(order.Id, _reason), cancellationToken);

    // The orchestration MUST be atomic: either every line item's stock is released,
    // or the operation fails and NOTHING is released.
    r.Should().BeFailureOfType<Error.NotFound>();
    productA.StockQuantity.Should().Be(aStockBefore);  // A NOT partially released
    productB.StockQuantity.Should().Be(bStockBefore);  // B unmutated (the handler never reached it)
    order.Status.Should().Be(OrderStatus.Delivered);    // Order NOT transitioned (preflight prevented mutation)
}

Generalisation. Anywhere a handler does:

foreach (var x in someSet)
{
    if (!cache.TryGetValue(x.RelatedId, out var related))
        continue;                              // ❌ silent skip — see anti-pattern below
    related.SideEffect(x.Args);
}

…there is a hidden missing-related-aggregate path. Apply the Partial-Failure Atomicity test pattern to every such loop. Failing tests will not be caught by a happy-path-only suite because every test sets up a known-good related-aggregate set.

Anti-pattern → fix.

// ❌ Silent skip — passes every happy-path test, but if a Product disappears between
// the order being created and the return being processed (or if a future refactor
// adds a DeleteProduct endpoint), the return "succeeds" with a partially-released
// stock state. No exception, no Result failure, no log entry. Data corruption.
foreach (var li in order.LineItems)
{
    if (!byId.TryGetValue(li.ProductId, out var product))
        continue;                              // ← invariant violation hidden here
    product.ReleaseStock(li.Quantity);
}

// ✅ Fail-loud with full preflight — same two-pass shape as the worked example above.
// Compute the missing set BEFORE any side effect; only enter the release loop
// when every related aggregate is reachable.
var presence = order.LineItems.Select(li => li.ProductId).Distinct()
    .Select(id => Result.Ensure(byId.ContainsKey(id),
        () => new Error.NotFound(ResourceRef.For<Product>(id))))
    .SequenceAll();
if (presence.IsFailure)
    return Result.Fail<Order>(presence.Error);

// Preflight the per-aggregate domain invariants (Recipe 25) BEFORE any mutation.
// Releasing stock on Product A then failing on Product B would leave A partially
// released — TransactionalCommandBehavior cannot roll back the in-memory aggregate
// graph within the request. Never mutate-and-bail in the loop.
var returnPreflight = order.CanReturn(command.Reason);
if (returnPreflight.IsFailure)
    return Result.Fail<Order>(returnPreflight.Error);
var releasePlan = order.LineItems
    .GroupBy(li => li.ProductId)
    .Select(group => (Product: byId[group.Key], Quantity: group.Sum(li => (long)li.Quantity)))
    .ToArray();
var preflight = releasePlan
    .Select(item => item.Product.CanReleaseStock(item.Quantity))
    .SequenceAll();
if (preflight.IsFailure)
    return Result.Fail<Order>(preflight.Error);

// Pass 2: every mutation has a matching Can* that just returned Ok — provably non-failing.
foreach (var item in releasePlan)
    item.Product.ReleaseStock(item.Quantity).Discard();

Recipe 23 — Concurrency control on aggregate-mutating endpoints: when to require If-Match

Problem. RFC 9110 §13.1.1 lets clients send If-Match: "etag" on unsafe methods to detect stale-read race conditions: if the resource's current ETag doesn't match, the server returns 412 Precondition Failed instead of overwriting concurrent changes. For mutating handlers, the framework provides ETagHelper.ParseIfMatch(request) to extract the typed EntityTagValue[] from the incoming If-Match header, plus the Result<T>.OptionalETag(...) / RequireETag(...) extensions (and their *Async overloads) that evaluate the precondition at the read-modify-write boundary inside the handler chain. (opts.WithETag(...).EvaluatePreconditions() on the response builder is a different feature — it only runs on GET / HEAD for If-None-Match → 304 and If-Match → 412 on safe-method reads; it is not the mutation hook.) The decision question: which mutating endpoints actually need this?

A blanket "require If-Match on every mutation" rule is wrong. Not requiring a header is different from ignoring a supplied header. RFC 9110 §§13.1.1 and 13.2.1 require eligible supplied preconditions to be evaluated before the action; a false If-Match must not perform the mutation. Domain transition guards do not replace this HTTP check. Use OptionalETag when unconditional callers are allowed and RequireETag when the endpoint requires a precondition:

Endpoint shape Lost-update window? Precondition policy
Body-less state-transition POST (POST /orders/{id}/submit, .../approve, .../cancel, .../return) Domain guards reject invalid transitions, but cannot determine whether the client saw the current version. Optional — OptionalETag. Missing header proceeds to the domain guard; a supplied mismatch returns 412 before mutation. Require the header only when the endpoint contract demands it.
Body-carrying full-update PUT (PUT /orders/{id} with a full replacement body) Yes. The body silently overwrites whatever the concurrent edit wrote. Yes — RequireETag. RFC 6585 says 428 Precondition Required when missing, RFC 9110 says 412 Precondition Failed when stale.
Body-carrying partial-update PATCH with a JSON Patch / JSON Merge Patch document Yes. Same overwrite risk as full update. Yes — RequireETag.
Destructive DELETE /resources/{id} Yes. A stale client can delete a version it has not seen after another writer changed it. Required — RequireETag by default. EF Core concurrency tokens catch writes racing after the handler's read, not stale-client reads before the request. A deliberately unconditional guarded-transition contract can use OptionalETag, but must still honor a supplied header.
Additive set operation (POST /orders/{id}/line-items, POST /products/{id}/stock-additions +5) Maybe. Depends on commutativity. Two concurrent +5 calls produce +10 correctly; "remove the last line item" against a stale read can drop the wrong item. Case-by-case. Commutative operations may admit unconditional callers via OptionalETag; non-commutative operations should use RequireETag. Both enforce supplied headers.
Resource creation (POST /customers, POST /products) N/A. No prior version of the new aggregate to match against. Not normally required. Any supplied precondition concerns the request's target resource, not the newly created aggregate's ETag.
using Trellis;
using Trellis.Asp;
using Trellis.EntityFrameworkCore;

// State-transition POST — no required header, but a supplied If-Match is enforced.
// Missing proceeds; mismatching, weak-only, empty, or malformed returns 412 before Approve.
app.MapPost("/orders/{id:guid}/approve", (OrderId id, OrderDbContext db, HttpContext httpContext, CancellationToken ct) =>
    db.Orders
        .FirstOrDefaultResultAsync(o => o.Id == id, new Error.NotFound(ResourceRef.For<Order>(id)), ct)
        .OptionalETagAsync(ETagHelper.ParseIfMatch(httpContext.Request))
        .BindAsync(o => o.Approve())
        .CheckAsync(_ => db.SaveChangesResultUnitAsync(ct))
        .ToHttpResponseAsync(OrderResponse.From));

// Full-update PUT — RequireETag at the read-modify-write boundary.
// Missing If-Match → 428; stale → 412; current → proceeds.
app.MapPut("/orders/{id:guid}", (OrderId id, ReplaceOrderRequest request, OrderDbContext db, HttpContext httpContext, CancellationToken ct) =>
    db.Orders
        .FirstOrDefaultResultAsync(o => o.Id == id, new Error.NotFound(ResourceRef.For<Order>(id)), ct)
        .RequireETagAsync(ETagHelper.ParseIfMatch(httpContext.Request))
        .BindAsync(o => o.Replace(request))
        .CheckAsync(_ => db.SaveChangesResultUnitAsync(ct))
        .ToHttpResponseAsync(OrderResponse.From, opts => opts.HonorPrefer()));

Direct DbContext in the Minimal API lambda vs command handler via Mediator. Both shapes are canonical Trellis. This recipe shows the direct-DbContext shape because the precondition belongs at the read-modify-write boundary. With Mediator, parse If-Match at the HTTP boundary, carry EntityTagValue[]? IfMatchETags on the command, and move the same chain into the handler. Keep OptionalETagAsync(command.IfMatchETags) or RequireETagAsync(command.IfMatchETags) after the not-found projection and before mutation. TransactionalCommandBehavior owns the commit, so drop the explicit save step. Preserve permission/resource authorization before the precondition check, and retain persistence-level concurrency protection for writes racing after the read.

Rationale. Header requirement is an endpoint policy; honoring a supplied precondition is not optional. State machines validate domain transitions, ETag checks enforce the caller's observed version, and persistence concurrency protection handles writes racing after the read. These mechanisms are complementary. A failed supplied precondition must leave state and representation metadata unchanged; no header still permits a guarded transition without a 428.

Eager-overload exception. FirstOrDefaultResultAsync accepts an Error, not an error factory, so these query examples supply it eagerly. Do not invent a factory overload. If lazy not-found construction is required, use the documented FirstOrDefaultMaybeAsync plus ToResultAsync(() => new Error.NotFound(...)) composition.


Cross-cutting tips

  • Run analyzers in CI. Trellis.Analyzers ships with the Trellis ASP template and runs on every build of a project that references it. Treat warnings as errors for TRLS00x once your codebase is clean.
  • Two independent await calls in a handler? Result.ParallelAsync + WhenAllAsync is the framework idiom — but only when the loads hit different resources. Two repository reads against the same scoped DbContext (the typical Trellis setup with AddTrellisUnitOfWork<TContext>()) race EF Core and throw InvalidOperationException; keep those sequential. The recipe spells out the safe shapes (HTTP + DB, two distinct upstream services, factory-created DbContexts via IDbContextFactory<T>) and the anti-pattern. See Recipe 21. The rule for "independent": the second factory's body does not reference any value produced by the first and the two factories hit distinct underlying resources.
  • Do not mix sync chain methods with async lambdas. result.Map(async v => …) triggers TRLS009; use MapAsync. The fix provider can apply this rewrite automatically.
  • Construct errors via the closed ADT. new Error.NotFound(ResourceRef.For<Order>(id)) — never new Error("not_found", "..."), which won't compile against the abstract base record.
  • Construct custom guard/conversion errors lazily. Put new Error..., ForField(...), or ForRule(...) inside the supported factory callback; use static when no state is captured. Use the already-lazy field/detail EnsureNotNull shorthand for ordinary required fields. Eager violation construction records validation metrics even if the guard succeeds or an earlier failure skips it. Preserve each API's documented factory signature and keep eager overloads for existing/reused errors or APIs without a factory.
  • Use Result.Combine (or EnsureAll) for accumulating validation. Manual IsSuccess checks across multiple results trigger TRLS008.
  • Aggregate per-item Results with Traverse / Sequence (fail-fast) or TraverseAll / SequenceAll (accumulating). When you have a collection and a per-item function returning Result<T>, use items.Traverse(item => Compute(item)) to lift it into Result<IReadOnlyList<T>>. When you already have an IEnumerable<Result<T>> (e.g., from a Select), call .Sequence() instead. Both short-circuit on the first failure. When you need to surface every failure (form-style validation), use TraverseAll / SequenceAll: they run through every item and fold failures via Error.Combine — two Error.InvalidInput errors merge their fields/rules, heterogeneous errors flatten into Error.Aggregate. See Recipe 20 for when to choose which.
  • Use Error.InvalidInput.ForField / .ForRule for single-violation 422s. Prefer Error.InvalidInput.ForField(ValidationCodes.StringEmail, "email", detail: "must contain @") over manually wrapping a single FieldViolation. The pointer overload is ForField(code, pointer, args: args, detail: detail) and preserves nested/array paths and input location. Global rules use ForRule(code, detail: detail); cross-field rules can also supply fields: [firstPointer, secondPointer] and args. Optional metadata can be omitted, but required codes must be nonblank. For multiple violations, keep the Error.InvalidInput constructor with an EquatableArray<FieldViolation>, or combine per-field TryCreate results as in Recipe 1.
  • InputPointer.Root for whole-body violations. Use InputPointer.ForProperty(name) for field-level violations and InputPointer.Root when the rule is object-level.
  • Only the Trellis namespace is auto-imported. The template's implicit usings include Trellis (which exposes Result, Result<T>, Error, Maybe<T>, RequiredString<T>, RequiredGuid<T>, RequiredInt<T>, RequiredDecimal<T>, RequiredDateTime<T>, etc.). Every other Trellis namespace requires an explicit using per file — e.g. using Trellis.Primitives; for Money / EmailAddress / PhoneNumber / MonetaryAmount / CurrencyCode / CountryCode / etc., using Stateless; for the upstream StateMachine<TState, TTrigger> type plus using Trellis.StateMachine; for the Trellis FireResult extension and LazyStateMachine<TState, TTrigger>, using Trellis.Authorization; for permission types. This is intentional: implicit usings cannot be added at the template level without breaking services that don't reference the package.
  • Accessing Maybe<T>.Value inside Expression<Func<...>> lambdas (EF Core Where/Select, FluentValidation RuleFor, Specifications): TRLS003 still applies inside expression trees, but it now recognises the multi-clause guard — e => e.Status == X && e.Y.HasValue && e.Y.Value == y is analyzer-clean, and MaybeQueryInterceptor translates each clause faithfully to SQL when AddTrellisInterceptors() is wired. The single-call equivalent e.Y.HasValueWhere(v => v == y) is also analyzer-clean and rewritten by the interceptor — use whichever reads better. Hoist into a guarded variable for projections that the interceptor doesn't cover. Do not suppress with #pragma warning disable TRLS003. See Recipe 8 for the full Specification walkthrough.
  • EquatableArray<T> does not implement IEnumerable<T> — project through .Items for LINQ / FluentAssertions / string.Join. The sequence-equality wrapper exposes a duck-typed GetEnumerator() for allocation-free foreach but deliberately does not implement IEnumerable<T>. LINQ extension methods (Select, Where, Any, ToList) and FluentAssertions extensions (Should().ContainSingle(), Should().HaveCount(...), Should().BeEquivalentTo(...)) bind on IEnumerable<T> and will not compile against the raw wrapper. Call .Items first — it returns the wrapped ImmutableArray<T>, which IS IEnumerable<T>. This shows up most often in test assertions on Error.InvalidInput.Fields / .Rules and in error-rendering helpers. See EquatableArray<T> in the Core reference for the worked example.

Recipe 24 — Indirect (multi-hop) resource authorization

Problem. A command identifies a "leaf" resource by id (e.g. MatchId) but ownership/authorization is determined by a different resource one or more navigation hops away. Examples:

  • Cricket fan-out: actor must own home OR away team to upload a scorecard. Match → {HomeTeam, AwayTeam}, OR-ownership.
  • Owner chain: Match → Team → Tournament — authorize against tournament owner.
  • Org hierarchy: Document → Folder where Folder carries the org-unit owning the document.

The naive workaround is a per-handler ownership guard (e.g. cricket's old MatchOwnershipGuard). It runs inside the handler body after the framework authorization pipeline has already passed, must be remembered in every handler, and is easy to forget — making it a recurring source of authorization gaps. IAuthorizeResourceVia<TOwner> moves the check into the framework pipeline as a declarative interface.

Decision table.

Scenario Recipe
Owner-by-id check on the command's resource (the 90% case) IAuthorizeResource<T> + IIdentifyResource<T, TId> → framework reuses SharedResourceLoaderById<T, TId> (Recipe 7)
Authorize against the resource the command identifies (custom loader) IAuthorizeResource<T> + custom IResourceLoader<TMessage, T> (Recipe 7)
Authorize against a single related resource one FK hop away IAuthorizeResourceVia<TOwner> + IIdentifyRelatedResource<TOwner, TOwnerId> on the leaf
Authorize against a set of related resources (cricket fan-out, OR-ownership) IAuthorizeResourceVia<TOwner> + IIdentifyRelatedResources<TOwner, TOwnerId> on the leaf
Authorize against a resource at the end of a chain (Match → Team → Tournament) IAuthorizeResourceVia<TFinalOwner> + IIdentifyRelatedResource<,> declared on each intermediate entity
Authorize against a recursive hierarchy (org-unit ancestors, comment-thread parents) Materialize the ancestor chain on the loaded aggregate (closure-table projection) and use zero-hop IAuthorizeResource<T>; OR a custom IResourceLoader<TMessage, TProjection>
Authorize against a composite shape (joins, projections, conditional paths, plural-in-middle) IResourceLoader<TMessage, TProjection> returning a custom projection; put IAuthorizeResource<TProjection> on the command

Cricket fan-out — end to end

using Mediator;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;

public sealed partial class MatchId : RequiredGuid<MatchId>;
public sealed partial class TeamId  : RequiredGuid<TeamId>;

public sealed class Match : Aggregate<MatchId>, IIdentifyRelatedResources<Team, TeamId>
{
    public TeamId HomeTeamId { get; }
    public TeamId AwayTeamId { get; }
    public IReadOnlyList<TeamId> GetRelatedResourceIds() => [HomeTeamId, AwayTeamId];
}

public sealed class Team : Aggregate<TeamId>
{
    public ActorId CreatedByActorId { get; }
}

public sealed record UploadScorecardCommand(MatchId MatchId, /* fields */)
    : ICommand<Result<Unit>>,
      IAuthorizeResourceVia<Team>,
      IIdentifyResource<Match, MatchId>
{
    public MatchId GetResourceId() => MatchId;

    public IResult Authorize(Actor actor, IReadOnlyList<Team> owners) =>
        Result.Ensure(
            owners.Any(t => t.CreatedByActorId == actor.Id),
            static () => new Error.Forbidden("match.upload-scorecard")
                { Detail = "Actor does not own either match team." });
}

// Composition root — assembly scan registers everything.
services.AddTrellisBehaviors();
services.AddResourceAuthorization(typeof(UploadScorecardCommand).Assembly);

The pipeline:

  1. Resolves the actor.
  2. Loads the Match via the existing SharedResourceLoaderById<Match, MatchId> (bridged automatically because the command implements IIdentifyResource<Match, MatchId>).
  3. Calls match.GetRelatedResourceIds() → [home, away], deduplicates, loads each via SharedResourceLoaderById<Team, TeamId>.
  4. Calls command.Authorize(actor, [homeTeam, awayTeam]).
  5. On any leaf-load failure, the loader's error bubbles. On any intermediate or owner-load failure, the pipeline collapses to Error.Forbidden (no existence leak). Empty ID list at any hop short-circuits to Forbidden without invoking Authorize.

Handler parameters. ActorResourceViaCommandHandler<UploadScorecardCommand,Match,Team,Result<Trellis.Unit>> passes the checked actor and loaded match to protected Handle, not either team or the owner collection. Keep owners.Any(...) unchanged: owning the away team alone still allows an upload. For Document -> Folder, use ActorResourceViaQueryHandler<ReadDocumentQuery,Document,Folder,Result<Document>>; the business body receives the document while the folder remains an authorization input. All static/resource stages share one provider resolution per dispatch. These bases do not require static IAuthorize, and the registered leaf must agree with TLeaf or the normal handler entry throws diagnostically. Existing leaf accessors remain available when the business body does not need an actor.

Chain — Match → Team → Tournament

public sealed class Match : Aggregate<MatchId>, IIdentifyRelatedResource<Team, TeamId>
{
    public TeamId TeamId { get; }
    public TeamId GetRelatedResourceId() => TeamId;
}

public sealed class Team : Aggregate<TeamId>, IIdentifyRelatedResource<Tournament, TournamentId>
{
    public TournamentId TournamentId { get; }
    public TournamentId GetRelatedResourceId() => TournamentId;
}

public sealed record CancelMatchCommand(MatchId MatchId)
    : ICommand<Result<Unit>>,
      IAuthorizeResourceVia<Tournament>,
      IIdentifyResource<Match, MatchId>
{
    public MatchId GetResourceId() => MatchId;

    public IResult Authorize(Actor actor, IReadOnlyList<Tournament> owners) =>
        Result.Ensure(
            owners[0].OwnerActorId == actor.Id,
            static () => new Error.Forbidden("match.cancel"));
}

The resolver discovers the path Match → Team → Tournament at registration time using the entity-side IIdentifyRelatedResource<,> declarations. Singular chains always pass IReadOnlyList<TOwner> of size 1 — index [0] is safe.

AOT / explicit registration

AddResourceAuthorization(Assembly[]) uses reflection. For Native AOT or trimming-strict deployments, register each via-command explicitly. The single-hop overload covers a leaf with one foreign key to its owner (singular extractor). It does not support fan-out — for that, drop to the hand-built ResolvedAuthorizationPath overload.

// Single-hop scenario: Match has one Team FK; the actor must own that team.
public sealed class Match : Aggregate<MatchId>, IIdentifyRelatedResource<Team, TeamId>
{
    public TeamId TeamId { get; }
    public TeamId GetRelatedResourceId() => TeamId;
}

public sealed record DeleteMatchCommand(MatchId MatchId)
    : ICommand<Result<Unit>>,
      IAuthorizeResourceVia<Team>,
      IIdentifyResource<Match, MatchId>
{
    public MatchId GetResourceId() => MatchId;

    public IResult Authorize(Actor actor, IReadOnlyList<Team> owners) =>
        Result.Ensure(
            owners[0].CreatedByActorId == actor.Id,
            static () => new Error.Forbidden("match.delete"));
}

services.AddRelatedResourceAuthorization<
    DeleteMatchCommand, Match, MatchId, Team, TeamId, Result<Unit>>(
    extractOwnerId: match => match.TeamId);  // single-hop selector

For chains (Match → Team → Tournament) or fan-out (cricket Match → {HomeTeam, AwayTeam}), the single-hop overload cannot express the path. Build a ResolvedAuthorizationPath manually and use the (this IServiceCollection, ResolvedAuthorizationPath) overload — the hand-built path can carry multiple hops and a plural terminal hop.

What the framework rejects at startup

  • Dual-mode commands — implementing both IAuthorizeResource<T> and IAuthorizeResourceVia<TOwner> on one command. Security primitives are never silently composed.
  • Via-command without IIdentifyResource<TLeaf, TLeafId> — the scanner cannot infer the leaf, so a silent skip would leave the via-marker unprotected. Throws naming the offending command.
  • Multiple distinct simple paths from leaf to owner (ambiguous). Throws listing every discovered path; disambiguate by removing an IIdentifyRelatedResource[s] declaration or by switching to the explicit IResourceLoader<TMessage, TProjection> escape hatch.
  • Plural hop in a non-terminal position — fan-out cartesian expansion is intentionally out of scope for v1.
  • No path from leaf to owner — declare an IIdentifyRelatedResource[s]<TOwner, ...> somewhere along the chain or use the escape hatch.
  • Missing SharedResourceLoaderById<TTo, TToId> registration for any hop — throws InvalidOperationException at request time (deployment bug, not authorization denial).

TOCTOU note

Resource authorization loads happen before the handler, including loads performed by a custom IResourceLoader<TMessage, TProjection>. TransactionalCommandBehavior does not open a database transaction around either the loader or the handler: its scope coordinates the eventual SaveChanges commit. Multi-hop widens the window in which ownership can change after authorization. Merely switching loader implementations does not close it. Enforce ownership in the repository's conditional mutation predicate (and reject a zero-row update), or explicitly establish an appropriate transaction/isolation strategy that covers both authorization reads and writes.


Problem. A handler iterates a collection that maps one-to-many onto related aggregates and applies an operation that can fail per element (e.g., SubmitOrderCommand reserves stock on the Product referenced by every LineItem). The naïve single-loop shape — call the mutating operation, check the Result, return on failure — is the lab convergent miss across two cycles (findings-2026-04-30.md §3.4): two out of three models shipped this shape, and the bug only surfaces on tests that arrange a later element to fail.

// ❌ Single-loop mutate-as-you-validate. Line 1 reserves; line 3 fails InsufficientStock;
// line 1's product is left in the reserved state with no compensating rollback.
foreach (var li in order.LineItems)
{
    var r = byId[li.ProductId].Reserve(li.Quantity);
    if (r.Error is { } err)
        return Result.Fail<Order>(err);
}
return order.Submit();

TransactionalCommandBehavior rolls the DB commit back on failure, but the in-memory aggregate state stays mutated for the rest of the request — visible to any subsequent code that reads the same aggregate within the request scope, and outright observable in unit tests that arrange the same Product instance and assert on its post-handler state.

The invariant the recipe teaches.

Every fallible domain check across every participating aggregate must succeed BEFORE the first state-changing call. A "fallible domain check" is any Result<T>-returning operation that can encode a domain rejection. After validation succeeds, every Pass 2 call has a matching Can* predicate from Pass 1, so the mutation is provably non-failing — no compensating-rollback machinery required.

Two design moves make this work.

  • Pure Can* predicate alongside the mutator. The aggregate exposes a side-effect-free CanReserve(qty) → Result<Trellis.Unit> that returns the same domain error the mutator would, and a Reserve(qty) that internally delegates to CanReserve before mutating. Same shape as Recipe 9's state-machine CanFire+Fire/FireResult pattern, lifted from "single transition on one aggregate" to "collection of operations across many aggregates."
  • Mutation-plan grouping for duplicate keys. If the input collection can name the same related aggregate twice (e.g., two line items with the same ProductId), aggregate the duplicates into a single (Product, totalQuantity) plan entry before validating. Validating each line independently against unchanged stock is not equivalent to mutating them sequentially: two CanReserve(3) calls against Stock=5 both pass, but the second Reserve(3) then fails. Grouping eliminates the aliasing.

⚠️ Per-line invariants must be enforced before grouping. The plan step (g.Sum(li => li.Quantity)) preserves the aggregated quantity but destroys per-line identity. If LineItem.Quantity could be -4, then a (5, -4) pair would group to a valid 1, slipping a negative quantity past CanReserve. In Trellis services per-line invariants are typically enforced at line-item construction (value-object Quantity : RequiredInt<Quantity> validating > 0, or a private constructor + TryCreate factory). If your per-line invariants don't aggregate cleanly into a sum — e.g., "no single line may exceed 100 units" rather than "the total across lines must not exceed available stock" — add an explicit per-line validation pass before the grouping step and Sequence its results into Pass 1. Production code should also cap each line's Quantity (and/or the order's line count) so a sequence of valid positives cannot overflow int inside g.Sum(...); Sum throws OverflowException rather than producing a Result failure, which would bypass the pipeline.

Worked example.

using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Mediator;
using Trellis;

public sealed class Product : Aggregate<ProductId>
{
    public int Stock { get; private set; }

    // Pure predicate — runs in Pass 1, mutates nothing.
    public Result<Trellis.Unit> CanReserve(int quantity) =>
        Result.Ensure(
            quantity > 0 && quantity <= Stock,
            () => Error.InvalidInput.ForRule(
                code: "stock.insufficient",
                detail: $"Cannot reserve {quantity} from stock of {Stock}."));

    // Mutator — re-checks via CanReserve as defense in depth so it is safe to call
    // outside the two-pass orchestration. When called after a matching Pass 1 CanReserve
    // succeeded in a single-threaded handler with no intervening mutations, Reserve is
    // provably non-failing.
    public Result<Trellis.Unit> Reserve(int quantity) =>
        CanReserve(quantity).Tap(() => Stock -= quantity);
}

public sealed class Order : Aggregate<OrderId>
{
    public IReadOnlyList<LineItem> LineItems { get; }
    public bool IsSubmitted { get; private set; }

    public Result<Trellis.Unit> CanSubmit() =>
        Result.Ensure(
            LineItems.Count > 0,
            static () => Error.InvalidInput.ForRule(
                code: "order.empty",
                detail: "Order must have at least one line item to submit."));

    public Result<Order> Submit() =>
        CanSubmit().Tap(() => IsSubmitted = true).Map(_ => this);
}

public sealed class SubmitOrderHandler(
    IOrderRepository orders,
    IProductRepository products) : ICommandHandler<SubmitOrderCommand, Result<Order>>
{
    public async ValueTask<Result<Order>> Handle(SubmitOrderCommand command, CancellationToken cancellationToken)
    {
        var orderResult = await orders.FindByIdAsync(command.OrderId, cancellationToken);
        if (!orderResult.TryGetValue(out var order))
            return orderResult;

        // Recipe 22 preflight (presence check). Every line-item ProductId must resolve
        // BEFORE the plan step, otherwise byId[g.Key] would throw KeyNotFoundException
        // and bypass the Result pipeline. SequenceAll reports every missing id.
        var productIds = order.LineItems.Select(li => li.ProductId).Distinct().ToArray();
        var loaded = await products.GetByIdsAsync(productIds, cancellationToken);
        var byId = loaded.ToDictionary(p => p.Id);
        var presence = productIds
            .Select(id => Result.Ensure(byId.ContainsKey(id),
                () => new Error.NotFound(ResourceRef.For<Product>(id))))
            .SequenceAll();
        if (presence.IsFailure)
            return Result.Fail<Order>(presence.Error);

        // Aggregate duplicate line items into one reservation per product so the
        // CanReserve checks operate on the same quantity the matching Reserve will deduct.
        var plan = order.LineItems
            .GroupBy(li => li.ProductId)
            .Select(g => (Product: byId[g.Key], Quantity: g.Sum(li => li.Quantity)))
            .ToArray();

        // PASS 1 — validate every fallible domain check across every aggregate. No mutations.
        // SequenceAll accumulates every violation so the response enumerates them rather than
        // reporting only the first; use .Sequence() for fail-fast semantics (see Recipe 20
        // for the decision criteria).
        var validation = plan
            .Select(p => p.Product.CanReserve(p.Quantity))
            .Append(order.CanSubmit())
            .SequenceAll();

        if (validation.Error is { } err)
            return Result.Fail<Order>(err);

        // PASS 2 — apply every mutation. Each call is provably non-failing because its
        // matched Can* predicate already passed in Pass 1 AND nothing has mutated the
        // in-memory aggregate state between passes (single-threaded handler). Discard() is
        // the idiomatic acknowledged-discard that suppresses TRLS001.
        foreach (var (product, quantity) in plan)
            product.Reserve(quantity).Discard();

        return order.Submit();
    }
}

The complete compile-checked snippet (with duplicate-product-aware test stubs and the anti-pattern WrongHandler under #if FALSE) lives at Examples/CookbookSnippets/Recipe25_TwoPassValidateThenMutate.cs in the framework repository.

The Pass-2-cannot-fail invariant — what makes it hold.

The recipe's correctness rests on two preconditions:

  1. Every Pass 2 call has a matching Can* in Pass 1. This is a static property of the handler shape — every mutator invoked after the validation if (validation.Error is { } err) return ... boundary must have appeared, by name and arguments, in the Pass 1 expression.
  2. Nothing mutates the participating aggregates between passes. Trivially satisfied for a single-threaded async handler operating on aggregates loaded into the request scope. Not satisfied if Pass 1 and Pass 2 are split across threads, if another handler runs concurrently against the same instances, or if a Pass-1 callback (e.g., a logger) is allowed to mutate state. Do not parallelize the mutation pass.

When both preconditions hold, Discard() on each Pass 2 mutator call is correct: TRLS001 is suppressed and the result really cannot fail. If you cannot prove (1) or (2) in your context, you are no longer using the two-pass pattern — you are doing transactional compensation, which needs explicit rollback machinery and is out of scope for this recipe.

Choosing fail-fast vs accumulating for the validation pass.

The worked example uses SequenceAll() so the response enumerates every violation (typical for form-style and stock-style invariants where the user benefits from seeing all problems at once). Switch to Sequence() for fail-fast semantics when later checks are expensive and a single failure is sufficient. See Recipe 20 for the full decision criteria. Both forms short-circuit on the same boundary: nothing in Pass 2 runs until validation returns Ok.

How this fits with sibling recipes.

  • Recipe 9 — single-aggregate single-transition variant of the same Can*+* shape. Recipe 25 generalizes it across many aggregates and many operations.
  • Recipe 20 — the Sequence/SequenceAll choice for the validation pass.
  • Recipe 22 — presence preflight for missing related aggregates (a different failure mode from per-element invariants). The two preflights compose: Recipe 22 runs first (every required aggregate exists), then Recipe 25 (every present aggregate's invariants admit the operation).

Anti-pattern → fix.

// ❌ Single-loop mutate-as-you-validate. The bug is invisible to happy-path tests because
// every test sets up a fully-satisfiable order. A test that arranges line 3 to fail —
// e.g., line 3's product has Stock=0 — reveals that line 1's product is left reserved.
foreach (var li in order.LineItems)
{
    var r = byId[li.ProductId].Reserve(li.Quantity);
    if (r.Error is { } err)
        return Result.Fail<Order>(err);
}
return order.Submit();

// ✅ Two-pass validate-then-mutate — same shape as the worked example above. Pass 1
// proves every Can* succeeds across every participating aggregate; Pass 2 then calls
// the matching mutators with provably-non-failing semantics.
var plan = order.LineItems
    .GroupBy(li => li.ProductId)
    .Select(g => (Product: byId[g.Key], Quantity: g.Sum(li => li.Quantity)))
    .ToArray();

var validation = plan
    .Select(p => p.Product.CanReserve(p.Quantity))
    .Append(order.CanSubmit())
    .SequenceAll();
if (validation.Error is { } err)
    return Result.Fail<Order>(err);

foreach (var (product, quantity) in plan)
    product.Reserve(quantity).Discard();

return order.Submit();

Partial-Failure Atomicity test (the test the bug-shipping models never wrote).

Every two-pass handler needs a test where a later element of the collection is unsatisfiable while earlier elements are. Happy-path tests cannot reveal the partial-mutation bug.

[Fact]
public async Task Submit_with_one_unsatisfiable_line_does_not_reserve_any_stock()
{
    // Two line items; line 2 cannot be reserved (stock=0).
    var (order, productA, productB) = SeedOrderWithTwoLineItemsAsync(
        stockA: 10, qtyA: 3,   // line 1 satisfiable
        stockB: 0,  qtyB: 1);  // line 2 unsatisfiable
    var aStockBefore = productA.Stock;
    var bStockBefore = productB.Stock;

    var r = await _sender.Send(new SubmitOrderCommand(order.Id), cancellationToken);

    r.Should().BeFailureOfType<Error.InvalidInput>();
    productA.Stock.Should().Be(aStockBefore);   // NOT partially reserved
    productB.Stock.Should().Be(bStockBefore);
    order.IsSubmitted.Should().BeFalse();        // primary aggregate not transitioned
}

Together with the duplicate-product case (two lines for the same product whose summed quantity exceeds available stock), these are the two failure-mode tests that catch every form of this bug.


Recipe 26 — Test a BackgroundService with WorkerHarness<TWorker>

Problem. A periodic worker reads pending work, dispatches a side effect, and emits a domain event per item. Integration tests must drive the worker forward deterministically (no Task.Delay), capture the domain events the worker raised, and stop the host cleanly — without re-implementing the IHost + FakeTimeProvider + IActorProvider + IDomainEventHandler<T> plumbing in every test fixture.

// The worker under test. BackgroundService is registered as a singleton hosted service,
// so it resolves scoped services through an IServiceScopeFactory rather than capturing
// them in the constructor. IWorkerTickSignal is the harness-only observation primitive;
// production hosts do not register an implementation, so the worker injects it as an
// optional dependency and no-ops when absent.
public sealed class HealthProbeWorker(
    IServiceScopeFactory scopeFactory,
    TimeProvider time,
    IWorkerTickSignal? tick = null) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Register the first Task.Delay BEFORE signaling readiness — the signal must
        // prove the FakeTimeProvider callback exists. Task.Delay(TimeSpan, TimeProvider,
        // CancellationToken) eagerly registers the timer with the TimeProvider when
        // called, so by the time SignalAsync("ready") completes the callback is already
        // observable to Time.Advance. Signaling FIRST (before Task.Delay) would leave a
        // gap during which the test can resume from WaitForTickAsync("ready"), call
        // Time.Advance, and have the worker subsequently register a deadline of
        // (advanced-now + period) — losing the Advance. The signal is a no-op in
        // production hosts that do not register IWorkerTickSignal.
        var nextDelay = Task.Delay(TimeSpan.FromMinutes(5), time, stoppingToken);
        if (tick is not null)
            await tick.SignalAsync("ready", stoppingToken).ConfigureAwait(false);

        while (!stoppingToken.IsCancellationRequested)
        {
            try { await nextDelay.ConfigureAwait(false); }
            catch (OperationCanceledException) { return; }

            await using var scope = scopeFactory.CreateAsyncScope();
            var mediator  = scope.ServiceProvider.GetRequiredService<IMediator>();
            var publisher = scope.ServiceProvider.GetRequiredService<IDomainEventPublisher>();
            var repo      = scope.ServiceProvider.GetRequiredService<IHealthProbeRepository>();

            foreach (var probe in await repo.GetDuePendingAsync(stoppingToken).ConfigureAwait(false))
            {
                var outcome = await mediator.Send(new RunProbeCommand(probe.Id), stoppingToken).ConfigureAwait(false);
                if (outcome.IsSuccess)
                    await publisher.PublishAsync(
                        new ProbeCompletedDomainEvent(probe.Id, time.GetUtcNow()),
                        stoppingToken).ConfigureAwait(false);
            }

            // Register the next iteration's delay BEFORE signaling "probe", for the same
            // reason as above: a test that snapshots a probe cursor and advances time
            // after the wait expects the next callback to already be registered.
            // Signaling AFTER PublishAsync + the next Task.Delay registration makes
            // WaitForTickAsync a true completion barrier — every captured event for this
            // iteration is recorded AND the next callback exists for the next Advance.
            nextDelay = Task.Delay(TimeSpan.FromMinutes(5), time, stoppingToken);
            if (tick is not null)
                await tick.SignalAsync("probe", stoppingToken).ConfigureAwait(false);
        }
    }
}

// The integration test.
public class HealthProbeWorkerTests
{
    [Fact]
    public async Task Worker_dispatches_due_probes_and_publishes_a_completion_event_per_run()
    {
        await using var harness = await WorkerHarness<HealthProbeWorker>.CreateAsync(opts =>
        {
            opts.ConfigureServices(s =>
            {
                // WorkerHarness deliberately does not call AddMediator(...) or
                // AddDomainEventDispatch(); a worker test re-uses the production
                // composition root so the test exercises the same wiring the production
                // host uses. Forgetting either registration would surface as
                // GetRequiredService throwing at scope resolution.
                s.AddMediator(options =>
                {
                    options.Assemblies = [typeof(RunProbeCommand).Assembly];
                    options.ServiceLifetime = ServiceLifetime.Scoped;
                });
                s.AddDomainEventDispatch();
                s.AddSingleton<IHealthProbeRepository, FakeHealthProbeRepository>();
            });
            opts.SeedAsync(async (sp, ct) =>
            {
                var repo = sp.GetRequiredService<IHealthProbeRepository>();
                await repo.AddAsync(new HealthProbe(ProbeId.NewUniqueV7(), "/api/orders"), ct);
            });
        });

        await harness.StartAsync(TestContext.Current.CancellationToken);

        // StartAsync returns as soon as ExecuteAsync is scheduled, NOT after the
        // worker has registered its first Task.Delay callback with FakeTimeProvider.
        // Block on the worker's "ready" signal — which the worker emits AFTER its
        // first Task.Delay(...) call (so the callback is provably registered before
        // the signal fires) — so the subsequent Time.Advance always lands on an
        // existing callback. Without this barrier the Advance races the worker.
        await harness.WaitForTickAsync("ready", TimeSpan.FromSeconds(5));

        // Snapshot the most recent "probe" tick BEFORE advancing time. The cursor is the
        // global signal index (LastTickIndexOf returns -1 when nothing has signaled yet);
        // WaitForTickAsync(after: cursor, ...) will block until a tick with a STRICTLY
        // greater global index fires, so the wait can never observe a tick already in
        // the recorded history. Do not use TickCountOf as a cursor — it is a per-name
        // count, not the global signal index, and will race when other tick names
        // interleave.
        var cursor = harness.LastTickIndexOf("probe");

        // Advance the FakeTimeProvider past the worker's 5-minute Task.Delay. Advance is
        // deterministic; the worker's Task.Delay(interval, time, ct) resumes and runs the
        // iteration. WaitForTickAsync's timeout measures real time and is NOT consumed
        // by the call to Advance.
        harness.Time.Advance(TimeSpan.FromMinutes(5));
        await harness.WaitForTickAsync("probe", after: cursor, TimeSpan.FromSeconds(2));

        // Once WaitForTickAsync returns, PublishAsync for this iteration has already
        // returned (the worker signals AFTER the publish loop), so the captured-event
        // list is fully populated and the synchronous read is race-free.
        harness.Events<ProbeCompletedDomainEvent>().Should().HaveCount(1);
    }
}

What it shows. WorkerHarness<TWorker>.CreateAsync(...) builds an IHost with TWorker registered as the sole hosted service, wires TimeProvider to FakeTimeProvider, registers a TestActorProvider returning WorkerHarnessOptions.SystemActor, and installs an open-generic IDomainEventHandler<> that captures every published event for harness.Events<TEvent>() and harness.WaitForEventAsync<TEvent>(). The harness does not register AddMediator(...) or AddDomainEventDispatch() — those are part of the production composition root and belong in the test's ConfigureServices callback so the worker exercises the same wiring it uses in production. harness.StartAsync(...) returns as soon as ExecuteAsync is scheduled on the thread pool, NOT after the worker has registered its first Task.Delay with FakeTimeProvider; the recipe makes the readiness barrier deterministic by REGISTERING the first Task.Delay(...) before signaling "ready" (because Task.Delay(TimeSpan, TimeProvider, CancellationToken) eagerly calls timeProvider.CreateTimer(...), the callback exists by the time SignalAsync("ready") completes), then await harness.WaitForTickAsync("ready", ...) in the test releases when the test can safely call Time.Advance. Signaling first and registering the delay second would leave a gap during which the test could Advance before any callback was registered — losing the Advance. The lazier alternative when you cannot change the worker is await harness.SettleAsync(), at the cost of a real-time yield. harness.Time.Advance(...) is the deterministic equivalent of Task.Delay — the worker's Task.Delay(interval, time, ct) resumes synchronously and runs the next iteration. Tests should pair Advance with WaitForTickAsync(name, after: cursor, ...) so the wait observes the next tick rather than racing with one already in the history; LastTickIndexOf(name) is the right baseline-cursor source. The harness's DisposeAsync stops the host with a real-time cap; if StartAsync itself throws, the harness drives a best-effort StopAsync so any earlier-registered IHostedService that succeeded still observes its stop hook.

Trellis.Testing.Worker references Trellis.Testing transitively, so handler-test assertions like Should().BeSuccess() and FakeRepository<,> are available from the same test project without an extra package.


Recipe 27 — Idempotent inserts on a unique constraint with TryInsertUniqueAsync

Problem. A worker (or any caller) processes events that may be redelivered. It must record "I handled event X for destination Y" exactly once. A (EventId, DestinationId) unique index in the database is the source of truth — the second delivery should silently no-op, not crash, not double-process. Doing this with Any(...) + Add + SaveChangesAsync is a TOCTOU race: two concurrent deliveries both see "not present", both Add, one wins and the loser throws DbUpdateException. Catching DbUpdateException and string-matching on the inner exception's message is provider-specific (SQL Server says one thing, PostgreSQL another, SQLite a third) and easy to get subtly wrong.

Solution. DbContext.TryInsertUniqueAsync(entity, ct) (from Trellis.EntityFrameworkCore.DbContextIdempotencyExtensions) adds the entity, calls SaveChangesAsync, and converts a provider-level unique-constraint violation into Result.Fail(new Error.Conflict(Resource: null, Code: FaultCodes.DuplicateKey)) with a generic safe Detail. Constraint identity (ConstraintName, ConstraintTableName) is extracted on a best-effort basis and attached to the Error.Conflict payload for structured logging. All other failures — concurrency, foreign-key, cancellation, connection errors — propagate to the caller so retry policies and global handlers see them. The helper requires a clean DbContext (no pending changes) so a duplicate-key violation can be unambiguously attributed to the entity being inserted.

// Domain: a worker records each (EventId, DestinationId) it has dispatched.
public sealed class DispatchedDelivery
{
    public required Guid EventId { get; init; }
    public required Guid DestinationId { get; init; }
    public required DateTimeOffset DispatchedAt { get; init; }
}

// EF Core: composite primary key on (EventId, DestinationId) gives the unique
// constraint TryInsertUniqueAsync relies on; no separate HasIndex().IsUnique()
// is needed. If your model already has a surrogate PK, add the unique index
// explicitly instead: e.HasIndex(d => new { d.EventId, d.DestinationId }).IsUnique().
public sealed class DispatchLogDbContext(DbContextOptions<DispatchLogDbContext> options) : DbContext(options)
{
    public DbSet<DispatchedDelivery> Deliveries => Set<DispatchedDelivery>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DispatchedDelivery>()
            .HasKey(d => new { d.EventId, d.DestinationId });
    }
}

public sealed class DispatchLogger(DispatchLogDbContext db, TimeProvider time, ILogger<DispatchLogger> log)
{
    public async Task<Result<DeliveryOutcome>> RecordDeliveryAsync(
        Guid eventId, Guid destinationId, CancellationToken ct)
    {
        var entry = new DispatchedDelivery
        {
            EventId = eventId,
            DestinationId = destinationId,
            DispatchedAt = time.GetUtcNow(),
        };

        var result = await db.TryInsertUniqueAsync(entry, ct);

        if (result.IsSuccess)
            return Result.Ok(DeliveryOutcome.Recorded);

        if (result.Error is Error.Conflict conflict && conflict.Code == FaultCodes.DuplicateKey)
        {
            // The second delivery — exactly what idempotency promises. No-op, do not fail.
            log.LogInformation(
                "Duplicate delivery suppressed for event {EventId} / destination {DestinationId} (table {Table}, constraint {Constraint}).",
                eventId, destinationId, conflict.ConstraintTableName, conflict.ConstraintName);
            return Result.Ok(DeliveryOutcome.AlreadyRecorded);
        }

        return Result.Fail<DeliveryOutcome>(result.Error);
    }
}

public enum DeliveryOutcome { Recorded, AlreadyRecorded }

What it shows. TryInsertUniqueAsync is the framework idiom for "insert unless a unique constraint says it already exists". The success path returns Result.Ok(entity) and the entity has its EF-populated generated values (PK, row version, sequence-assigned columns) in place on the same instance the caller passed in. The duplicate path returns a failed Result<TEntity> carrying an Error.Conflict whose Code is "duplicate.key" and whose ConstraintName / ConstraintTableName telemetry fields are populated from the underlying provider exception, and the helper detaches the attempted entity from the change tracker so a retry with a freshly-constructed entity does not re-flush the original on the next SaveChangesAsync. ConstraintName and ConstraintTableName are best-effort and marked [JsonIgnore] on Error.Conflict — they are telemetry fields for structured logs, never serialized to API responses; the safe-for-clients message lives in Detail. Foreign-key violations, DbUpdateConcurrencyException, connection-level exceptions, and OperationCanceledException all propagate normally so retry policies still see them. The clean-context precondition (throws InvalidOperationException if ChangeTracker.HasChanges() is true on entry) prevents the failure from being mis-attributed to the inserted entity when unrelated pending changes exist; flush them first or use a fresh context. Pair the helper with SaveChangesWithRetryAsync (from Trellis.EntityFrameworkCore.DbContextRetryExtensions) when the retry shape is "regenerate a key and try again" rather than "second writer wins" — the two helpers are complementary, not substitutes.

Match FaultCodes.DuplicateKey, not a copied wire literal. The shared Core vocabulary also provides FaultCodes.ReferentialIntegrity, FaultCodes.RetryAborted, and FaultCodes.RetryExhausted for Result-returning save and retry helpers. Their spellings are frozen; the constants are available to clients and tests without an EF Core dependency.

DbExceptionClassifier.ExtractConstraintIdentity(DbUpdateException) is the lower-level building block the helper uses; the same identity is also now populated on the Error.Conflict returned by SaveChangesResultAsync and SaveChangesWithRetryAsync for the duplicate.key and referential.integrity reason codes, so existing code paths get the new telemetry fields for free.


Recipe 28 — Synthesise ProblemDetails.Instance from a ResourceRef

Problem. A POST /api/orders endpoint that creates an order against an existing customer fails with new Error.NotFound(ResourceRef.For<Customer>("abc-123")) when the customer does not exist. The default RFC 9457 Instance is the request URL (/api/orders), which does not identify the missing resource — the client has to inspect the body to learn which customer was missing. Worse, telemetry indexed by Instance collapses every missing-customer failure on this endpoint into the same /api/orders bucket regardless of which customer was missing.

Solution. TrellisAspOptions.SynthesizeProblemDetailsInstanceFromResourceRef (default true) tells ResponseFailureWriter to populate Instance from the failing ResourceRef whenever the request URL does not already identify the resource. The synthesised value is /{collection}/{escapedId} (no /api/ prefix, no api-version segment, no query string). The naive plural fallback lowercases the type name; registered overrides are emitted verbatim, so use lowercase override values if you want to preserve the convention. The original request URI is preserved under Extensions["request"] for callers that need both.

// Aggregates with default plural names need no extra wiring. The naive default
// is type.ToLowerInvariant() + "s" — fine for "Order" → "orders", "Customer" →
// "customers". Override irregular plurals or domain-specific naming with the
// attribute (preferred — keeps the mapping next to the type):
[ResourceCollectionName("people")]
public sealed class Person { /* ... */ }

[ResourceCollectionName("statuses")]
public sealed class Status { /* ... */ }
// Composition root. AddTrellisAsp wires the registry; the typed extension
// registers a single override and is AOT/trim-safe. Use the assembly scanner
// when you want every [ResourceCollectionName]-tagged type in an assembly
// picked up at once; mark the call as RequiresUnreferencedCode-aware in
// AOT-published apps.
services.AddTrellisAsp();
services.AddResourceCollectionName<Person>("people");
services.AddResourceCollectionName("LegacyDocument", "legacy-documents");
services.AddResourceCollectionNames(typeof(Person).Assembly);  // alternative

On the wire. POST /api/orders returning Result.Fail<Order>(new Error.NotFound(ResourceRef.For<Customer>("abc-123"))) emits:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "instance": "/customers/abc-123",
  "request": "/api/orders",
  "code": "error.unspecified",
  "kind": "not-found"
}

This NotFound was built with new Error.NotFound(...), which names no reason, so code is the sentinel error.unspecified; the HTTP condition is already carried by kind and status. Build it as new Error.NotFound(ResourceRef.For<Customer>(id)) { Code = "customer.not-found" } and that code reaches the wire instead — worth doing whenever a client would act differently on "no such row" than on "exists, but withheld from you", since both share the 404 surface. Code is inherited by every case, so any error given one emits it verbatim; the cases whose reason is required — Conflict, Forbidden, InvariantViolation, Unexpected — take it positionally and can never be built without one.

If the same error is raised on GET /api/customers/abc-123, the URL already identifies the resource, so synthesis is suppressed and instance stays /api/customers/abc-123 with no request extension. Suppression is segment-and-query-value-aware: an id of "1" does not match the path segment v1, and a percent-encoded path segment a%2fb correctly matches the raw id a/b (RFC 3986 case-insensitive percent escapes).

Opting out. Set o.SynthesizeProblemDetailsInstanceFromResourceRef = false in AddTrellisAsp(o => ...) to retain the historical request-URL-only Instance. The new shape is strictly more informative, so the toggle exists for strict backward compatibility only.

Defensive synthesis. The writer never throws while building the synthesised URI. Malformed ResourceRef values (empty Type or Id), unsafe collection names, and a missing registry all silently fall back to the request URL — a domain 404/409 can never turn into a 500 because of synthesis. Error.Aggregate never promotes a child's ResourceRef; the envelope itself carries no resource identity.

Common-noun guidance. The naive plural (Type.ToLowerInvariant() + "s") produces poor output for words like Status → statuss, Address → addresss, Person → persons. Override these explicitly via [ResourceCollectionName(...)] on the aggregate (preferred) or via services.AddResourceCollectionName<T>(name). The attribute constructor and the DI helper both validate the name as a single safe URL path segment so misconfiguration fails fast at host start.


Recipe 29 — IETF Idempotency-Key middleware on POST / PATCH with UseTrellisIdempotency

Problem. A client retries a POST /payments after a network hiccup. The original request already created the payment; the retry must return the same 201 Created payload — not a second 201 (double-charge), and not a 400 Bad Request because the second attempt now violates a uniqueness rule. The IETF draft-ietf-httpapi-idempotency-key-header names the header (Idempotency-Key) and the contract: Trellis stores the first response under (scope, parsed key) with its request fingerprint, replays it on a matching-fingerprint retry, rejects fingerprint mismatches with MismatchStatusCode (default 422 Unprocessable Entity), and serialises matching concurrent in-flight retries with 409 Conflict + Retry-After. Doing this by hand per endpoint scatters request buffering, response capture, scope/tenant isolation, and store-side CAS across the codebase.

Solution. Trellis.Asp.Idempotency.IdempotencyMiddleware (opt-in via the [Idempotent] endpoint attribute) does the contract end-to-end. The middleware is a no-op on endpoints that do not carry the attribute and on methods outside the configured set (POST and PATCH by default).

// Program.cs
builder.Services.AddControllers();
builder.Services.AddTrellis(t => t
    .UseAsp()
    .UseProblemDetails()
    .UseIdempotency(opt =>
    {
        opt.Ttl = TimeSpan.FromHours(24);
        opt.MaxRequestBodyBytes = 256 * 1024;
    }));
builder.Services.AddInMemoryIdempotencyStore(); // dev / single-instance; swap for an EF-backed store in production

var app = builder.Build();
app.UseTrellisIdempotency();
app.MapControllers();
// PaymentsController.cs
[ApiController]
[Route("payments")]
public sealed class PaymentsController : ControllerBase
{
    [HttpPost]
    [Idempotent]
    public Task<IActionResult> CreateAsync([FromBody] CreatePaymentRequest body, CancellationToken ct)
        => /* handler returns 201 Created with the payment representation */;
}
// Minimal API equivalent — attach the attribute as endpoint metadata.
app.MapPost("/payments", CreatePaymentAsync).WithMetadata(new IdempotentAttribute());

What it shows. The middleware reads the configured header (default Idempotency-Key), rejects raw header values above the parser's 4 KiB defensive cap, parses accepted values as the RFC 8941 sf-string subset, buffers the request body up to MaxRequestBodyBytes, computes a SHA-256 fingerprint over method, PathBase + Path, canonicalized query, Content-Type, Content-Encoding, configured AdditionalFingerprintHeaders, and body bytes, resolves a scope through IIdempotencyScopeResolver, and calls IIdempotencyStore.TryReserveAsync(scope, key, fingerprint, ct).

Scope is not the route. The default resolver uses the current actor's id via IActorProvider, falling back to the shared anonymous scope when no provider is registered or no actor resolves. A custom resolver can supply a different isolation boundary, such as tenant plus actor. All of an actor's opted-in endpoints sharing the store share one client key namespace. The fingerprint is compared against the entry at (scope, key); it is not a third key component. While that reservation or unexpired snapshot exists, the same actor, key, and body sent to POST /restaurants/A/import and then POST /restaurants/B/import produce a path mismatch and MismatchStatusCode (default 422), not a replay or a second independent operation. Use distinct keys for distinct operations and reuse a key for retries of the same request. See request identity and fingerprint.

The store either issues a Reserved outcome (carrying an opaque CAS reservation token), reports AlreadyInFlight (matching fingerprint, concurrent reservation still active — the middleware responds 409 Conflict with Retry-After), Replay (a matching-fingerprint, unexpired completed snapshot — the middleware writes the captured status code, headers, and body verbatim), or BodyHashMismatch (same scope and key, different request fingerprint — the middleware responds with MismatchStatusCode, default 422 Unprocessable Entity). Despite its name, BodyHashMismatch covers changes to any fingerprinted component, not just the body. When the reservation is Reserved, the middleware decorates IHttpResponseBodyFeature with CapturingResponseBodyFeature (a tee — bytes still flow to the client while a bounded copy is captured) and registers an OnStarting callback that snapshots the final status code and response headers before the first byte flushes. On a successful flush within MaxResponseBodyBytes, the middleware calls IIdempotencyStore.CompleteAsync(scope, key, reservationId, snapshot, ct) against a bounded 5-second cancellation token (NOT HttpContext.RequestAborted, so finalisation still runs if the client disconnected). On any failure path — exception, response-too-large, SendFileAsync (uncapturable), middleware abort, 5xx response status (treated as transient per the IETF Idempotency-Key draft), or response trailers (cannot be replayed by the snapshot writer) — the middleware calls AbandonAsync(scope, key, reservationId, ct) so the next retry can re-reserve. Reservation tokens are opaque string GUIDs the store uses for CAS so a stale completer cannot finalise a reservation the store already took over after the reservation timeout elapsed (a later same-key request re-reserves; there is no background sweeper).

Composition rules. services.AddTrellisIdempotency(...) (or the builder slot t.UseIdempotency(...)) registers options + scope resolver + an internal marker; services.AddInMemoryIdempotencyStore() is a separate, explicit call so a dev-only in-memory store is never silently inherited into production. app.UseTrellisIdempotency() throws at startup if AddTrellisIdempotency(...) was not called. The IIdempotencyStore registration is also validated at startup when the container exposes IServiceProviderIsService (the default Microsoft.Extensions.DependencyInjection container does); on containers that do not expose it the missing-store failure surfaces as a per-request resolution error on the first opted-in request. The in-memory store is single-process only; multi-instance hosts need an EF-backed store (per-tenant table or shared with Scope as a discriminator column) that implements the same CAS contract. MaxRequestBodyBytes and MaxResponseBodyBytes are hard caps: exceeding the request cap returns 413 Payload Too Large before any handler runs; exceeding the response cap aborts capture and records no snapshot (the next retry re-executes), so the cap should be set high enough to envelop the largest legitimate response from any opted-in endpoint. Endpoints that stream via SendFileAsync cannot be captured and are equivalent to exceeding the response cap — model those as non-idempotent or convert them to a buffered response.

Tests. Use Microsoft.AspNetCore.TestHost.TestServer (the same harness pattern as Recipe 26) plus an IIdempotencyStore registered as a singleton (InMemoryIdempotencyStore) plus TimeProvider swapped for Microsoft.Extensions.Time.Testing.FakeTimeProvider (from the Microsoft.Extensions.TimeProvider.Testing NuGet package). Within one scope, send the same key and complete request twice — assert the second call returns the captured status code, headers, and body byte-for-byte. Reuse the key with a changed body, path, or configured method on an opted-in endpoint — assert MismatchStatusCode (default 422) and that the original snapshot is still replayable. Advance FakeTimeProvider past ReservationTimeout to exercise the reservation-timeout takeover path (a later same-scope, same-key, matching-fingerprint request re-reserves the stale entry). The NuGet package is Microsoft.Extensions.TimeProvider.Testing (the namespace containing FakeTimeProvider is Microsoft.Extensions.Time.Testing); the test project should reference the package the same way Trellis.Testing.Worker's harness does.


Recipe 30 — Rehydrating entities from persistence: fail-loud vs Result-track

Problem. A repository loads a row from the database and needs to reconstruct a domain entity whose value-object fields each have TryCreate(...) → Result<TVO>. Recipe 18 covers the inbound direction (request DTO → command, with Result.Combine + Map). The inverse direction — DB row → entity — has a different failure semantic:

  • Inbound is untrusted input — every TryCreate failure is a legitimate validation response and must surface as Error.InvalidInput on the Result track so the caller can fix the request.
  • Outbound is trusted input — the row was written through the same TryCreate chain (write-path validation). A TryCreate failure on read means the row predates the validation rule (legacy data), the rule changed since (migration drift), or the database was tampered with — none of which the application caller can fix.

Picking the right rehydration shape depends on which trust model applies to that specific aggregate.

Pattern A — fail-loud rehydration (the 90% case)

When write-path validation is guaranteed (TryCreate enforced at every insert + update + migration backfill), a TryCreate failure on read is an operator bug. Use Result<T>.GetValueOrThrow(string? errorMessage) so the failure is loud, immediate, and names the offending row:

using Trellis;

public sealed class UserRepository(AppDbContext db) : IUserRepository
{
    public async Task<Result<User>> FindByIdAsync(UserId id, CancellationToken ct)
    {
        var row = await db.UserRows.AsNoTracking()
            .FirstOrDefaultAsync(r => r.Id == id.Value, ct);
        if (row is null)
            return Result.Fail<User>(new Error.NotFound(ResourceRef.For<User>(id)));

        // Write-path TryCreate guarantees these are valid. A failure here is database
        // corruption / migration drift; throw to fail loud rather than surface a
        // "validation" failure to the application layer (which cannot act on it).
        var user = User.TryCreate(
            UserId.TryCreate(row.Id).GetValueOrThrow($"Corrupt User.Id in row {row.Id}"),
            FirstName.TryCreate(row.FirstName).GetValueOrThrow($"Corrupt User.FirstName in row {row.Id}"),
            LastName.TryCreate(row.LastName).GetValueOrThrow($"Corrupt User.LastName in row {row.Id}"),
            EmailAddress.TryCreate(row.Email).GetValueOrThrow($"Corrupt User.Email in row {row.Id}"))
            .GetValueOrThrow($"Corrupt User aggregate for row {row.Id}");

        return Result.Ok(user);
    }
}

GetValueOrThrow throws InvalidOperationException whose message names the offending row. The exception bubbles through ExceptionBehavior and surfaces as new Error.Unexpected("unhandled-exception", faultId) to the wire (HTTP 500), with the full message in operator-side logs — exactly the shape for "this should never have happened."

Why not Trellis.Testing.Unwrap()? Unwrap() is a test-only helper (see Recipe 18). Production code that uses it mixes test and production seams and is harder to grep for than a named-verb extractor. GetValueOrThrow ships in Trellis.Core and the verb in the name makes the failure mode explicit at every call site.

Pattern B — Result-track end-to-end (the legacy-data case)

When write-path validation cannot be guaranteed — the table predates the current rules, an external migration imports rows from a third-party system, or the column may contain genuinely corruptible data — keep the failure on the Result track and let the application layer decide:

public sealed class LegacyContactRepository(AppDbContext db) : ILegacyContactRepository
{
    public Task<Result<Contact>> FindByIdAsync(ContactId id, CancellationToken ct) =>
        db.ContactRows.AsNoTracking()
            .FirstOrDefaultAsync(r => r.Id == id.Value, ct)
            .EnsureNotNullAsync(() => new Error.NotFound(ResourceRef.For<Contact>(id)))
            .BindAsync(row => Result.Combine(
                ContactId.TryCreate(row.Id, "Id"),
                FirstName.TryCreate(row.FirstName, "FirstName"),
                EmailAddress.TryCreate(row.Email, "Email"))
                .Bind((cid, firstName, email) => Contact.TryCreate(cid, firstName, email)));
}

The application layer then matches on the typed failure:

var result = await repo.FindByIdAsync(id, ct);
return result.Match(
    onSuccess: contact => Ok(ContactResponse.From(contact)),
    onFailure: err => err switch
    {
        Error.NotFound       => NotFound(),
        Error.InvalidInput i => UnprocessableEntity(i.Fields), // row is invalid — surface why
        _                    => Problem(),
    });

Choosing between A and B

Indicator Pattern A (fail-loud) Pattern B (Result-track)
Write path goes through TryCreate? Yes No (raw insert / external import / pre-validation legacy table)
Schema migrations preserve invariants? Yes (every column-add ships a backfill that satisfies TryCreate) No (some columns may have legacy values)
Who fixes a failure? Operator (it's a bug — read the log, fix the data, redeploy) Application / user (the data is genuinely invalid; surface and let them correct it)
Wire shape on failure HTTP 500 (Error.Unexpected) HTTP 422 (Error.InvalidInput) or domain-specific

If you don't have one clear answer for a given aggregate, default to Pattern A — it's the higher-leverage shape because the failure is operator-actionable, and it matches the write-path invariants you're already enforcing at the API seam.

Anti-pattern → fix.

// ❌ Wrong — Trellis.Testing.Unwrap() in production code. Mixes test and production seams;
//   harder to grep for than a named-verb extraction; the test-only contract is documented
//   at Recipe 18.
var user = User.TryCreate(
    UserId.TryCreate(row.Id).Unwrap(),
    EmailAddress.TryCreate(row.Email).Unwrap()).Unwrap();
return Result.Ok(user);

// ❌ Wrong — inline .Match(v => v, e => throw …). 50+ characters of ceremony at every call
//   site; obscures intent; every codebase reinvents its own exception type and message format.
var user = User.TryCreate(
    UserId.TryCreate(row.Id).Match(v => v, e => throw new InvalidOperationException(e.ToString())),
    EmailAddress.TryCreate(row.Email).Match(v => v, e => throw new InvalidOperationException(e.ToString())))
    .Match(v => v, e => throw new InvalidOperationException(e.ToString()));
return Result.Ok(user);

// ✅ Correct — GetValueOrThrow with a row-identifying message at each seam.
var user = User.TryCreate(
    UserId.TryCreate(row.Id).GetValueOrThrow($"Corrupt User.Id in row {row.Id}"),
    EmailAddress.TryCreate(row.Email).GetValueOrThrow($"Corrupt User.Email in row {row.Id}"))
    .GetValueOrThrow($"Corrupt User aggregate for row {row.Id}");
return Result.Ok(user);

Recipe 31 — Avoid duplicate load with IAuthorizedResource<TCommand, TResource>

Problem. A command implements IAuthorizeResource<Order> (or IAuthorizeResourceVia<Team>); the resource-authorization pipeline loads the resource to run Authorize(actor, resource); then the handler loads the same resource again from its repository to mutate it. For non-EF stores this is wasted I/O — a doubled CosmosDB read (and doubled RU billing), a doubled Dapper roundtrip, a doubled outbound HTTP call. Even for EF (where the change-tracker identity map returns the same tracked instance) the second LINQ query still fires.

Fix. Inject IAuthorizedResource<TCommand, TResource> and call GetRequiredResource() instead of re-fetching via the repository. The framework returns the same instance the loader produced for this dispatch.

// ❌ Wrong — handler reloads the resource the pipeline already loaded.
public sealed class CancelOrderHandler(IOrderRepository orders) // duplicate-load source
    : ICommandHandler<CancelOrderCommand, Result<Unit>>
{
    public async ValueTask<Result<Unit>> Handle(CancelOrderCommand cmd, CancellationToken ct)
    {
        var found = await orders.FindByIdAsync(cmd.OrderId, ct);   // SECOND lookup — wasteful
        if (!found.TryGetValue(out var order))
            return Result.Fail<Unit>(new Error.NotFound(ResourceRef.For<Order>(cmd.OrderId)));
        order.Cancel();
        return Result.Ok(Unit.Value);
    }
}

// ✅ Correct — handler reads the loaded resource from the accessor.
public sealed class CancelOrderHandler(IAuthorizedResource<CancelOrderCommand, Order> authorized)
    : ICommandHandler<CancelOrderCommand, Result<Unit>>
{
    public ValueTask<Result<Unit>> Handle(CancelOrderCommand cmd, CancellationToken ct)
    {
        // The pipeline loaded this Order to run cmd.Authorize(actor, order); we mutate the
        // SAME instance. No second DB roundtrip; for EF the entity is already tracked and the
        // mutation flows through the unit-of-work commit at the end of the request.
        authorized.GetRequiredResource().Cancel();
        return new(Result.Ok(Unit.Value));
    }
}

Command and loader are unchanged. Existing IAuthorizeResource<Order> + IIdentifyResource<Order, OrderId> + SharedResourceLoaderById<Order, OrderId> registrations stay exactly as in Recipe 7. The accessor is auto-registered by AddResourceAuthorization(...) for every closed (TMessage, TResource) pair the scan sees, and by the explicit AddResourceAuthorization<TMessage, TResource, TResponse>() / AddRelatedResourceAuthorization<...>() helpers for AOT consumers. No additional composition-root call is required.

Actor/resource parameter alternative. When business logic also needs the checked actor, the accessor constructor can be replaced by a parameterless framework base:

public sealed class CancelOrderHandler
    : ActorResourceCommandHandler<CancelOrderCommand, Order, Result<Trellis.Unit>>
{
    protected override ValueTask<Result<Trellis.Unit>> Handle(
        CancelOrderCommand command, Actor actor, Order order, CancellationToken cancellationToken)
    {
        order.Cancel();
        return new(Result.Ok());
    }
}

Keep genuine business constructor dependencies; remove only actor/accessor plumbing. The normal entry checks every declared gate and binds the resource to that exact dispatch, so an inner handler cannot borrow an outer dispatch's resource. Via commands use the corresponding ActorResourceViaCommandHandler<TCommand,TLeaf,TOwner,TResponse> and receive the same leaf, not owners. Handler tests send the command through Mediator with TestActorProvider and fake loaders/business dependencies; the actor/resource hook is protected and cannot be invoked as a public bypass. All mutation-readiness and TOCTOU cautions below apply equally to base-supplied resources.

Via commands (multi-hop authorization via IAuthorizeResourceVia<TOwner>) expose the leaf through the accessor — the resource the message identifies via IIdentifyResource<TLeaf, TLeafId>, which is the typical mutation target. The owner accessor is intentionally not exposed in v4; handlers that need owner state read it from their repository.

public sealed record UploadScorecardCommand(MatchId MatchId, Scorecard Scorecard)
    : ICommand<Result<Unit>>,
      IIdentifyResource<Match, MatchId>,
      IAuthorizeResourceVia<Team>
{
    public MatchId GetResourceId() => MatchId;
    public IResult Authorize(Actor actor, IReadOnlyList<Team> teams) =>
        Result.Ensure(teams.Any(t => t.CreatedByActorId == actor.Id),
            static () => new Error.Forbidden("team.not-owner"));
}

public sealed class UploadScorecardHandler(
    IAuthorizedResource<UploadScorecardCommand, Match> match)   // LEAF accessor
    : ICommandHandler<UploadScorecardCommand, Result<Unit>>
{
    public ValueTask<Result<Unit>> Handle(UploadScorecardCommand cmd, CancellationToken ct)
    {
        match.GetRequiredResource().UploadScorecard(cmd.Scorecard);   // mutate the leaf
        return new(Result.Ok(Unit.Value));
    }
}

When NOT to inject the accessor. The framework cannot enforce mutation-readiness — it just returns whatever the loader returned. If your loader returns any of the following, do not inject the accessor; reload via your repository instead:

  • A projection type (e.g. OrderHeader for cheap authorization, not the full Order aggregate).
  • A no-tracking EF entity that the handler must mutate (the mutation will not persist).
  • A stale read-replica POCO when the handler needs strong consistency.
  • An HTTP DTO that cannot be persisted back through any local repository.

In those cases the loader's job is "decide who owns the resource for the authorization check"; the handler's job is "fetch the canonical mutation-ready aggregate". They are different shapes and the accessor would couple them incorrectly.

Concurrency. The accessor is safe across nested mediator.Send and concurrent Task.WhenAll dispatch of the same closed pair within one DI scope. Implementation uses a per-async-flow linked frame list with an IsActive flag — each push allocates a new frame (no shared mutable state between sibling forks), and dispose flips the frame's IsActive flag (visible to orphan child tasks that captured the frame at fork time but outlived the parent dispatch). The framework guarantees an orphan task cannot read the resource after the parent's dispatch ends. (Verified by AuthorizedResourceHolderTests.ParallelPushes_OfDifferentResources_DoNotCrossContaminate and OrphanChildTask_CapturesFrameAtFork_ButReadsNothingAfterParentDispose.)

Failure modes. GetRequiredResource() throws InvalidOperationException outside a populated dispatch — typical causes:

  • the handler was invoked directly (e.g. from a unit test) without going through the mediator pipeline;
  • the message lacks resource-authorization registration (AddResourceAuthorization was never called for it);
  • authentication failed, the loader failed, or Authorize was denied (none of which populate the accessor — denied authorizations cannot expose the loaded resource).

For optional reads use TryGetResource(out var resource) which returns false instead of throwing.

What it shows. Eliminates the duplicate load that motivated the v4 accessor. For non-EF stores (CosmosDB, Dapper, HTTP-backed loaders) this is a measurable perf win — half the I/O on every authorized command. For EF it is also a win (skips the second LINQ query — the identity map only handles entity-instance deduplication, not the SQL roundtrip). The framework guarantee is identity, not mutation-readiness — the cookbook caution above is the user's responsibility.

Related recipes. Recipe 7 for the authorization model itself; Recipe 24 for the via case the accessor composes with.


Recipe 32 — Hide existence with AuthFailureExposurePolicy.HideAsNotFound

Problem. A Forbidden response on GET /incidents/{id} tells the unauthorized caller "this resource exists and you may not access it". For some resources — incident reports, security findings, internal correspondence, private profiles — that disclosure is itself the leak. The boundary needs to return 404 (indistinguishable from "the resource does not exist") to unauthorized actors.

Fix. Opt the resource into AuthFailureExposurePolicy.HideAsNotFound via ResourceAuthorizationOptions. Resource-stage root Error.NotFound, Error.Gone, Error.Forbidden, and Error.AuthenticationRequired become one fresh public NotFound. Its type and ID come from configuration and the request, never the original error; original code, detail, and cause are not copied. Other direct-resource / via-leaf errors pass through unchanged.

// Composition root.
builder.Services.AddTrellis(options => options
    .UseResourceAuthorization<GetIncidentQuery, Incident, Result<IncidentDto>>()
    .UseResourceAuthorization(o => o.HideExistence<Incident>()));
// Command and loader are unchanged from Recipe 7.
public sealed record GetIncidentQuery(IncidentId Id)
    : IQuery<Result<IncidentDto>>,
      IAuthorizeResource<Incident>,
      IIdentifyResource<Incident, IncidentId>
{
    public IncidentId GetResourceId() => Id;
    public IResult Authorize(Actor actor, Incident incident) =>
        Result.Ensure(incident.AssigneeId == actor.Id || actor.HasPermission("incidents:read-any"),
            static () => new Error.Forbidden("incidents.read-denied"));
}

Public metadata. For the same request ID, missing, removed (Gone), denied, and anonymous resource-stage outcomes all yield NotFound with resource { Type: "Incident", Id: "inc-42" }, code error.unspecified, and the standard NotFound display message. The default ASP mapper produces matching 404 ProblemDetails metadata, including instance; only per-request trace identifiers may vary. No original storage resource or cause reaches this public error.

Fixed public code/detail. Both HideExistence forms accept optional static metadata. Use the same configured reason for every concealed failure, not different missing/denied reasons:

.UseResourceAuthorization(o => o.HideExistence<Incident>(
    code: "incident.not-found",
    detail: "Incident not found."));

Omitted/null/empty/whitespace code uses error.unspecified; null detail uses the standard display message. The last call for a resource replaces its configuration; parameterless HideExistence resets code/detail to defaults.

Multiple resources. Chain calls in one callback or contribute separate callbacks; callbacks compose in registration order.

.UseResourceAuthorization(o => o
    .HideExistence<Incident>()
    .HideExistence<SecurityFinding>()
    .HideExistence<PrivateProfile>())

Default is Propagate. Resources that do not opt in retain their original errors. Set DefaultExposurePolicy = AuthFailureExposurePolicy.HideAsNotFound for service-wide hiding with default public metadata, then use Propagate<TResource>() for safe-to-disclose resources.

.UseResourceAuthorization(o =>
{
    o.DefaultExposurePolicy = AuthFailureExposurePolicy.HideAsNotFound;
    o.Propagate<PublicProfile>();      // genuinely public resource — leak is harmless
});

Projection-loader overload. When the loader returns an internal projection for authorization but the wire-public type is different, use the two-type overload:

.UseResourceAuthorization(o => o.HideExistence<IncidentOwnership, Incident>(
    code: "incident.not-found", detail: "Incident not found."));

The pipeline extracts the ID from IIdentifyResource<Incident, IncidentId> first, falling back to the projection's identifier interface when necessary. NotFound.Resource.Type is the public type name, for missing projections as well as denials.

Via commands key on TLeaf. HideExistence<Match>() covers IAuthorizeResourceVia<Team> + IIdentifyResource<Match,MatchId>. Missing leaves and withheld outcomes use the same public Match error, never an owner error. The projection form can select a separate public leaf type.

Pipeline interaction caveat. When a command implements both IAuthorize (static permissions) and IAuthorizeResource<T>, the canonical pipeline runs AuthorizationBehavior before ResourceAuthorizationBehavior. An unauthenticated caller fails the static gate first — that AuthenticationRequired is not translated to NotFound, because AuthorizationBehavior has no concept of the resource it's protecting. Commands that need full existence-hiding (anonymous probes return 404, not 401) must omit IAuthorize and let HideAsNotFound cover the resource-authorization branch alone.

ActorResourceQueryHandler<GetIncidentQuery,Incident,Result<IncidentDto>> and the corresponding direct-command/via bases preserve this resource-only shape: they do not force IAuthorize. Their protected Handle receives the authenticated, resource-authorized actor and loaded incident/leaf only after the resource gate succeeds.

Cache safety. A shared cache can serve an unauthorized actor's synthetic 404 to a later authorized actor, regardless of whether their error bodies match. Mark responses for hidden resources with Cache-Control: private or no-store:

endpoints.MapGet("/incidents/{id}", async (...) =>
    (await mediator.Send(new GetIncidentQuery(...)))
        .ToHttpResponse(o => o.WithCacheControl(CacheControl.NoStore())));   // safe under HideAsNotFound

Observability. Every translation emits a structured log at Information:

EventId: 1 (EventName "ExistenceHidden")
Resource-authorization failure hidden as NotFound for GetIncidentQuery: original Kind=forbidden Code=incidents.read-denied → public resource Incident

The log retains the original input Kind and Code for private diagnostics, including missing-resource codes. They are not attached to the returned error or its cause. Example SIEM query (KQL):

Trellis_Logs
| where EventName == "ExistenceHidden"
| summarize count() by MessageName, OriginalCode, PublicResourceType, bin(TimeGenerated, 5m)

Normalization scope. Only resource-stage root NotFound/Gone/Forbidden/AuthenticationRequired normalize, including the Forbidden generated for a loader's null-success contract violation. Gone uses the same public 404 so tombstones cannot reveal previous existence. Direct-resource / via-leaf Unexpected, Unavailable, transport faults, and other root kinds remain unchanged. Aggregates and unrelated handler failures are outside the policy. Application response customization must not reintroduce private distinctions; response timing is not equalized.

Via intermediate/owner failures. Every intermediate/owner load failure collapses to Forbidden("resource.authorization-via.load-failed") before normalization, regardless of underlying kind. Under hiding, even owner Unavailable becomes the public NotFound. ExistenceHidden sees that collapsed code, not the downstream cause. Use direct authorization with a custom projection loader when your application needs to classify related-resource operational failures itself.

Related recipes. Recipe 7 for the authorization model; Recipe 24 for via commands; Recipe 31 for the resource-handoff accessor that composes with this policy.


Recipes 33-34 — Moved to xavierjohn/Trellis.Microservices

Moved. Recipes 33 ("Strict AddJwtBearer validation profile for UseTrellisInternalJwtActor") and 34 ("Microservices behind YARP, end-to-end") moved to the new xavierjohn/Trellis.Microservices repository in v3, alongside the carved-out Trellis.Microservices.AspNetCore and moved Trellis.Yarp packages. They are now Recipe 1 and Recipe 2 of the microservices cookbook.

Why moved. The recipes document the consumer-side strict AddJwtBearer profile (Recipe 1) and the end-to-end YARP gateway + downstream walkthrough (Recipe 2), both of which depend on types that now live exclusively in the new repo. Keeping them here would create dangling cross-doc references.

Migration for early adopters. If you were calling services.AddTrellis(b => b.UseTrellisInternalJwtActor(...)), switch to services.AddTrellisInternalJwtActorProvider(...) after installing Trellis.Microservices.AspNetCore and replacing the using Trellis.Asp.Authorization; directive with using Trellis.Microservices.AspNetCore;. The UseTrellisInternalJwtActor slot was removed from TrellisServiceBuilder in this same v3 cleanup (breaking change — see CHANGELOG).


Recipe 35 — Transactional outbox for crash-safe domain events

Problem. Your command raises a domain event and commits, but the in-pipeline UseDomainEvents() dispatch runs after the transaction. If the process crashes between the commit and the dispatch, the event is lost — the order is saved but OrderPlaced never reaches its handler.

Fix. Install Trellis.EntityFrameworkCore.Outbox. The capture interceptor writes one row per uncommitted event into TrellisOutboxMessages in the same transaction as the aggregate, and a background relay re-dispatches them after the commit. Wire three things:

// 1. Map the table.
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
    modelBuilder.AddTrellisOutbox();

// 2. Add the capture interceptor on the context options.
options.UseNpgsql(cs).AddTrellisInterceptors().AddTrellisOutboxInterceptor();

// 3. Register the relay (UseOutbox) alongside your handlers (UseDomainEvents).
services.AddTrellis(trellis => trellis
    .UseDomainEvents(typeof(Program).Assembly)
    .UseEntityFrameworkUnitOfWork<AppDbContext>()
    .UseOutbox<AppDbContext>());

Raise events exactly as before — DomainEvents.Add(new OrderPlaced(Id, clock.GetUtcNow())). When the outbox is enabled the capture interceptor clears the aggregate's events after the commit succeeds (in the SavedChanges callback, not inside the transaction), so the in-pipeline dispatch sees none and the relay becomes the single, durable dispatcher.

Semantics to remember.

  • The guarantee is at-least-once delivery, and delivery means every handler completed: a handler that throws leaves the message pending and the retry re-invokes only the failed handlers, up to OutboxOptions.MaxAttempts, after which the message is parked. Make handlers idempotent — a crash before the relay's bookkeeping save re-delivers to all of them. (In-pipeline dispatch still swallows handler exceptions: it runs post-commit and has no retry mechanism.)
  • Maybe<T> event members are supported — a present value serializes as the underlying value, an absent one as JSON null. Members that depend on a caller-registered (non-attribute) JsonSerializerOptions converter still need a nullable transport, since the outbox serializer only honors [JsonConverter]-attributed types.
  • This is an outbox, not an event store: rows are a transient delivery buffer and may be pruned once ProcessedAt is set.
  • Capture persists the current W3C trace context with each domain row; the alpha outbox schema includes nullable lineage and trace columns. See the outbox reference for the column names.

See trellis-api-efcore-outbox.md for the full contract, options, and operational guidance.

Recipe 36 — Translating a domain event into an integration event

Problem. You want to publish that an order was placed to other services, but your OrderPlaced domain event carries internal value objects (OrderId, CustomerEmail, Money). Relaying it as-is couples external consumers to your domain model, and every refactor becomes a breaking wire change.

Fix. Keep the domain event internal and publish a deliberately-shaped IIntegrationEvent translated from it. The translator is an ordinary domain-event handler that adds to IIntegrationEventCollector; the outbox relay captures and delivers the integration event.

// 1. The external contract — primitive/nullable members, no internal value objects.
public sealed record OrderPlacedIntegrationEvent(Guid OrderId, string CustomerEmail, decimal Total, DateTimeOffset OccurredAt)
    : IIntegrationEvent;

// 2. The translator — a domain-event handler that emits the contract.
public sealed class OrderPlacedTranslator(IIntegrationEventCollector collector) : IDomainEventHandler<OrderPlaced>
{
    public ValueTask HandleAsync(OrderPlaced domainEvent, CancellationToken cancellationToken)
    {
        collector.Add(new OrderPlacedIntegrationEvent(
            domainEvent.OrderId.Value, domainEvent.CustomerEmail.Value, domainEvent.Total.Amount, domainEvent.OccurredAt));
        return ValueTask.CompletedTask;
    }
}

// 3. An in-process consumer (or replace IIntegrationEventPublisher with a broker adapter).
public sealed class NotifyShippingHandler : IIntegrationEventHandler<OrderPlacedIntegrationEvent>
{
    public ValueTask HandleAsync(OrderPlacedIntegrationEvent e, CancellationToken ct) { /* ... */ return ValueTask.CompletedTask; }
}

// 4. Wire it — translators are domain-event handlers; the outbox delivers both kinds.
services.AddTrellis(trellis => trellis
    .UseDomainEvents(typeof(Program).Assembly)
    .UseIntegrationEvents(typeof(Program).Assembly)
    .UseEntityFrameworkUnitOfWork<AppDbContext>()
    .UseOutbox<AppDbContext>());

Semantics to remember.

  • The integration event is enrolled only after its source domain event is durably committed and dispatched by the relay — never for state that rolled back. The relay stages drained collector events as OutboxMessageKind.Integration rows atomically with the source row's saved handler progress (or overall completion), then publishes them on a later drain. There is no per-handler collector rollback: an event added by a translator that subsequently throws can still be enrolled, as can output from a successful translator whose sibling fails. The source row can remain pending while those integration rows are already eligible for publication; integration publication is not proof that the translator or every local handler completed.
  • The default IIntegrationEventPublisher fans out in-process to IIntegrationEventHandler<T> (great for a modular monolith and tests). Replace that one registration with a message-broker adapter to deliver to other services — the aggregate, translator, and outbox are unchanged.
  • Delivery is at-least-once. Routine retries skip translators whose success was recorded, so a failed sibling does not by itself re-enroll their integration events. A translator that added an event and then failed is retried and can produce a new row with a new message id; crashes before progress is saved can also repeat translation. Delivery retries can redeliver an existing integration row. Dedupe repeated delivery by message id, and use business identity when semantic duplicates can have distinct message ids.
  • Integration events require the outbox. The collector accepts Add only inside an active outbox-relay translator invocation; calls from command handlers, direct translator calls, and ordinary in-process domain dispatch throw InvalidOperationException. Use the persistence/capture setup from Recipe 35 plus UseOutbox<TContext>(); the translator above is invoked by the relay, not by the command handler.
  • CausationId on a translated integration row is the source domain row id; the domain row's cause is the inbound envelope MessageId when present. Both rows share the persisted W3C trace and optional application-owned business CorrelationId (use IntegrationMessageContext.BeginCorrelation("workflow-id") to supply one explicitly). Do not derive business correlation from a trace or HTTP request id.

See trellis-api-efcore-outbox.md for the routing contract.

Recipe 37 — Reconstituting an aggregate without its factory (non-EF repositories)

Problem. A non-EF repository (Dapper, raw ADO, a Cosmos SDK) must rebuild an aggregate from a stored row without re-running its Create/TryCreate factory: reconstitution must not re-validate invariants, mint a new identity, or raise creation events. EF Core does this in its materializer; outside EF you assemble the aggregate yourself. Recipe 30 covers turning stored primitives back into value objects; this recipe covers assembling the aggregate from them and restoring its persistence metadata via the IReconstitutionStampable seam.

1. The aggregate exposes a reconstitution factory (author-owned)

Add a private constructor that takes the full persisted domain state (including child collections) and a Reconstitute(...) factory that calls it — pure assignment, no Create, no behavior methods, no events. Make the factory internal (+ [InternalsVisibleTo] to the persistence assembly) to keep it off the public domain surface, or public when the adapter lives in another package.

using Trellis;

public sealed class Order : Aggregate<OrderId>
{
    private readonly List<OrderLine> _lines = [];

    public CustomerId CustomerId { get; }
    public OrderStatus Status { get; }
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();

    private Order(OrderId id, CustomerId customerId) : base(id)   // create-time ctor
    {
        CustomerId = customerId;
        Status = OrderStatus.Draft;
    }

    public static Result<Order> Create(CustomerId customerId) => /* invariant checks */ ;

    private Order(OrderId id, CustomerId customerId, OrderStatus status, IEnumerable<OrderLine> lines)
        : base(id)                                                // reconstitution ctor — pure assignment
    {
        CustomerId = customerId;
        Status = status;
        _lines.AddRange(lines);
    }

    // Rebuilds an Order from stored domain state. Does NOT run Create or raise events.
    internal static Order Reconstitute(
        OrderId id, CustomerId customerId, OrderStatus status, IEnumerable<OrderLine> lines) =>
        new(id, customerId, status, lines);
}

2. The repository assembles domain state, then stamps infrastructure metadata

Rehydrate the value objects (fail-loud per Recipe 30), call Reconstitute, then cast to IReconstitutionStampable to restore the audit timestamps and the concurrency token in one call. StampReconstitutedState validates the ETag and clears any uncommitted events, so the loaded aggregate has no uncommitted events (IsChanged == false under the default event-based change tracking).

public sealed class DapperOrderRepository(IDbConnection db) : IOrderRepository
{
    public async Task<Result<Order>> FindByIdAsync(OrderId id, CancellationToken ct)
    {
        var row = await db.QuerySingleOrDefaultAsync<OrderRow>(/* ... */);
        if (row is null)
            return Result.Fail<Order>(new Error.NotFound(ResourceRef.For<Order>(id)));

        var lines = lineRows.Select(r => OrderLine.Reconstitute(
            ProductId.TryCreate(r.ProductId).GetValueOrThrow($"Corrupt OrderLine.ProductId in row {r.Id}"),
            r.Quantity));

        var order = Order.Reconstitute(
            OrderId.TryCreate(row.Id).GetValueOrThrow($"Corrupt Order.Id in row {row.Id}"),
            CustomerId.TryCreate(row.CustomerId).GetValueOrThrow($"Corrupt Order.CustomerId in row {row.Id}"),
            OrderStatus.TryCreate(row.Status).GetValueOrThrow($"Corrupt Order.Status in row {row.Id}"),
            lines);

        // Restore the infrastructure metadata loaded from the row. Store-native tokens are commonly
        // quoted (a Cosmos _etag arrives as "abc" or W/"abc"), but StampReconstitutedState requires
        // the unquoted RFC 9110 opaque form and throws on '"'. Stores that already hand back an
        // unquoted token (a SQL rowversion, say) pass straight through.
        ((IReconstitutionStampable)order)
            .StampReconstitutedState(row.CreatedAt, row.LastModified, NormalizeStoredETag(row.ETag, row.Id));

        return Result.Ok(order);
    }

    private static string NormalizeStoredETag(string stored, Guid rowId)
    {
        if (!stored.StartsWith('"') && !stored.StartsWith("W/\"", StringComparison.Ordinal))
            return stored;

        if (!EntityTagValue.TryParse(stored).TryGetValue(out var parsed))
            throw new InvalidOperationException($"Corrupt Order.ETag in row {rowId}: {stored}");

        return parsed.OpaqueTag;
    }
}

On save, generate a fresh token and stamp it the same way (or via IETagStampable.StampETag), using the previous value in the optimistic WHERE ETag = @original clause — the non-EF equivalent of the EF concurrency-token interceptor.

Semantics to remember.

  • The reconstitution constructor must be pure assignment: no Create, no behavior methods, no events. StampReconstitutedState clears any uncommitted events defensively, but relying on that hides a modeling bug.
  • Reconstitution does not re-validate domain invariants — correct for trusted outbound reads (see Recipe 30). Value-object-level checks still run through each TryCreate.
  • Child entities follow the same pattern: a private reconstitution constructor + Reconstitute factory; the parent copies them into its backing collection.

Recipe 38 — Tenant-scoped resource authorization with a typed actor attribute

Problem. A multi-tenant service must reject any command whose target row belongs to a different tenant than the caller. The check is the same in every command — read the tenant from the actor's claim, compare it to the resource's tenant — so it gets copy-pasted, and each copy re-does the actor.GetAttribute("tid") string lookup plus a manual TenantId.TryCreate(...). A shared base class is tempting, but tenant isolation is policy, not configuration: a base type either rigidly assumes one scope shape or grows so many hooks it saves nothing, and it hides a security decision inside a hierarchy. Keep the rule explicit per command; remove only the parsing ceremony.

using Mediator;
using Trellis;
using Trellis.Authorization;
using Trellis.Mediator;

// String-backed tenant id, sourced from the actor's "tid" claim. DocumentId and TenantDocument
// (the loaded resource, carrying the tenant it belongs to) are your own types — see Recipe 1.
public sealed partial class TenantId : RequiredString<TenantId>;

public sealed record ArchiveDocumentCommand(DocumentId DocumentId)
    : ICommand<Result<Trellis.Unit>>, IAuthorizeResource<TenantDocument>, IIdentifyResource<TenantDocument, DocumentId>
{
    public DocumentId GetResourceId() => DocumentId;

    // The per-command scope check. No base class — the rule stays explicit and lives with the
    // command. Actor.TryGetAttribute<TenantId> parses the "tid" claim through the VO's IParsable (validating via TryCreate),
    // so the gate deny-closes (Forbidden) on a missing, malformed, or mismatched tenant claim.
    public IResult Authorize(Actor actor, TenantDocument resource) =>
        Result.Ensure(
            actor.TryGetAttribute<TenantId>(ActorAttributes.TenantId, out var tenant) && tenant == resource.TenantId,
            () => new Error.Forbidden(
                Code: "tenant.isolation",
                Resource: ResourceRef.For<TenantDocument>(resource.Id)));
}

// When a handler needs the tenant as a Result to compose with other steps, the Result-returning
// overload carries the value object forward (or a failed Result whose error field is the key):
Result<TenantId> tenant = actor.GetRequiredAttribute<TenantId>(ActorAttributes.TenantId);

What it shows. Actor.GetRequiredAttribute<TVo>(key) and Actor.TryGetAttribute<TVo>(key, out vo) parse an actor attribute (an ABAC claim) into a Trellis value object through its IParsable implementation — the same validation that guards request input now guards claim-sourced values, with no GetAttribute(...) + TryCreate(...) boilerplate and no magic strings. TryGetAttribute is the natural fit for an authorization gate (deny-close on false); GetRequiredAttribute returns Result<TVo> for railway composition in a handler, failing with an Error.InvalidInput whose field is the attribute key when the claim is absent or invalid. The value object can be any source-generated Required* VO — string-, Guid-, or int-backed — since the generator makes each one IParsable, so a Guid-backed tenant id works as naturally as a string claim. Wiring is identical to Recipe 7; the tenant check lives in Authorize(actor, resource), which the resource-authorization pipeline runs after the loader produces the resource.

Why no TenantScopedCommand base type. The variable part of a tenant guard — what "scope" means, which resources are scoped, how the resource exposes its tenant — is domain policy. A reusable base class is either too rigid or needs enough hooks to beat the three explicit lines it replaces, and inheritance hides a security decision. The typed accessor removes the ceremony (parsing) while leaving the policy (the comparison) visible and per-command.

Recipe 39 — Rendering a validation failure in the caller's language (code + args)

Problem. A 422 arrives carrying detail in English, and the UI must show the message in the user's language. The tempting shortcut is to scrape the sentence — read the numbers out of "must be between 0 and 150", or split "Valid values: Checking, MoneyMarket, Savings" on ", " — and that shortcut is what code and args exist to retire. Prose belongs to whichever producer noticed the failure, and two producers may notice the same failure: an unknown enum name is worded 'Platinum' is not a valid AccountType. Valid values: … by RequiredEnum.TryCreate and Invalid AccountType value: 'Platinum'. Valid values are: … by RequiredEnumJsonConverter, depending on how the value reached the model. A scraper built against one wording silently mis-renders the other. Code is stable, and Args carries the operands as typed values — so render from those and treat Detail only as a fallback.

using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Trellis;
using Trellis.Asp;

// Resource keys are reason codes, so the .resx *is* the vocabulary:
//   value.between-inclusive   = "Must be between {from} and {to}."
//   string.max-length         = "Use at most {maxLength} characters."
//   enum.name-undefined       = "Choose one of: {allowed}."
//   enum.name-undefined.count = "That is not one of the {allowedCount} permitted values."
//   enum.name-undefined.bare  = "That is not a permitted value."
//   enum.undefined            = "Choose one of: {allowed}."
//   enum.undefined.count      = "That is not one of the {allowedCount} permitted values."
//   enum.undefined.bare       = "That is not a permitted value."
//   value.not-empty           = "This field cannot be blank."
//   error.unspecified         = "That value is not valid."
//
// Two further keys are *not* reason codes: bool.true and bool.false render boolean args,
// because a raw "True" is not a sentence in any language.
//
// The rows above are illustrative: a real table carries one row per code the API can emit,
// and a row per .count/.bare variant of the two enum codes.
public sealed class ViolationMessages;

public sealed class ViolationMessageRenderer(IStringLocalizer<ViolationMessages> localizer)
{
    public string Render(FieldViolationProblemDetail violation, CultureInfo culture)
    {
        var template = localizer[TemplateKey(violation)];

        // An unknown code is not a bug to hide: a server may add a code before this client
        // learns it. The server's own sentence is the best available answer, and only when
        // it is absent too does the caller get a generic one -- which is why
        // error.unspecified is the one row the table must carry.
        return template.ResourceNotFound
            ? violation.Detail ?? localizer[ValidationCodes.Unspecified].Value
            : Expand(template.Value, violation.Args, culture);
    }

    // `allowed` is dropped whole past ValidationArgs.MaxAllowedMembers and replaced by
    // `allowedCount`, so an enum rejection has three renderings, not one. A missing `allowed`
    // means "not supplied" — never "nothing is permitted".
    private static string TemplateKey(FieldViolationProblemDetail violation)
    {
        if (violation.Code is not (ValidationCodes.EnumNameUndefined or ValidationCodes.EnumUndefined))
            return violation.Code;
        if (violation.Args?.ContainsKey("allowed") == true) return violation.Code;

        return violation.Args?.ContainsKey("allowedCount") == true
            ? violation.Code + ".count"
            : violation.Code + ".bare";
    }

    private string Expand(
        string template,
        IReadOnlyDictionary<string, ValidationArgValue>? args,
        CultureInfo culture)
    {
        if (args is null || !template.Contains('{')) return template;

        var rendered = new StringBuilder(template.Length);
        var rest = template.AsSpan();
        while (true)
        {
            var open = rest.IndexOf('{');
            var close = open < 0 ? -1 : rest[open..].IndexOf('}');
            if (open < 0 || close < 0)
            {
                rendered.Append(rest);
                return rendered.ToString();
            }

            rendered.Append(rest[..open]);
            var name = rest.Slice(open + 1, close - 1).ToString();
            rendered.Append(args.TryGetValue(name, out var value)
                ? Format(value, culture)
                : rest.Slice(open, close + 1));   // leave an unmatched placeholder visible
            rest = rest[(open + close + 1)..];
        }
    }

    // The union is closed, so this switch covers every shape an arg can take: a new case
    // cannot appear without this method needing an arm for it.
    private string Format(ValidationArgValue value, CultureInfo culture) => value switch
    {
        ValidationArgValue.Text text => text.Value,

        // Invariant on the wire, cultural on screen — a German user reads "1,5", not "1.5".
        ValidationArgValue.Number number => number.Value.ToString("G29", culture),

        // A raw "True" is not a sentence in any language, so booleans go through the table too.
        ValidationArgValue.Bool flag => localizer[flag.Value ? "bool.true" : "bool.false"].Value,

        ValidationArgValue.List list => FormatList(list, culture),

        // Unreachable while the union stays closed. Throwing rather than returning "" is what
        // makes the exhaustiveness real: a case added by a future upgrade surfaces here instead
        // of silently deleting an operand from a translated sentence.
        _ => throw new NotSupportedException($"Unhandled arg value: {value.GetType().Name}."),
    };

    // Projected with a loop rather than LINQ: `Items` is an EquatableArray, which deliberately
    // does not implement IEnumerable<T>, so Enumerable.Select never applies. The only `Select`
    // in scope is then MaybeLinqExtensions', and inference fails with CS0411.
    private string FormatList(ValidationArgValue.List list, CultureInfo culture)
    {
        var parts = new string[list.Items.Length];
        for (var i = 0; i < parts.Length; i++)
            parts[i] = Format(list.Items[i], culture);

        return string.Join(culture.TextInfo.ListSeparator + " ", parts);
    }
}

Read the violations off the response, not the root, and do not assume they are there:

public static class ViolationReader
{
    /// <summary>
    /// The root <c>code</c> on a validation failure is the <c>error.unspecified</c> sentinel by
    /// design — the reasons are per-violation. And a body that never parsed reports 400 with no
    /// <c>fieldViolations</c> member at all, which is "no per-field reason available", not a
    /// malformed response.
    /// </summary>
    public static FieldViolationProblemDetail[] ReadFieldViolations(
        ProblemDetails problem,
        JsonSerializerOptions options) =>
        problem.Extensions.TryGetValue("fieldViolations", out var raw) && raw is JsonElement element
            ? element.Deserialize<FieldViolationProblemDetail[]>(options) ?? []
            : [];

    public static IEnumerable<(string? Pointer, string Message)> Localize(
        ProblemDetails problem,
        ViolationMessageRenderer renderer,
        JsonSerializerOptions options,
        CultureInfo culture) =>
        ReadFieldViolations(problem, options)
            .Select(violation => (violation.Location.Pointer, renderer.Render(violation, culture)));
}

FieldViolationProblemDetail and ValidationArgValue are declared in Trellis.Asp and Trellis.Core, which a Trellis-based caller already references — but nothing here depends on that. The contract is the JSON, so a client on another stack declares its own equivalent record and the recipe is unchanged; these are response-shape metadata, not a domain model to share.

What it shows. A localized message is a function of Code, Args, and a resource table — never of Detail. Keying the .resx on the reason code makes the ValidationCodes vocabulary the translation vocabulary, so a missing translation is a missing row rather than a parsing bug, and a translator sees the whole surface in one file. Because ValidationArgValue is a closed union of JSON's self-describing values, formatting is total and exhaustive: Number is a decimal written invariantly on the wire and formatted culturally for display, which is the one conversion a scraper cannot do correctly because it never recovers the type. Location.Pointer (RFC 6901) is what binds the message back to a form control.

The same renderer works server-side when an API must localize on behalf of thin clients — resolve the culture from Accept-Language and render before writing the response. Prefer the client doing it: the server cannot know the user's locale better than the client, and a localized detail is not cacheable across languages.

Anti-pattern → fix.

Anti-pattern Why it breaks Fix
Parsing operands out of Detail ("Valid values: ", "between 0 and 150") Prose belongs to the producer, and one failure has several producers with different wording. It is also English-only, which is the problem being solved. Read Args["from"], Args["allowed"].
Treating a missing allowed as "nothing is permitted" Past ValidationArgs.MaxAllowedMembers the list is omitted deliberately and allowedCount sent instead. Rendering "choose one of: (none)" tells the user the field is unusable. Branch on allowed / allowedCount / neither.
Switching on the root code Error.InvalidInput leaves the root at error.unspecified; the actionable codes are per violation. Descend into fieldViolations[n].code.
Assuming fieldViolations exists A body that never parsed reports 400 with no such member. Treat absence as "no per-field reason available".
Formatting a Number with InvariantCulture for display, or echoing the raw JSON text Shows 1.5 to a user whose locale writes 1,5. Format with the UI culture; the wire stays invariant.
Falling back to a generic string for an unrecognized code Discards the server's own explanation, which is usually better than "Invalid input". Fall back to Detail first, generic prose last.

Recipe 40 — Computed pagination with validated query-bound continuation state

Problem. Page a computed distance (or score), preserve a deterministic tie-breaker, reject impossible boundaries, and reject tokens from a different origin/filter/algorithm context. Spherical latitude/longitude queries have a translated EF Core path; other computations remain application-owned and must start from a complete, bounded candidate set.

Translated EF Core spherical-distance path

GeoBounds validates the radius and derives one or two conservative, non-wrapping boxes. GeoCoordinateExpressions.WithinRadius inlines that broad prefilter and the exact haversine predicate into one expression. DistanceMetersTo supplies the same translated calculation as the first seek key; the database therefore owns filtering, ordering, boundary projection, and seeking.

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Trellis;
using Trellis.EntityFrameworkCore;
using Trellis.Primitives;

public static class NearbyVenuePagination
{
    public static Task<Result<Page<Venue>>> ListAsync(
        AppDbContext db,
        GeoCoordinate origin,
        double radiusMeters,
        string scopeFilterIdentity,
        string? cursor,
        int? limit,
        CancellationToken ct)
    {
        return GeoBounds.TryCreate(origin, radiusMeters, nameof(radiusMeters))
            .Combine(PageRequest.TryCreate(cursor, limit))
            .BindAsync(input =>
            {
                var (bounds, request) = input;
                var withinRadius = GeoCoordinateExpressions.WithinRadius<Venue>(
                    venue => venue.Latitude,
                    venue => venue.Longitude,
                    bounds);
                var distance = GeoCoordinateExpressions.DistanceMetersTo<Venue>(
                    venue => venue.Latitude,
                    venue => venue.Longitude,
                    bounds.Center);

                var context = ContextIdentity(bounds, scopeFilterIdentity);
                var seek = SeekDefinition.Ascending(distance)
                    .ThenAscending(venue => venue.Id)
                    .WithCodec(CreateCodec(context, bounds.RadiusMeters));

                return db.Venues
                    .AsNoTracking()
                    .Where(venue => venue.IsPublished)
                    .Where(withinRadius)
                    .ToPageAsync(request, seek, cancellationToken: ct);
            });
    }

    private static ICursorCodec<(double Primary, Guid Secondary)> CreateCodec(
        string context,
        double radiusMeters) =>
        CursorCodec.Map<
            ((double Primary, Guid Secondary) Primary, string Secondary),
            (double Primary, Guid Secondary)>(
            CursorCodec.Composite(
                CursorCodec.Composite<double, Guid>(),
                CursorCodec.Scalar<string>()),
            state => (state, context),
            (wire, field) =>
                double.IsFinite(wire.Primary.Primary)
                && wire.Primary.Primary is >= 0
                && wire.Primary.Primary <= radiusMeters
                && string.Equals(wire.Secondary, context, StringComparison.Ordinal)
                    ? Result.Ok(wire.Primary)
                    : Result.Fail<(double Primary, Guid Secondary)>(
                        Error.InvalidInput.ForField(
                            field: field ?? "cursor",
                            code: "cursor.malformed",
                            detail: "Cursor distance or query context is invalid.")));

    private static string ContextIdentity(GeoBounds bounds, string scopeFilterIdentity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(scopeFilterIdentity);
        var canonical = string.Concat(
            "geo-haversine-v1;distance-asc;guid-asc;",
            scopeFilterIdentity.Length.ToString(CultureInfo.InvariantCulture), ":",
            scopeFilterIdentity, ";",
            Canonical(bounds.Center.Latitude), ";",
            Canonical(bounds.Center.Longitude), ";",
            Canonical(bounds.RadiusMeters));
        return Convert.ToHexString(
            SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
    }

    private static string Canonical(double value) =>
        (value == 0 ? 0d : value).ToString("R", CultureInfo.InvariantCulture);
}

scopeFilterIdentity is a server-derived identity for the exact authorization and filter scope; it is not client input and does not replace the .Where(...) predicates. Include every membership/ordering input in the canonical identity. The codec adds that identity to the wire state and validates it on decode; WithCodec changes only continuation encoding, not filtering. The hash is not a signature — wrap the codec with application-owned protection when cursor tamper resistance is required.

The broad boxes deliberately admit false positives, while the exact predicate removes them. They use ordinary numeric columns rather than a provider spatial index. SQLite and SQL Server are exercised by Trellis integration tests; verify all translated functions and seek comparisons on other providers. Translation/connection failures propagate without a client-side fallback. Search values are parameters, so different origins/radii within the same one-box or two-box structural case reuse the query shape. Provider transcendental functions can round differently from the in-memory SinPi/CosPi path at an exact boundary; include an application tolerance in the validated radius when required.

Bounded application-computed fallback

This example uses planar Euclidean distance over a complete, bounded, authorized snapshot (at most 10,000 candidates with unique IDs). scopeSnapshotId is a server-assigned identity for that exact candidate snapshot and authorization/filter scope, never a client-supplied substitute for filtering. Production code must obtain that set through an appropriate index/search provider or bounded domain operation; do not load an unbounded table. Coordinates are finite and within ±1,000,000 in the application's units.

using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Trellis;

public sealed record DistanceCandidate(Guid Id, double X, double Y);
public sealed record DistanceItem(Guid Id, double Distance);
public sealed record DistanceBoundary(double Distance, Guid Id, string Context);

public static class DistancePagination
{
    public static Result<Page<DistanceItem>> List(
        IReadOnlyList<DistanceCandidate> authorizedSnapshot,
        string scopeSnapshotId, double originX, double originY,
        string? cursor, int? limit)
    {
        if (!ValidCoordinate(originX) || !ValidCoordinate(originY))
            return Result.Fail<Page<DistanceItem>>(Error.InvalidInput.ForField(
                field: "origin", code: "search.origin.invalid", detail: "Origin is outside the supported coordinate range."));

        var context = ContextIdentity(scopeSnapshotId, originX, originY);
        var codec = CreateCodec(context);
        return PageRequest.TryCreate(cursor, limit)
            .BindZip(request => request.Decode(codec))
            .Map((request, boundary) => BuildPage(authorizedSnapshot, originX, originY,
                request.Size, boundary, context, codec));
    }

    private static ICursorCodec<DistanceBoundary> CreateCodec(string context) =>
        CursorCodec.Map<((double Primary, Guid Secondary) Primary, string Secondary), DistanceBoundary>(
            CursorCodec.Composite(
                CursorCodec.Composite<double, Guid>(), CursorCodec.Scalar<string>()),
            state => ((state.Distance, state.Id), state.Context),
            (wire, field) =>
                double.IsFinite(wire.Primary.Primary) && wire.Primary.Primary >= 0
                && string.Equals(wire.Secondary, context, StringComparison.Ordinal)
                    ? Result.Ok(new DistanceBoundary(
                        wire.Primary.Primary, wire.Primary.Secondary, wire.Secondary))
                    : Result.Fail<DistanceBoundary>(Error.InvalidInput.ForField(
                        field: field ?? "cursor", code: "cursor.malformed",
                        detail: "Cursor distance or query context is invalid.")));

    private static Page<DistanceItem> BuildPage(
        IReadOnlyList<DistanceCandidate> candidates, double x, double y,
        PageSize size, Maybe<DistanceBoundary> boundary, string context,
        ICursorCodec<DistanceBoundary> codec)
    {
        if (candidates.Count > 10_000
            || candidates.Select(c => c.Id).Distinct().Count() != candidates.Count
            || candidates.Any(c => !ValidCoordinate(c.X) || !ValidCoordinate(c.Y)))
            throw new ArgumentException("The trusted candidate snapshot violates its bounds.", nameof(candidates));

        IEnumerable<DistanceItem> scored = candidates.Select(c =>
            new DistanceItem(c.Id, Math.Sqrt(
                ((c.X - x) * (c.X - x)) + ((c.Y - y) * (c.Y - y)))));

        if (boundary.TryGetValue(out var after))
            scored = scored.Where(item =>
                item.Distance > after.Distance
                || (item.Distance == after.Distance && item.Id.CompareTo(after.Id) > 0));

        var rows = scored.OrderBy(item => item.Distance).ThenBy(item => item.Id)
            .Take(size.Applied + 1).ToArray();

        return PageBuilder.FromOverFetch(rows, size,
            last => codec.Encode(new DistanceBoundary(last.Distance, last.Id, context)));
    }

    private static bool ValidCoordinate(double value) =>
        double.IsFinite(value) && value is >= -1_000_000 and <= 1_000_000;

    private static string ContextIdentity(string scopeSnapshotId, double x, double y)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(scopeSnapshotId);
        var canonical = string.Concat(
            "distance-v1;asc;guid-asc;",
            scopeSnapshotId.Length.ToString(CultureInfo.InvariantCulture), ":", scopeSnapshotId, ";",
            (x == 0 ? 0d : x).ToString("R", CultureInfo.InvariantCulture), ";",
            (y == 0 ? 0d : y).ToString("R", CultureInfo.InvariantCulture));
        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical)));
    }
}

What it shows.

  • CursorCodec.Composite safely nests boundary (distance, id) with query context. CursorCodec.Map gives that wire tuple a named state and validates finite, nonnegative distance and exact context identity. Validation also runs when encoding server boundaries; corrupt state throws rather than generating an unusable next token.
  • Context uses invariant round-trip coordinates (normalizing signed zero), a length-prefixed scope/snapshot identity, fixed sort directions, and an algorithm version before hashing. Include every additional filter/parameter that affects membership or ordering in the canonical identity. The hash is not a signature: a malicious client can still construct unsigned state. Protection requires a caller-owned codec wrapper, and every request must reapply authorization.
  • The boundary filter runs before Take. Ascending distance ties use ascending Guid.CompareTo, matching the in-memory ThenBy comparator; GUIDs do not have a C# > operator. A descending score needs the corresponding reversed predicate. Never round the boundary for display before encoding it.
  • PageBuilder does not search, sort, calculate distances, or decode. It calls the encoder once on the last retained row only if over-fetch finds another row. Provider-owned continuation tokens instead belong in a directly constructed Page<T>.
  • This bounded implementation recomputes and sorts candidates; it is not a constant-time/indexed-search promise. An immutable snapshot identity is an application guarantee here, not a Trellis snapshot feature. Against changing data, even exact cursors cannot promise a frozen result set.

Anti-pattern → fix. Raw JSON/base64 with unchecked distance → typed codecs plus validation; global nearest-N candidates before applying a boundary → seek before Take; timestamp/score alone → add a stable unique tie-breaker; replaying a token with another origin/filter → validate canonical context; treating cursor signing, spatial indexing, or ellipsoidal distance as built in → explicitly supply the appropriate infrastructure.

References. Primitives geographic bounds, EF geographic expressions, Core pagination, EF seek definitions, Recipe 3. Result failures propagate through Bind / Map (TRLS001); Maybe is read through guarded TryGetValue (TRLS003). No throwing parser is used for expected client-input failures.

Cross-references