Table of Contents

ASP.NET Core Integration

Level: Intermediate | Time: 25-35 min | Prerequisites: Basics

When your application already returns Result<T>, the next problem is predictable HTTP behavior: correct status codes, useful Problem Details responses, clean controller code, and support for web concerns like ETags, Prefer, and pagination. Trellis.Asp solves that boundary.

Tip

Register AddTrellisAsp() even though Trellis has fallback defaults. It makes your HTTP mappings explicit and gives you one obvious place to customize them later.

What this package gives you

Trellis.Asp is the ASP.NET Core adapter layer for Trellis.

It gives you:

  • ToActionResult(...) and ToHttpResult(...) for mapping Result<T> to HTTP responses
  • default error-type-to-status-code mappings
  • Problem Details responses for failures
  • automatic 204 No Content for successful Result<Unit>
  • scalar value validation for MVC and Minimal APIs
  • representation metadata support for headers like ETag and Last-Modified
  • Prefer-aware update helpers
  • partial-content helpers for paginated responses

Quick start: MVC controllers

If you are using controllers, this is the smallest complete setup:

using Microsoft.AspNetCore.Mvc;
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();

public interface IUserService
{
    Task<Result<User>> GetByIdAsync(string id, CancellationToken cancellationToken);
    Task<Result<User>> CreateAsync(CreateUserRequest request, CancellationToken cancellationToken);
}

public sealed record User(string Id, string Email);
public sealed record CreateUserRequest(string Email);
public sealed record UserResponse(string Id, string Email)
{
    public static UserResponse From(User user) => new(user.Id, user.Email);
}

[ApiController]
[Route("users")]
public sealed class UsersController(IUserService users) : ControllerBase
{
    [HttpGet("{id}", Name = nameof(GetById))]
    public async Task<ActionResult<UserResponse>> GetById(string id, CancellationToken ct) =>
        await users.GetByIdAsync(id, ct)
            .ToActionResultAsync(this, UserResponse.From);

    [HttpPost]
    public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken ct) =>
        await users.CreateAsync(request, ct)
            .ToCreatedAtActionResultAsync(
                this,
                nameof(GetById),
                user => new { id = user.Id },
                UserResponse.From);
}

Why this works well:

  • your service layer stays focused on domain results
  • your controller only handles HTTP concerns
  • success and failure paths stay visible

Quick start: Minimal APIs

If you prefer Minimal APIs, use the Minimal API helpers instead:

using Microsoft.AspNetCore.Routing;
using Trellis;
using Trellis.Asp;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddTrellisAsp();
builder.Services.AddScalarValueValidationForMinimalApi();

var app = builder.Build();
app.UseScalarValueValidation();

app.MapGet("/users/{id}", async (
    string id,
    IUserService users,
    CancellationToken ct) =>
    await users.GetByIdAsync(id, ct)
        .MapAsync(UserResponse.From)
        .ToHttpResultAsync())
    .WithName("GetUser");

app.MapPost("/users", async (
    CreateUserRequest request,
    IUserService users,
    CancellationToken ct) =>
    await users.CreateAsync(request, ct)
        .ToCreatedAtRouteHttpResultAsync(
            routeName: "GetUser",
            routeValues: user => new RouteValueDictionary(new { id = user.Id }),
            map: UserResponse.From));

app.Run();

public interface IUserService
{
    Task<Result<User>> GetByIdAsync(string id, CancellationToken cancellationToken);
    Task<Result<User>> CreateAsync(CreateUserRequest request, CancellationToken cancellationToken);
}

public sealed record User(string Id, string Email);
public sealed record CreateUserRequest(string Email);
public sealed record UserResponse(string Id, string Email)
{
    public static UserResponse From(User user) => new(user.Id, user.Email);
}

AddTrellisAsp() overloads

There are two registration styles:

builder.Services.AddTrellisAsp();

builder.Services.AddTrellisAsp(options =>
{
    options.MapError<DomainError>(StatusCodes.Status400BadRequest);
});

Use the parameterless overload when the defaults already match your API. Use the configured overload when you want to override specific mappings.

Default error mapping

One of the biggest wins of Trellis.Asp is that you do not need a custom switch statement in every endpoint.

