ASP.NET Core Integration
Trellis.Asp maps Result, Result<T>, Result<WriteOutcome<T>>, and Result<Page<T>> to ASP.NET Core HTTP responses (status codes, Problem Details, ETags, Prefer, ranges, paginated envelopes) using the single verb ToHttpResponse(...).
Patterns Index
| Goal | Use | See |
|---|---|---|
Map Result<T> to a Minimal API response |
result.ToHttpResponse(...) / ToHttpResponseAsync(...) |
Quick start |
| Project the response body separately from the domain value | result.ToHttpResponse(body: domain => dto, configure: opts => ...) |
Body projection |
Return ActionResult<T> from an MVC controller |
.AsActionResult<T>() / .AsActionResultAsync<T>() |
MVC controllers |
Map Result<Unit> (no payload) |
Return Result.Ok() — ToHttpResponse emits 204 No Content |
Result<Unit> → 204 |
| Override an error → status mapping for one endpoint | opts.WithErrorMapping<TError>(status) / opts.WithErrorMapping(err => ...) |
Error mapping |
| Override mappings globally | AddTrellisAsp(opts => opts.MapError<TError>(status)) |
Error mapping |
Conditional GET/HEAD (If-None-Match, If-Modified-Since) |
opts.WithETag(...).EvaluatePreconditions() |
Conditional requests |
Honor Prefer: return=minimal / return=representation |
opts.HonorPrefer() on a WriteOutcome response |
Prefer header |
Emit 201 Created with a Location header |
opts.CreatedAtRoute(name, values) (AOT-safe) / Created(...) / CreatedAtAction(...) |
Created responses |
Return paginated JSON + RFC 8288 Link header |
Result<Page<T>>.ToHttpResponse(nextUrlBuilder, body) |
Pagination |
Return 206 Partial Content for byte / item ranges |
opts.WithRange(from, to, total) / opts.WithRange(selector) |
Range responses |
| Validate scalar value objects (route, query, JSON body) | AddScalarValueValidation + UseScalarValueValidation + WithScalarValueValidation |
Scalar value validation |
| Bind value objects in route segments | AddTrellisRouteConstraint<T>("Name") then "/x/{id:Name}" |
Route constraints |
Hydrate the current Actor from JWT claims |
AddClaimsActorProvider / AddEntraActorProvider / AddDevelopmentActorProvider |
Actor providers |
Use this guide when
- Your application returns
Result<T>and you need predictable HTTP status, Problem Details, and conditional-request behavior at the boundary. - You are wiring Minimal API endpoints or MVC controllers and want one verb (
ToHttpResponse) instead of aswitch-per-endpoint. - You need ETag,
If-Match/If-None-Match,Prefer, orRangesemantics that match RFC 9110 / 7240 / 8288 without hand-rolling header parsing. - You bind scalar value objects (
IScalarValue<TSelf, TPrimitive>) from routes, queries, or JSON bodies and want validation collected asError.UnprocessableContent. - You hydrate the current
Actorfrom JWT/OIDC claims for downstream authorization checks.
Surface at a glance
| Type | Purpose |
|---|---|
HttpResponseExtensions |
ToHttpResponse / ToHttpResponseAsync for Error, Result<T>, Result<WriteOutcome<T>>, Result<Page<T>> (with optional body projector). |
HttpResponseOptionsBuilder<TDomain> |
Fluent options for the generic overloads (WithETag, WithLastModified, Vary, Created/CreatedAtRoute/CreatedAtAction, EvaluatePreconditions, HonorPrefer, WithRange, WithErrorMapping, …). |
HttpResponseOptionsBuilder |
Non-generic builder for the Error overload (Vary, HonorPrefer, WithErrorMapping). |
ActionResultAdapterExtensions |
AsActionResult<T> / AsActionResultAsync<T> to wrap an IResult for MVC. |
TrellisAspOptions |
DI-registered error-type → status-code map; configure via AddTrellisAsp(opts => opts.MapError<TError>(status)). |
ETagHelper |
ParseIfMatch / ParseIfNoneMatch returning EntityTagValue[]?; IfMatchSatisfied / IfNoneMatchMatches comparison helpers. |
IfNoneMatchExtensions |
EnforceIfNoneMatchPrecondition(EntityTagValue[]?) — converts a successful result into Error.PreconditionFailed when If-None-Match: * is sent and the resource exists. |
PreferHeader |
Parse(HttpRequest) → ReturnRepresentation, ReturnMinimal, RespondAsync, Wait, HandlingStrict, HandlingLenient, HasPreferences. |
RangeRequestEvaluator / RangeOutcome |
RFC 9110 §14 Range evaluation (bytes only) returning FullRepresentation / PartialContent / NotSatisfiable. |
PartialContentHttpResult / PartialContentResult |
IResult and MVC ObjectResult companions that emit 206 Partial Content. |
PagedResponse<TResponse> / PageLink |
JSON envelope and Link header entries returned by the Result<Page<T>> overload. |
Trellis.Asp.Authorization.* |
AddClaimsActorProvider / AddEntraActorProvider / AddDevelopmentActorProvider / AddCachingActorProvider<T>. |
Trellis.Asp.ModelBinding.* |
ScalarValueModelBinder<,> / MaybeModelBinder<,> / ScalarValueModelBinderProvider. |
Trellis.Asp.Routing.* |
TrellisValueObjectRouteConstraint<T> + AddTrellisRouteConstraint<T> / AddTrellisRouteConstraints. |
Trellis.Asp.Validation.* |
ValidatingJsonConverter<,>, MaybeScalarValueJsonConverter<,>, ScalarValueValidationFilter (MVC), ScalarValueValidationEndpointFilter (Minimal API), ScalarValueValidationMiddleware, ValidationErrorsContext. |
Full signatures: trellis-api-asp.md.
Installation
dotnet add package Trellis.Asp
Trellis.Asp bundles the AOT-friendly Trellis.AspSourceGenerator.dll (attached automatically) and contains the actor providers formerly published as Trellis.Asp.Authorization.
Quick start
A composition root that wires Trellis.Asp once, then a Minimal API endpoint that returns Result<T>:
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Asp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTrellisAsp();
var app = builder.Build();
app.UseScalarValueValidation();
app.MapGet("/users/{id}", async (string id, IUserService users, CancellationToken ct) =>
(await users.GetByIdAsync(id, ct))
.ToHttpResponse(user => new UserResponse(user.Id, user.Email)))
.WithName("Users_GetById")
.WithScalarValueValidation();
app.Run();
public sealed record User(string Id, string Email);
public sealed record UserResponse(string Id, string Email);
public interface IUserService
{
Task<Result<User>> GetByIdAsync(string id, CancellationToken ct);
}
Behavior:
- Success →
200 OKwith the projectedUserResponsebody. Error.NotFound→404 Not FoundProblem Details.Error.UnprocessableContent→422 Unprocessable Contentvalidation Problem Details.- Any other failure → status from
TrellisAspOptions(default500).
Result<Unit> → 204 No Content
Side-effecting commands return Result<Unit> from Result.Ok() / Result.Fail(error). Trellis.Asp maps a successful Result<Unit> to 204 No Content automatically — no body, no projector required.
app.MapDelete("/users/{id}", async (string id, IUserService users, CancellationToken ct) =>
(await users.DeleteAsync(id, ct)).ToHttpResponse());
public interface IUserService
{
Task<Result<Unit>> DeleteAsync(string id, CancellationToken ct);
}
MVC controllers
For MVC, convert with ToHttpResponse / ToHttpResponseAsync, then adapt with AsActionResult<T> / AsActionResultAsync<T> so the action signature stays Task<ActionResult<T>> for OpenAPI / [ProducesResponseType<T>].
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Trellis;
using Trellis.Asp;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddControllers()
.AddScalarValueValidation();
builder.Services.AddTrellisAsp();
var app = builder.Build();
app.UseScalarValueValidation();
app.MapControllers();
app.Run();
[ApiController]
[Route("users")]
public sealed class UsersController(IUserService users) : ControllerBase
{
[HttpGet("{id}", Name = nameof(GetById))]
[ProducesResponseType<UserResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
public Task<ActionResult<UserResponse>> GetById(string id, CancellationToken ct) =>
users.GetByIdAsync(id, ct)
.ToHttpResponseAsync(user => new UserResponse(user.Id, user.Email))
.AsActionResultAsync<UserResponse>();
[HttpPost]
[ProducesResponseType<UserResponse>(StatusCodes.Status201Created)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status422UnprocessableEntity)]
public Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken ct) =>
users.CreateAsync(request, ct)
.ToHttpResponseAsync(
body: user => new UserResponse(user.Id, user.Email),
configure: opts => opts.CreatedAtRoute(
nameof(GetById),
user => new RouteValueDictionary { ["id"] = user.Id }))
.AsActionResultAsync<UserResponse>();
}
public sealed record User(string Id, string Email);
public sealed record CreateUserRequest(string Email);
public sealed record UserResponse(string Id, string Email);
public interface IUserService
{
Task<Result<User>> GetByIdAsync(string id, CancellationToken ct);
Task<Result<User>> CreateAsync(CreateUserRequest request, CancellationToken ct);
}
Note
AsActionResult<T> only exists for the generic ActionResult<T> shape. For value-less responses, return IResult directly from a Minimal API endpoint or use IActionResult in MVC.
Body projection
Every generic ToHttpResponse overload accepts an optional body: Func<TDomain, TBody> projector. The selectors in the options builder (WithETag, WithLastModified, Created(selector), CreatedAtRoute(values), WithContentLocation, WithRange(selector)) always run against the domain value, not the projected body. This keeps response DTOs free of representation concerns:
return result.ToHttpResponse(
body: product => new ProductResponse(product.Id.Value, product.Name.Value, product.Price.Value),
configure: opts => opts
.WithETag(product => product.ETag)
.CreatedAtRoute("Products_GetById", product => new RouteValueDictionary { ["id"] = product.Id.Value }));
There is no WithBody(...) builder method. Pass the projector as the second positional argument to ToHttpResponse instead.
Error mapping
Defaults are sourced from TrellisAspOptions:
Error type |
Default status |
|---|---|
Error.BadRequest |
400 |
Error.Unauthorized |
401 |
Error.Forbidden |
403 |
Error.NotFound |
404 |
Error.MethodNotAllowed |
405 |
Error.NotAcceptable |
406 |
Error.Conflict |
409 |
Error.Gone |
410 |
Error.PreconditionFailed |
412 |
Error.ContentTooLarge |
413 |
Error.UnsupportedMediaType |
415 |
Error.RangeNotSatisfiable |
416 |
Error.UnprocessableContent |
422 |
Error.PreconditionRequired |
428 |
Error.TooManyRequests |
429 |
Error.InternalServerError / Error.Unexpected |
500 |
Error.NotImplemented |
501 |
Error.ServiceUnavailable |
503 |
Override globally:
builder.Services.AddTrellisAsp(opts =>
{
opts.MapError<Error.Conflict>(StatusCodes.Status400BadRequest);
});
Override per call (highest precedence first):
return result.ToHttpResponse(
body: order => new OrderResponse(order.Id),
configure: opts => opts
.WithErrorMapping<Error.Conflict>(StatusCodes.Status409Conflict)
.WithErrorMapping(err => err is Error.NotFound ? StatusCodes.Status410Gone : default));
Resolution order: WithErrorMapping(Func<Error,int>) → WithErrorMapping<TError>(int) → TrellisAspOptions.MapError<TError>(int) → 500.
Problem Details output
Failures are emitted as application/problem+json. Companion headers are added automatically:
| Error | Companion header |
|---|---|
Error.MethodNotAllowed |
Allow |
Error.TooManyRequests / Error.ServiceUnavailable |
Retry-After (when configured) |
Error.RangeNotSatisfiable |
Content-Range: {Unit} */{CompleteLength} |
Extensions always carry code and kind. Error.InternalServerError adds faultId. Rule violations land under rules. For any 5xx, Detail is replaced with "An internal error occurred." so internal diagnostics never leak.
Error.UnprocessableContent is routed to Results.ValidationProblem(...):
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"title": "One or more validation errors occurred.",
"status": 422,
"code": "unprocessable-content",
"kind": "unprocessable-content",
"errors": {
"email": ["Email is required"]
}
}
The errors dictionary keys are each FieldViolation.Field.Path with the leading / trimmed; the values are Detail ?? ReasonCode.
Created responses
Three options for 201 Created. Pick by your AOT requirement and the link source.
| Builder | Location source | AOT-safe |
|---|---|---|
Created(string locationLiteral) |
Caller-supplied literal | Yes |
Created(Func<TDomain, string> selector) |
Selector over the domain value | Yes |
CreatedAtRoute(string routeName, Func<TDomain, RouteValueDictionary> routeValues) |
LinkGenerator.GetUriByName (resolved at execute time) |
Yes |
CreatedAtAction(string actionName, Func<TDomain, RouteValueDictionary> routeValues, string? controllerName = null) |
LinkGenerator.GetUriByAction |
No — [RequiresUnreferencedCode] / [RequiresDynamicCode] |
app.MapPost("/products", async (CreateProduct cmd, IProductWriter writer, CancellationToken ct) =>
(await writer.CreateAsync(cmd, ct)).ToHttpResponse(
body: product => new ProductResponse(product.Id.Value, product.Name.Value),
configure: opts => opts.CreatedAtRoute(
"Products_GetById",
product => new RouteValueDictionary { ["id"] = product.Id.Value })))
.WithName("Products_Create");
Warning
Under query-string API versioning, CreatedAtRoute route values must include ["api-version"] = ApiVersion. Otherwise the emitted Location header omits the version and 404s on dereference (the response itself looks correct).
WriteOutcome<T>
When commands return Result<WriteOutcome<T>>, the response is RFC 9110-shaped from the variant:
WriteOutcome<T> |
Status |
|---|---|
Created(value) |
201 Created (plus Location from Created/CreatedAtRoute/CreatedAtAction) |
Updated(value) |
200 OK (or 204 No Content with Prefer: return=minimal and HonorPrefer()) |
UpdatedNoContent |
204 No Content |
Accepted(value) |
202 Accepted (with Retry-After when configured) |
AcceptedNoContent |
202 Accepted |
app.MapPut("/orders/{id:guid}", async (
Guid id,
UpdateOrderRequest request,
IOrderService orders,
CancellationToken ct) =>
(await orders.UpdateAsync(id, request, ct)).ToHttpResponse(
body: order => new OrderResponse(order.Id, order.Total),
configure: opts => opts
.WithETag(order => order.ETag)
.HonorPrefer()));
Conditional requests
EvaluatePreconditions() runs only on GET / HEAD and only when at least one selector (WithETag / WithLastModified) is configured. Evaluation order (RFC 9110): If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since. Failed If-Match / If-Unmodified-Since → 412; failed If-None-Match / If-Modified-Since on GET/HEAD → 304.
app.MapGet("/products/{id:guid}", async (Guid id, IProductReader reader, CancellationToken ct) =>
(await reader.GetAsync(id, ct)).ToHttpResponse(
body: product => new ProductResponse(product.Id, product.Name, product.Price, product.ETag),
configure: opts => opts
.WithETag(product => product.ETag)
.WithLastModified(product => product.UpdatedAt)
.EvaluatePreconditions()));
For unsafe methods (PUT, POST), evaluate preconditions before the mutation. Use the typed parsers from ETagHelper:
using Trellis.Asp;
EntityTagValue[]? ifMatch = ETagHelper.ParseIfMatch(httpContext.Request);
EntityTagValue[]? ifNoneMatch = ETagHelper.ParseIfNoneMatch(httpContext.Request);
ParseIfMatch returns null (header absent), [] (present but empty / weak-only — strong-only enforcement), the wildcard, or the parsed strong tags. ParseIfNoneMatch returns null, [], the wildcard, or the parsed strong/weak tags.
The aggregate-side concurrency helpers OptionalETag(...) / RequireETag(...) consume EntityTagValue[]?. They live in Trellis.Core — see trellis-api-core.md.
For "create only if absent" flows (PUT / POST with If-None-Match: *), use EnforceIfNoneMatchPrecondition:
using Trellis.Asp;
var guarded = result.EnforceIfNoneMatchPrecondition(
ETagHelper.ParseIfNoneMatch(httpContext.Request));
When the header contains * and the result is currently a success, it is replaced with Error.PreconditionFailed (PreconditionKind.IfNoneMatch). No-op otherwise.
Prefer header
HonorPrefer() honors RFC 7240 Prefer: return=minimal / return=representation on a WriteOutcome response. It always emits Vary: Prefer; Preference-Applied is emitted only when Trellis honored a preference.
| Sent header | Effect on WriteOutcome.Updated |
|---|---|
Prefer: return=minimal |
204 No Content + Preference-Applied: return=minimal |
Prefer: return=representation |
200 OK with body + Preference-Applied: return=representation |
| (none) | 200 OK with body (no Preference-Applied) |
For raw access to the parsed header:
using Trellis.Asp;
var prefer = PreferHeader.Parse(httpContext.Request);
if (prefer.ReturnMinimal) { /* … */ }
Note
PreferHeader.HasPreferences is true only when at least one recognized standard preference (return, respond-async, wait, handling) was parsed. Unknown tokens do not set it.
Pagination
The Result<Page<T>> overload always emits a PagedResponse<TBody> JSON envelope. The RFC 8288 Link header is added only when Page.Next and/or Page.Previous cursors are present.
app.MapGet("/products", async (
string? cursor,
int? limit,
IProductReader reader,
HttpContext ctx,
CancellationToken ct) =>
(await reader.ListAsync(cursor, limit ?? 50, ct)).ToHttpResponse(
nextUrlBuilder: (next, applied) =>
$"{ctx.Request.Scheme}://{ctx.Request.Host}/products?cursor={next.Token}&limit={applied}",
body: product => new ProductResponse(product.Id, product.Name)));
public sealed record ProductResponse(string Id, string Name);
public interface IProductReader
{
Task<Result<Page<Product>>> ListAsync(string? cursor, int limit, CancellationToken ct);
}
Failure on the page result short-circuits through the standard error pipeline (Problem Details, default mapping).
Range responses
WithRange(from, to, totalLength) emits 200 OK when the configured range covers the whole representation; otherwise 206 Partial Content with Content-Range. The static-range overload clamps to to totalLength - 1.
app.MapGet("/products", async (IProductReader reader, int? page, int? pageSize, CancellationToken ct) =>
{
var size = Math.Clamp(pageSize ?? 25, 1, 100);
var number = Math.Max(page ?? 0, 0);
var from = number * size;
var (items, total) = await reader.ListWithCountAsync(from, size, ct);
if (items.Length == 0) return Result.Ok(items).ToHttpResponse();
var to = from + items.Length - 1;
return Result.Ok(items).ToHttpResponse(opts => opts.WithRange(from, to, total));
});
For byte ranges with full RFC 9110 semantics, evaluate Range yourself with RangeRequestEvaluator.Evaluate(request, completeLength) and switch on RangeOutcome. RangeRequestEvaluator returns FullRepresentation for non-GET, missing Range, non-bytes units, multi-range, malformed, or empty ranges; only well-formed satisfiable single byte ranges produce PartialContent.
Scalar value validation
Trellis.Asp validates value objects implementing IScalarValue<TSelf, TPrimitive> at every binding site (route, query, JSON body) and surfaces the result as Error.UnprocessableContent.
| Host | Required wiring |
|---|---|
| MVC controllers | services.AddControllers().AddScalarValueValidation(); + app.UseScalarValueValidation(); |
| Minimal API | services.AddScalarValueValidationForMinimalApi(); + app.UseScalarValueValidation(); + .WithScalarValueValidation() per endpoint |
| Either | services.AddTrellisAsp(); (registers TrellisAspOptions and chains AddScalarValueValidation() for both MVC and Minimal API JSON pipelines) |
Important
The IServiceCollection-receiver AddScalarValueValidation() only configures shared JSON support. For MVC apps you still need AddControllers().AddScalarValueValidation() so the ScalarValueValidationFilter and ScalarValueModelBinderProvider are registered.
Maybe<T> rules:
- Omitted or JSON
null→Maybe<T>.None. - Valid value →
Maybe.From(value). - Invalid value (fails
TValue.TryCreate) → validation error collected inValidationErrorsContext; the request short-circuits with a validation Problem Details before the handler runs.
using Trellis;
using Trellis.Primitives;
public sealed record UpdateCustomerRequest(
FirstName Name,
Maybe<PhoneNumber> Phone,
Maybe<Url> Website);
Warning
AddTrellisAsp / AddScalarValueValidation only wire scalar VO converters. Composite value objects (multi-field [OwnedEntity] types like ShippingAddress, Money) need an explicit [JsonConverter(typeof(CompositeValueObjectJsonConverter<MyVo>))] on the type; otherwise model binding silently bypasses TryCreate and an invalid payload reaches the domain layer.
Route constraints
Bind value objects (any IParsable<T>) directly from a route segment.
using Trellis.Asp;
// AOT-safe — explicit registration per type
services.AddTrellisRouteConstraint<ProductId>("ProductId");
// Reflection-based — scans the calling assembly + Trellis.Core for IScalarValue<,> + IParsable<>
services.AddTrellisRouteConstraints();
app.MapGet("/products/{id:ProductId}", (ProductId id) => Results.Ok(id));
AddTrellisRouteConstraints is reflection-based and not Native AOT compatible; AddTrellisRouteConstraint<T> is AOT-safe.
Actor providers
Trellis.Asp.Authorization hydrates the current Actor from JWT/OIDC claims. The Actor and IActorProvider types themselves live in Trellis.Authorization.
using Trellis.Asp.Authorization;
services.AddClaimsActorProvider(opts =>
{
opts.ActorIdClaim = "sub";
opts.PermissionsClaim = "permissions";
});
services.AddEntraActorProvider(opts =>
{
opts.MapPermissions = claims => claims
.Where(c => string.Equals(c.Type, "roles", StringComparison.OrdinalIgnoreCase))
.Select(c => c.Value)
.ToHashSet();
});
if (env.IsDevelopment())
{
services.AddDevelopmentActorProvider(opts =>
{
opts.DefaultActorId = "development";
opts.DefaultPermissions = new HashSet<string> { "orders:read", "orders:create" };
});
}
// Wraps the previously registered IActorProvider with per-request caching:
services.AddCachingActorProvider<EntraActorProvider>();
DevelopmentActorProvider throws InvalidOperationException outside the Development environment regardless of header presence; in Development it reads the X-Test-Actor header (JSON: { "Id", "Permissions", "ForbiddenPermissions", "Attributes" }, case-insensitive). See trellis-api-testing-aspnetcore.md for WebApplicationFactory.CreateClientWithActor.
Composition
Once the application returns Result<T> / Result<WriteOutcome<T>>, every cross-cutting concern composes through the same ToHttpResponse call — there is no per-endpoint switch to keep in sync with the error catalog.
app.MapPut("/products/{id:guid}", async (
Guid id,
UpdateProductRequest request,
IProductService products,
HttpContext httpContext,
CancellationToken ct) =>
{
var ifMatch = ETagHelper.ParseIfMatch(httpContext.Request);
var ifNoneMatch = ETagHelper.ParseIfNoneMatch(httpContext.Request);
return await products.UpdateAsync(id, request, ifMatch, ct)
.EnforceIfNoneMatchPreconditionAsync(ifNoneMatch)
.ToHttpResponseAsync(
body: product => new ProductResponse(product.Id, product.Name, product.Price, product.ETag),
configure: opts => opts
.WithETag(product => product.ETag)
.HonorPrefer()
.WithErrorMapping<Error.Conflict>(StatusCodes.Status409Conflict));
});
When you genuinely need a custom payload shape (non-Problem-Details body, endpoint-specific JSON, extra cookies), reach for MatchAsync from Trellis.Core instead of ToHttpResponse. Treat that as the exception, not the rule.
Practical guidance
- Convert at the API boundary only. Keep
Result<T>flowing through your application layer; convert toIResult/ActionResult<T>exactly once, at the endpoint. AddTrellisAsp()is the one-call setup. It registersTrellisAspOptionsand configures both the MVC and Minimal API JSON pipelines for scalar-value /Maybe<T>deserialization. You still needUseScalarValueValidation()middleware and (for Minimal APIs)WithScalarValueValidation()per endpoint.- Document failure status codes. Add
[ProducesResponseType<ProblemDetails>(...)]for every spec-listed failure status (422,409,403,404, …). TheIEndpointMetadataProvideron Trellis result types already declares the union of statuses the writer can emit (200,201,206,304,400,404,412,500); layer your spec-specific metadata on top. Result<Unit>for side-effect commands. A successfulResult<Unit>produces204 No Contentwith no body.- Use typed ETag parsers.
ETagHelper.ParseIfMatch/ParseIfNoneMatchreturnEntityTagValue[]?, which feedsOptionalETag/RequireETag(Core) andEnforceIfNoneMatchPrecondition(Asp) directly. - Versioned
Locationheaders. Under query-string API versioning, everyCreatedAtRoutecall must include["api-version"] = ApiVersionin the route values, otherwise theLocation404s on dereference and tests still pass. - Avoid controller-level
[Consumes("application/json")]. Trigger-style POSTs without bodies (e.g.,POST /orders/{id}/submission) return415for any request without aContent-Type. Apply[Consumes]per body-bearing action. - Prefer
CreatedAtRouteoverCreatedAtActionfor trim/AOT scenarios;CreatedAtActionis annotated[RequiresUnreferencedCode]/[RequiresDynamicCode]. - Prove
422mapping in integration tests. Exception middleware does not map TrellisResultfailures — assert at least one business-validation failure surfaces as422Problem Details end-to-end.
Cross-references
- API surface:
trellis-api-asp.md Result,Result<T>,Error,WriteOutcome<T>,Page<T>,EntityTagValue,OptionalETag/RequireETag:trellis-api-core.mdActor,IActorProvider,IAuthorize:trellis-api-authorization.mdIScalarValue<TSelf, TPrimitive>,Maybe<T>, ready-to-use value objects:trellis-api-primitives.md- Integration-test helpers (
CreateClientWithActorforX-Test-Actor):trellis-api-testing-aspnetcore.md - Composite value object end-to-end pattern (Recipe 13):
trellis-api-cookbook.md - HTTP client side (consuming results):
integration-http.md - FluentValidation pipeline:
integration-fluentvalidation.md - EF Core integration (
FirstOrDefaultResultAsync,SaveChangesResultUnitAsync):integration-ef.md