ToHttpResponse — Unified Response Verb
ToHttpResponse(...) is the single Trellis verb for translating Result, Result<T>, Result<WriteOutcome<T>>, and Result<Page<T>> into ASP.NET Core responses, returning Microsoft.AspNetCore.Http.IResult for both Minimal API and MVC hosts.
Patterns Index
| Goal | Use | See |
|---|---|---|
Map Result<T> to 200 OK (or mapped Problem Details on failure) |
result.ToHttpResponse() / await ...ToHttpResponseAsync() |
Quick start |
| Send no body on success | Return Result<Unit> (Result.Ok()) — emits 204 No Content |
Basic mapping |
| Project the response body separately from the domain value | result.ToHttpResponse(body: domain => dto, configure: opts => ...) |
Body projection |
Map Result<WriteOutcome<T>> to RFC 9110 write semantics |
result.ToHttpResponse(opts => opts.CreatedAtRoute(...)) |
Created responses, WriteOutcome variants |
Wrap as MVC ActionResult<T> |
Chain .AsActionResult<T>() / .AsActionResultAsync<T>() |
MVC adapter |
Add ETag / Last-Modified and honor conditional GET/HEAD |
opts.WithETag(...).WithLastModified(...).EvaluatePreconditions() |
ETag and conditional requests |
Set per-endpoint Cache-Control |
opts.WithCacheControl(CacheControl.NoStore()) / opts.WithCacheControl(CacheControl.Public(TimeSpan.FromMinutes(5))) / opts.WithCacheControl(t => …) |
Cache-Control |
Honor Prefer: return=minimal / return=representation |
opts.HonorPrefer() |
Prefer header |
Parse pagination query input without collapsing ?cursor= into absence |
Request.TryCreatePageRequest() |
Pagination |
Return paginated JSON with RFC 8288 Link header |
result.ToHttpResponse(nextUrlBuilder, body) on Result<Page<T>> |
Pagination |
| Override error → status mapping for one endpoint | opts.WithErrorMapping<TError>(status) / opts.WithErrorMapping(err => ...) |
Per-call error mapping |
Render a standalone Error (no Result pipeline) |
error.ToHttpResponse(...) |
Standalone error |
Use this guide when
- You are writing endpoint code and need the exact overload, parameter order, or builder method for
ToHttpResponse. - You want to confirm what each
HttpResponseOptionsBuilder<T>method does without scanning the API reference. - You need to know which HTTP status / headers a given
Resultshape produces. - You are migrating from a v1 ASP integration that used the legacy result-mapping helpers — see migration.md.
For broader ASP.NET integration topics (Problem Details rendering, scalar validation, route constraints, actor providers, controller examples), see integration-aspnet.md.
Surface at a glance
| Receiver | Overload (simplified) | Success status | Failure |
|---|---|---|---|
Error |
error.ToHttpResponse(configure?) |
n/a | Problem Details |
Result<T> |
result.ToHttpResponse(configure?) |
200 (or 201 if Created* configured); 204 for Result<Unit> |
Problem Details |
Result<T> |
result.ToHttpResponse(body, configure?) |
Same as above; body is body(domain) |
Problem Details |
Result<WriteOutcome<T>> |
result.ToHttpResponse(configure?) |
Variant-driven (Created → 201, Updated → 200/204, UpdatedNoContent → 204, Accepted → 202, AcceptedNoContent → 202) |
Problem Details |
Result<WriteOutcome<T>> |
result.ToHttpResponse(body, configure?) |
Same; projected body | Problem Details |
Result<Page<T>> |
result.ToHttpResponse(nextUrlBuilder, body, configure?) |
200 with PagedResponse<TBody> envelope + RFC 8288 Link |
Problem Details |
Each overload also exposes Task<...> and ValueTask<...> async variants named ToHttpResponseAsync with identical signatures. The configure delegate runs against HttpResponseOptionsBuilder<TDomain> (or the non-generic HttpResponseOptionsBuilder for the Error overload).
Full signatures: trellis-api-asp.md.
HttpResponseOptionsBuilder<TDomain> — chainable surface
| Method | Effect |
|---|---|
WithETag(Func<T, string>) / WithETag(Func<T, EntityTagValue>) |
Emits ETag; the string overload wraps as EntityTagValue.Strong. |
WithLastModified(Func<T, DateTimeOffset>) |
Emits Last-Modified in RFC 1123 format. |
Vary(params string[]) |
Appends to Vary (preserves existing values; case-insensitive dedupe). |
WithContentLanguage(params string[]) / WithContentLocation(Func<T, string>) |
Sets the matching response header. |
WithCacheControl(CacheControlHeaderValue) |
Sets Cache-Control on success (200 / 201 / 204 / 304 / WriteOutcome / paged) and on failure responses, so a sensitive endpoint declaring WithCacheControl(CacheControl.NoStore()) protects 404 / 403 / 412 / 422 from intermediate-cache leakage just as much as the 200. Throws ArgumentNullException on null. Use the CacheControl presets (NoStore(), NoCache(), Public(TimeSpan), Private(TimeSpan), Immutable(TimeSpan)) for common shapes. |
WithCacheControl(Func<T, CacheControlHeaderValue?>) |
Per-domain selector — success path only (failures carry no domain value, and no-payload write outcomes like UpdatedNoContent / AcceptedNoContent also skip the selector since they carry no T). Returning null from the selector skips the per-domain header; when the static-value overload is also configured, the static value remains in place. |
Created(string literal) / Created(Func<T, string>) |
201 Created with literal or value-derived Location. |
CreatedAtRoute(name, Func<T, RouteValueDictionary>) |
201 Created with relative Location via LinkGenerator.GetPathByName. AOT-safe. |
CreatedAtAction(action, Func<T, RouteValueDictionary>, controller?) |
MVC CreatedAtAction equivalent. Not trim/AOT-safe — RequiresUnreferencedCode / RequiresDynamicCode. |
WithLocation(name, Func<T, RouteValueDictionary>) |
Adds a named-route Location without changing the normal 2xx status. |
WithRouteValueResolver(key, Func<HttpContext, string?>) |
Applies request-based values after the domain selector; null preserves the existing value. |
WithLocationRouteResolver(Action<LocationRouteContext>) |
Optional destination-aware callback after the selector and all legacy resolvers. Receives the final destination and a mutable per-execution copy; last registration wins and errors propagate. |
EvaluatePreconditions() |
On GET/HEAD, evaluates If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since against the configured ETag/Last-Modified. Not on by default. |
HonorPrefer() |
Honors RFC 7240 Prefer: return=minimal / return=representation. Always emits Vary: Prefer; emits Preference-Applied only when honored. Not on by default. |
WithErrorMapping(Func<Error, int>) |
Per-call error → status mapper. Highest precedence. |
WithErrorMapping<TError>(int status) |
Per-call override for a single error type. |
The non-generic HttpResponseOptionsBuilder (for error.ToHttpResponse(...)) only exposes Vary, HonorPrefer, the static-value WithCacheControl(CacheControlHeaderValue) overload, and the two WithErrorMapping methods.
Note
The options builder has no body method. To project the response body, use the body parameter of the ToHttpResponse<TDomain, TBody> overload. Selectors in the options builder always run against the TDomain value, not the projected body.
Installation
dotnet add package Trellis.Asp
Register defaults in Program.cs:
builder.Services.AddTrellisAspWithScalarValidation();
Quick start
A read endpoint, in both API styles. Same Result pipeline, same verb.
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Trellis;
using Trellis.Asp;
public sealed record TodoDto(Guid Id, string Title);
// Minimal API
app.MapGet("/todos/{id:guid}", async (Guid id, ITodoService svc, CancellationToken ct) =>
await svc.GetAsync(id, ct).ToHttpResponseAsync());
// MVC controller
[ApiController]
[Route("todos")]
public sealed class TodosController(ITodoService svc) : ControllerBase
{
[HttpGet("{id:guid}")]
public Task<ActionResult<TodoDto>> Get(Guid id, CancellationToken ct) =>
svc.GetAsync(id, ct)
.ToHttpResponseAsync()
.AsActionResultAsync<TodoDto>();
}
ToHttpResponseAsync returns Task<IResult>; AsActionResultAsync<T> adapts it to ActionResult<T> for MVC signatures.
Basic mapping
| Receiver shape | Success status | Body |
|---|---|---|
Result<T> (no Created*) |
200 OK |
T (or body(domain) if projecting) |
Result<T> (with Created*) |
201 Created + Location |
T |
Result<Unit> |
204 No Content |
none |
Result<WriteOutcome<T>> |
Variant-driven (see WriteOutcome variants) | Variant-driven |
Result<Page<T>> (paginated overload) |
200 OK + Link |
PagedResponse<TBody> |
Any Fail(error) |
Mapped via TrellisAspOptions (or per-call override) |
application/problem+json |
Use Result<Unit> for no-payload commands:
app.MapDelete("/todos/{id:guid}", async (Guid id, ITodoService svc, CancellationToken ct) =>
await svc.DeleteAsync(id, ct).ToHttpResponseAsync()); // 204 on Ok, Problem Details on Fail
Body projection
Pass body as the second positional argument to project a DTO without losing access to the domain value for header selectors.
public sealed record TodoView(Guid Id, string Title, string ETag);
app.MapGet("/todos/{id:guid}", async (Guid id, ITodoService svc, CancellationToken ct) =>
await svc.GetAsync(id, ct).ToHttpResponseAsync(
body: t => new TodoView(t.Id, t.Title, $"{t.Version}"),
configure: opts => opts.WithETag(t => $"{t.Version}")));
Selectors (WithETag, WithLastModified, Created(Func<T, string>), etc.) always receive the domain value, not the projected body.
ETag and conditional requests
EvaluatePreconditions() opts in to RFC 9110 evaluation on safe methods (GET, HEAD). Pair it with WithETag and/or WithLastModified:
app.MapGet("/todos/{id:guid}", async (Guid id, ITodoService svc, CancellationToken ct) =>
await svc.GetAsync(id, ct).ToHttpResponseAsync(opts => opts
.WithETag(t => $"{t.Version}")
.WithLastModified(t => t.UpdatedAt)
.EvaluatePreconditions()));
Evaluation order (per ConditionalRequestEvaluator): If-Match → If-Unmodified-Since → If-None-Match → If-Modified-Since. Failed If-Match / If-Unmodified-Since → 412 Precondition Failed; failed If-None-Match / If-Modified-Since on GET/HEAD → 304 Not Modified. On unsafe methods, evaluate the precondition before the mutation (see integration-aspnet.md).
Cache-Control
WithCacheControl(...) declares an RFC 9111 Cache-Control directive on the response. The builder accepts a System.Net.Http.Headers.CacheControlHeaderValue directly; the framework-provided CacheControl presets cover the common shapes:
app.MapGet("/me", [Authorize] (IActorProvider provider, CancellationToken ct) =>
provider.GetCurrentActorAsync(ct).ToHttpResponseAsync(o => o
.WithCacheControl(CacheControl.NoStore()) // identity data — keep out of intermediates
.VaryForActor()));
| Preset | Emits | Use when |
|---|---|---|
CacheControl.NoStore() |
no-store |
Per-user identity, secrets, or anything that must not be cached anywhere. |
CacheControl.NoCache() |
no-cache |
Caches may store the response but must revalidate before serving. |
CacheControl.Public(TimeSpan) |
public, max-age={seconds} |
Public catalog / reference data; shared caches may serve any consumer for the lifetime. |
CacheControl.Private(TimeSpan) |
max-age={seconds}, private |
Per-user data safe to cache in the user agent only. Combine with VaryForActor() if any intermediate is in the path. |
CacheControl.Immutable(TimeSpan) |
public, max-age={seconds}, immutable |
RFC 8246 — the response will not change for the freshness lifetime, so clients should not revalidate. Use for versioned / content-addressed assets. |
Each preset returns a fresh CacheControlHeaderValue, so consumer-side mutation cannot leak across responses. Timed presets reject negative durations.
The static-value overload applies to failures too. WithCacheControl(value) sets the header before the success/failure branch, so a sensitive endpoint declaring WithCacheControl(CacheControl.NoStore()) protects its 404 / 403 / 422 responses just as much as its 200 — a leaked 404 from /api/users/{id} reveals which user IDs exist and must not be cached.
The selector overload is success-only. WithCacheControl(Func<T, CacheControlHeaderValue?>) runs against the success-path domain value. It does not fire on failure responses (no domain), and it does not fire on WriteOutcome.UpdatedNoContent / WriteOutcome.AcceptedNoContent (no payload). Returning null from the selector skips the per-domain header; when the static-value overload is also configured, the static value remains in place (the selector "refines, then falls back to static" rather than "overrides to nothing").
For directives outside the preset set (s-maxage, must-revalidate, stale-while-revalidate via Extensions, etc.) pass a hand-built CacheControlHeaderValue:
opts.WithCacheControl(new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromMinutes(5),
SharedMaxAge = TimeSpan.FromMinutes(15),
MustRevalidate = true,
});
Cache-Control and VaryForActor() are orthogonal. Cache-Control says "is this cacheable, and for how long"; Vary says "by which request dimensions does the cache key vary." Per-user representations behind a shared cache combine both: opts.WithCacheControl(CacheControl.Private(TimeSpan.FromMinutes(5))).VaryForActor().
Prefer header
HonorPrefer() is meaningful on Result<WriteOutcome<T>> updates: Prefer: return=minimal short-circuits Updated → 204 No Content; return=representation returns 200 OK with the body. Vary: Prefer is always emitted when honored; Preference-Applied is emitted only when the preference was honored.
app.MapPut("/todos/{id:guid}", async (Guid id, UpdateTodo cmd, ITodoService svc, CancellationToken ct) =>
await svc.UpdateAsync(id, cmd, ct).ToHttpResponseAsync(opts => opts
.WithETag(t => $"{t.Version}")
.HonorPrefer()));
Created responses
| Configuration | Result |
|---|---|
opts.Created("/orders/123") |
201 Created, literal Location. |
opts.Created(o => $"/orders/{o.Id}") |
201 Created, value-derived Location. |
opts.CreatedAtRoute("Orders_GetById", o => new RouteValueDictionary { ["id"] = o.Id }) |
201 Created, relative link via LinkGenerator.GetPathByName. AOT-safe. |
opts.CreatedAtAction("GetById", o => new RouteValueDictionary { ["id"] = o.Id }) |
201 Created, MVC link. Not trim/AOT-safe. |
app.MapPost("/orders", async (CreateOrder cmd, IOrderService svc, CancellationToken ct) =>
await svc.CreateAsync(cmd, ct).ToHttpResponseAsync(opts => opts
.CreatedAtRoute("Orders_GetById", o => new RouteValueDictionary { ["id"] = o.Id })))
.WithName("Orders_Create");
Important
Links to query/header-versioned destinations need an accepted api-version value. Prefer .WithVersionedRoute() from Trellis.Asp.ApiVersioning to resolve the target's mapped query/segment version rather than hard-coding values; neutral/unversioned targets do not need a version. TRLS023 catches missed chaining on CreatedAtRoute(...), CreatedAtAction(...), and WithLocation(...), and its code fix appends the call.
WithVersionedRoute() now resolves the final destination, not the current endpoint. Requested, fallback, and pinned versions must map to that destination; [MapToApiVersion] narrows controller versions. Missing/ambiguous targets or unsupported pins throw InvalidOperationException (prefer a uniquely named route). URL-segment destinations receive the resolved/pinned value in their actual :apiVersion parameter, without a duplicate api-version query entry. Neutral/missing-metadata targets remove supplied api-version; missing-metadata warnings now identify and deduplicate by destination.
The extension uses WithLocationRouteResolver after all legacy callbacks; it overrides version values on a cloned dictionary without mutating shared selector values. The last destination-aware callback registration wins, including repeated WithVersionedRoute calls. Literal/selector Created(...) and WriteOutcome-owned URIs are unaffected. Existing syntax, statuses, and action-link AOT limitations remain. See the migration guidance. PageUrl shares the corrected mapping checks but retains ambient segment routing, consumer overrides, and rejection of explicit segment pins.
WriteOutcome<T> variants
When the receiver is Result<WriteOutcome<T>>, the outcome variant drives status and headers — Created* builder methods are not required (they are required for Result<T> create endpoints). See integration-aspnet.md → WriteOutcome<T> for the full mapping table and command examples.
WriteOutcome.Created(value) permits a 201 with no Location, suitable for a PUT creating
at its request URL. A nonblank outcome Location wins; null, empty, or whitespace falls
back to the builder's Created, CreatedAtRoute, CreatedAtAction, or WithLocation.
The outcome still determines status, including 201 when WithLocation supplies the fallback.
Route/action fallbacks run route-value and API-versioning hooks. An unresolved configured
fallback returns response.location-unresolved (500 by default); callback exceptions
propagate. Updated and Accepted outcomes do not use these fallbacks.
Pagination
Result<Page<T>> has a dedicated overload requiring nextUrlBuilder and a per-item body projector. The pagination signature is not the same as the Result<T> overload:
| Parameter | Type | Notes |
|---|---|---|
nextUrlBuilder |
Func<Cursor, int, string> |
Receives the cursor and the applied page limit; returns an absolute URL. Used for both next and prev links in the RFC 8288 Link header. |
body |
Func<T, TBody> |
Per-item projector (not per-page). Each Page<T> item is mapped to TBody for the envelope. |
app.MapGet("/todos", async (ITodoService svc, HttpRequest req, CancellationToken ct) =>
{
Result<Page<Todo>> page = await req.TryCreatePageRequest()
.BindAsync(request => svc.ListAsync(request, ct));
return page.ToHttpResponse(
nextUrlBuilder: (c, n) =>
$"{req.Scheme}://{req.Host}{req.Path}?cursor={Uri.EscapeDataString(c.Token)}&limit={n}",
body: t => new TodoDto(t.Id, t.Title));
}).WithInputOrigin(InputLocation.Query);
TryCreatePageRequest() reads raw query values so a missing cursor remains distinct from
?cursor=, rejects repeated values, and returns query-located coded failures. It does not add
ApiExplorer/OpenAPI parameter metadata; document cursor and limit separately. The declared
query origin keeps a later opaque-cursor decode failure query-located.
Per-call error mapping
Override the global TrellisAspOptions defaults for a single endpoint. Per-call overrides have higher precedence than the global registration.
// Global default at composition root:
builder.Services.AddTrellisAsp(opts => opts.MapError<Error.Conflict>(StatusCodes.Status409Conflict));
// Per-call override for one endpoint:
app.MapGet("/legacy/{id:guid}", async (Guid id, ITodoService svc, CancellationToken ct) =>
await svc.GetAsync(id, ct).ToHttpResponseAsync(opts => opts
.WithErrorMapping<Error.NotFound>(StatusCodes.Status410Gone)));
For Problem Details payload rules and the full default mapping table, see integration-aspnet.md → Error mapping and integration-aspnet.md → Problem Details output.
Standalone error
For diagnostic / fault-demo endpoints that produce an Error without a Result pipeline:
app.MapGet("/diagnostics/throttle",
() => new Error.RateLimited("rate-limit-policy") { Detail = "10 req/min" }
.ToHttpResponse(opts => opts.WithErrorMapping<Error.RateLimited>(StatusCodes.Status429TooManyRequests)));
The non-generic HttpResponseOptionsBuilder exposes Vary, HonorPrefer, WithCacheControl(value), and the two WithErrorMapping overloads only.
MVC adapter
AsActionResult<T>() / AsActionResultAsync<T>() (in ActionResultAdapterExtensions) wrap the IResult so it satisfies ActionResult<T> signatures. The MVC pipeline still executes the same IResult via IConvertToActionResult.
[HttpPost]
public Task<ActionResult<TodoDto>> Create(CreateTodo cmd, CancellationToken ct) =>
_svc.CreateAsync(cmd, ct)
.ToHttpResponseAsync(opts => opts.CreatedAtRoute(
"Todos_GetById",
t => new RouteValueDictionary { ["id"] = t.Id }))
.AsActionResultAsync<TodoDto>();
Note
ToHttpResponse writes via the Minimal API Results.* infrastructure, bypassing MVC output formatters. If an endpoint depends on a custom MVC formatter (e.g. XML), return an MVC ObjectResult directly for that endpoint and use ToHttpResponse(...).AsActionResult<T>() for the rest.
Composition
ToHttpResponseAsync accepts both Task<Result<T>> and ValueTask<Result<T>> receivers, so it composes naturally at the end of a Bind/Map/Ensure chain.
app.MapPut("/todos/{id:guid}/title", async (Guid id, RenameTodo cmd, ITodoService svc, CancellationToken ct) =>
await svc.GetAsync(id, ct)
.EnsureAsync(t => t.OwnerId == cmd.OwnerId,
new Error.Forbidden("todos.rename"))
.BindAsync(t => svc.RenameAsync(t.Id, cmd.NewTitle, ct))
.ToHttpResponseAsync(opts => opts
.WithETag(t => $"{t.Version}")
.HonorPrefer()));
Test the produced IResult directly without spinning up the host:
var http = (await result).ToHttpResponse(opts => opts.WithETag(t => $"{t.Version}"));
await http.ExecuteAsync(httpContext);
// Assert on httpContext.Response.StatusCode and headers.
Practical guidance
- Pick the right receiver shape. Reads →
Result<T>. Commands with a no-body success →Result<Unit>(auto204). Writes that need RFC 9110 status semantics (201/202/204withLocation/Retry-After) →Result<WriteOutcome<T>>. Lists →Result<Page<T>>with the paginated overload. - Use
CreatedAtRoutefor AOT.CreatedAtActioncarriesRequiresUnreferencedCode/RequiresDynamicCode. PassRouteValueDictionary(not anonymous types) to stay AOT-safe. - Opt in to preconditions and
Prefer. BothEvaluatePreconditions()andHonorPrefer()are off by default — call them where you need the behavior. - Selectors run on the domain value. When projecting via
body, allWith*selectors still receive the domainT. Use this to compute strong ETags from version fields the DTO does not expose. - Per-call mappings beat globals.
opts.WithErrorMapping<TError>(status)overrides theTrellisAspOptionsregistration for that one call. - Don't pre-compute the
IResult. Build it inside the endpoint handler so the configuredLinkGeneratorresolves at execute time with the liveHttpContext.RequestServices.
Cross-references
- API surface: trellis-api-asp.md
Result<T>,WriteOutcome<T>,Page<T>,Cursor,Errorsemantics: trellis-api-core.md- Broader ASP.NET integration (Problem Details rendering, scalar validation, route constraints, actor providers, MVC patterns): integration-aspnet.md
- HTTP client side (
HttpResponseMessage→Result<T>): integration-http.md