Trellis error type Default HTTP status
ValidationError 400 Bad Request
BadRequestError 400 Bad Request
UnauthorizedError 401 Unauthorized
ForbiddenError 403 Forbidden
NotFoundError 404 Not Found
MethodNotAllowedError 405 Method Not Allowed
NotAcceptableError 406 Not Acceptable
ConflictError 409 Conflict
GoneError 410 Gone
PreconditionFailedError 412 Precondition Failed
ContentTooLargeError 413 Content Too Large
UnsupportedMediaTypeError 415 Unsupported Media Type
RangeNotSatisfiableError 416 Range Not Satisfiable
DomainError 422 Unprocessable Content
PreconditionRequiredError 428 Precondition Required
RateLimitError 429 Too Many Requests
UnexpectedError 500 Internal Server Error
ServiceUnavailableError 503 Service Unavailable
Note

Trellis error codes follow the .error suffix convention, such as validation.error, not.found.error, and conflict.error.

Problem Details output

Failures are returned as Problem Details responses, so clients get a standard shape instead of ad hoc JSON.

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "email": ["Email is required"]
  }
}

Scalar value validation

This solves a common pain point: value objects are great in your domain, but raw ASP.NET Core model binding does not know how to validate them the way Trellis does.

MVC setup

For controllers, use the MVC-specific registration:

builder.Services
    .AddControllers()
    .AddScalarValueValidation();

var app = builder.Build();
app.UseScalarValueValidation();
app.MapControllers();

That registration adds:

  • JSON converter support for scalar values
  • model binders for route/query/form values
  • a validation filter that returns proper validation responses

Minimal API setup

For Minimal APIs, register JSON support, middleware, and the endpoint filter:

using Trellis.Primitives;
using Trellis.Asp;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScalarValueValidationForMinimalApi();

var app = builder.Build();
app.UseScalarValueValidation();

app.MapPost("/customers", (CreateCustomerRequest request) => Results.Ok(request))
    .WithScalarValueValidation();

app.Run();

public sealed record CreateCustomerRequest(EmailAddress Email, FirstName Name);

Important distinction

AddScalarValueValidation() also exists on IServiceCollection, but that convenience overload only configures shared JSON support. It does not replace AddControllers().AddScalarValueValidation() for MVC apps.

Optional value objects with Maybe<T>

Maybe<T> is useful when “missing” is valid but “present and invalid” should still fail the request.

using Trellis;
using Trellis.Primitives;

public sealed record UpdateCustomerRequest(
    FirstName Name,
    Maybe<PhoneNumber> Phone,
    Maybe<Url> Website);

With scalar value validation enabled:

  • omitted or null optional values become Maybe<T>.None
  • valid values become Maybe.From(value)
  • invalid values produce a validation error instead of silently becoming null

Conditional requests: ETags and concurrency

This solves the “lost update” problem and lets clients cache responses safely.

GET with representation metadata

Use representation metadata to emit response headers such as ETag.

using Trellis;
using Trellis.Asp;

app.MapGet("/products/{id:guid}", (Guid id, ProductDbContext db, HttpContext httpContext) =>
    db.Products
        .FirstOrDefaultResultAsync(
            p => p.Id == ProductId.Create(id),
            Error.NotFound("Product not found.", id.ToString()))
        .ToHttpResultAsync(
            httpContext,
            product => RepresentationMetadata.WithStrongETag(product.ETag),
            ProductResponse.From));

public sealed record ProductResponse(Guid Id, string Name, decimal Price, string ETag)
{
    public static ProductResponse From(Product product) =>
        new(product.Id.Value, product.Name.Value, product.Price.Value, product.ETag);
}

If the client sends a matching If-None-Match, the response is automatically shortened to 304 Not Modified.

PUT with If-Match

ETagHelper.ParseIfMatch(request) returns EntityTagValue[]?, and that typed value flows directly into Trellis concurrency helpers.

using Trellis;
using Trellis.Asp;
using Trellis.Primitives;

app.MapPut("/products/{id:guid}", (Guid id, UpdateProductRequest request, ProductDbContext db, HttpContext httpContext) =>
    db.Products
        .FirstOrDefaultResultAsync(
            p => p.Id == ProductId.Create(id),
            Error.NotFound("Product not found.", id.ToString()))
        .OptionalETagAsync(ETagHelper.ParseIfMatch(httpContext.Request))
        .BindAsync(product => product.UpdatePrice(request.Price))
        .CheckAsync(_ => db.SaveChangesResultUnitAsync())
        .ToUpdatedHttpResultAsync(
            httpContext,
            product => RepresentationMetadata.WithStrongETag(product.ETag),
            ProductResponse.From));

public sealed record UpdateProductRequest(MonetaryAmount Price);
public sealed record ProductResponse(Guid Id, string Name, decimal Price, string ETag)
{
    public static ProductResponse From(Product product) =>
        new(product.Id.Value, product.Name.Value, product.Price.Value, product.ETag);
}

Use:

  • OptionalETag(...) when If-Match is optional
  • RequireETag(...) when missing If-Match should fail with 428 Precondition Required

Create-if-absent with If-None-Match

For “only create if this resource does not already exist” flows, use ParseIfNoneMatch(...) and EnforceIfNoneMatchPrecondition(...).

var ifNoneMatch = ETagHelper.ParseIfNoneMatch(httpContext.Request); // EntityTagValue[]?
var guarded = result.EnforceIfNoneMatchPrecondition(ifNoneMatch);
Note

EnforceIfNoneMatchPrecondition(...) takes EntityTagValue[]?, not string[].

Prefer header support

Sometimes a client wants the updated representation back. Sometimes it only wants confirmation that the write succeeded. Trellis supports both without forcing you to hand-roll header parsing.

using Trellis;
using Trellis.Asp;

app.MapPut("/orders/{id:guid}", async (
    Guid id,
    UpdateOrderRequest request,
    IOrderService orders,
    HttpContext httpContext,
    CancellationToken ct) =>
    await orders.UpdateAsync(id, request, ct)
        .ToUpdatedHttpResultAsync(
            httpContext,
            order => RepresentationMetadata.WithStrongETag(order.ETag),
            OrderResponse.From));

Behavior:

  • Prefer: return=minimal204 No Content
  • Prefer: return=representation200 OK with a body
  • Preference-Applied is emitted when Trellis honors the preference

If you need raw access to the parsed header:

var prefer = PreferHeader.Parse(httpContext.Request);

if (prefer.ReturnMinimal)
{
    // client asked for a minimal response
}
Note

PreferHeader.HasPreferences means “at least one recognized standard preference was parsed.” Unknown tokens do not set it.

Pagination and partial content

For paged item collections, Trellis can return 206 Partial Content with a Content-Range header.

app.MapGet("/products", async (ProductDbContext db, int? page, int? pageSize) =>
{
    var size = Math.Clamp(pageSize ?? 25, 1, 100);
    var number = Math.Max(page ?? 0, 0);
    var from = number * size;

    var total = await db.Products.CountAsync();
    var items = await db.Products
        .OrderBy(p => p.Name)
        .Skip(from)
        .Take(size)
        .Select(ProductResponse.From)
        .ToArrayAsync();

    if (items.Length == 0)
        return Results.Ok(items);

    var to = from + items.Length - 1;
    return Result.Success(items).ToHttpResult(from, to, total);
});
Note

RangeRequestEvaluator is the lower-level RFC 9110 byte-range helper. It intentionally returns FullRepresentation for many cases: non-GET requests, missing Range, unsupported units, empty ranges, multiple ranges, and malformed single ranges.

When to customize the response yourself

The built-in mappers are the default choice. Reach for custom matching only when the endpoint genuinely needs a custom payload shape.

app.MapPost("/orders", async (
    CreateOrderRequest request,
    IOrderService orders,
    CancellationToken ct) =>
    await orders.CreateAsync(request, ct).MatchErrorAsync(
        onValidation: validation => Results.BadRequest(new
        {
            message = "Validation failed",
            errors = validation.FieldErrors
                .GroupBy(e => e.FieldName)
                .ToDictionary(g => g.Key, g => g.Select(e => e.Message).ToArray())
        }),
        onConflict: error => Results.Conflict(new { message = error.Detail }),
        onSuccess: order => Results.Created($"/orders/{order.Id}", order),
        cancellationToken: ct));

Use this approach when you need:

  • a non-Problem-Details error body
  • endpoint-specific payload shapes
  • extra headers or cookies beyond the standard helpers

Best practices

  1. Convert at the API boundary only. Keep Result<T> in your application layer.
  2. Use MVC-specific or Minimal-API-specific validation setup. Do not rely on the shared convenience overload alone for MVC.
  3. Use Result<Unit> for side-effect operations. Trellis maps successful unit results to 204 No Content.
  4. Prefer typed ETag helpers. ParseIfMatch(...) and ParseIfNoneMatch(...) return EntityTagValue[]?, which matches the concurrency APIs.
  5. Use representation metadata instead of hand-writing headers when you want ETag, Last-Modified, Vary, or related response metadata.

Next steps