Offside.AspNetCore transforma um Result em uma resposta HTTP. É a única camada que conhece status codes — o domínio permanece agnóstico de transporte.
builder.Services.AddOffside(options => { /* catálogos */ });
builder.Services.AddOffsideAspNetCore();
AddOffsideAspNetCore registra OffsideAspNetCoreOptions. Quando há um IHostEnvironment no container, ExposeExceptionDetails assume IsDevelopment().
app.MapGet("/orders/{id}", (string id, OrderService orders, HttpContext http) =>
orders.Get(id).ToHttpResult(http));
app.MapPost("/orders", (CreateOrder cmd, OrderHandler handler, HttpContext http) =>
handler.Handle(cmd).ToHttpResult(http));
A sobrecarga com HttpContext é a que se deve usar. Ela resolve o IErrorMessageResolver e as options a partir dos request services e deriva a cultura do header Accept-Language.
public sealed class OrdersController(OrderService orders, IErrorMessageResolver resolver) : ControllerBase
{
[HttpGet("{id}")]
public IActionResult Get(string id) =>
orders.Get(id).ToActionResult(resolver, CultureInfo.CurrentUICulture);
}
| Resultado | Resposta |
|---|---|
Result.Success() |
204 No Content |
Result<T>.Success(value) |
200 OK com value no corpo |
Para um 201 Created ou qualquer outro formato de sucesso, faça o branch antes de converter — ToHttpResult cuida do caminho de falha e você mantém controle total do caminho de sucesso:
app.MapPost("/orders", (CreateOrder cmd, OrderHandler handler, HttpContext http) =>
{
var result = handler.Handle(cmd);
return result.IsSuccess
? Results.Created($"/orders/{result.Value.Id}", result.Value)
: result.ToHttpResult(http);
});
Toda falha produz o mesmo corpo, application/problem+json com nomes em camelCase:
{
"type": "https://httpstatuses.io/409",
"title": "Conflict",
"status": 409,
"detail": "O pedido 42 já foi enviado.",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"errors": [
{
"code": "order.already_shipped",
"kind": "Conflict",
"detail": "O pedido 42 já foi enviado.",
"field": null
}
]
}
| Campo | Significado |
|---|---|
type |
https://httpstatuses.io/{status} |
title |
O ErrorKind do erro primário, como string |
status |
Derivado do kind mais severo presente |
detail |
A mensagem resolvida do erro primário |
traceId |
Activity.Current?.Id, caindo para HttpContext.TraceIdentifier |
errors |
Todos os erros do resultado, na ordem em que o domínio os reportou |
debug |
Presente apenas em um 500 com ExposeExceptionDetails ligado; omitido nos demais casos |
Clientes devem fazer branch em errors[].code, não em detail — o código é o contrato, o texto é dado de catálogo.
| ErrorKind | Status |
|---|---|
Unexpected |
500 |
Unauthorized |
401 |
Forbidden |
403 |
TooManyRequests |
429 |
Conflict |
409 |
PreconditionFailed |
412 |
Gone |
410 |
Unprocessable |
422 |
NotFound |
404 |
Validation |
400 |
BadRequest |
400 |
Quando um resultado carrega vários erros, a resposta reflete o kind mais severo, não o primeiro erro. Severidade, do mais severo para o menos:
| Rank | Kinds |
|---|---|
| 0 | Unexpected |
| 1 | Unauthorized, Forbidden |
| 2 | TooManyRequests |
| 3 | Conflict |
| 4 | PreconditionFailed |
| 5 | Gone |
| 6 | Unprocessable |
| 7 | NotFound |
| 8 | Validation, BadRequest |
Empates vão para o primeiro erro do resultado. Unauthorized e Forbidden compartilham o rank 1, então um resultado que carrega os dois reporta aquele que o domínio listou primeiro.
Result.Failure(
Error.Validation("email"), // 400
Error.Conflict("order", "dup"), // 409 ← mais severo, vence
Error.NotFound("order", 1)); // 404
// → status 409, title "Conflict", e os três erros no array errors
Ordenar por severidade em vez de por posição significa que uma falha genuína nunca é mascarada por uma mensagem de validação que por acaso foi adicionada primeiro. E nada se perde nos dois casos: a lista completa sempre é enviada.
ErrorKind.Unexpected é tratado de forma diferente, porque seu detalhe é material de diagnóstico e não algo que um cliente deva ler.
Quando o kind vencedor é Unexpected:
detail de todo erro inesperado é substituído pela mensagem genérica unexpected do catálogo — tanto no detail de topo quanto nas entradas de errors.debug apenas quando ExposeExceptionDetails está ligado.ILoggerFactory na categoria Offside.AspNetCore, junto com o traceId.return Result.Failure(Error.Unexpected(ex.ToString()));
Em produção:
{
"type": "https://httpstatuses.io/500",
"title": "Unexpected",
"status": 500,
"detail": "Ocorreu um erro inesperado.",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"errors": [
{ "code": "unexpected", "kind": "Unexpected", "detail": "Ocorreu um erro inesperado.", "field": null }
]
}
Em desenvolvimento, a mesma resposta ganha um campo "debug": "System.InvalidOperationException: ...". O detail visível ao cliente é genérico nos dois casos — ExposeExceptionDetails controla apenas o debug.
O traceId é a ponte: aparece na resposta e na linha de log, então o usuário pode citá-lo e você encontra a causa real.
Defina explicitamente se não quiser depender do ambiente. OffsideAspNetCoreOptions é um singleton simples, então registre a sua própria instância em vez de chamar AddOffsideAspNetCore:
builder.Services.AddSingleton(new OffsideAspNetCoreOptions { ExposeExceptionDetails = false });
Ou construa direto no ponto de chamada:
result.ToHttpResult(resolver, culture: null, new OffsideAspNetCoreOptions { ExposeExceptionDetails = false });
Quando nenhuma cultura é passada, ela vem do header Accept-Language da requisição — o primeiro range, sem o quality value. Accept-Language: pt-BR,pt;q=0.9 resolve para pt-BR, que cai para pt e depois para o catálogo invariante.
O header cai para CultureInfo.CurrentUICulture quando está ausente, vazio, é *, ou não é um nome de cultura reconhecido. Um header malformado nunca derruba uma requisição.
Veja Mensagens e culturas para a resolução de catálogo.
| Método | Cultura | Options |
|---|---|---|
ToHttpResult(resolver, exposeExceptionDetails?) |
CurrentUICulture |
flag |
ToHttpResult(resolver, culture, exposeExceptionDetails?) |
explícita | flag |
ToHttpResult(resolver, culture?, options) |
explícita ou Accept-Language |
objeto |
ToHttpResult(httpContext) |
Accept-Language |
do DI |
ToActionResult(resolver, culture, exposeExceptionDetails?) |
explícita | flag |
ToActionResult(resolver, culture?, options) |
explícita ou Accept-Language |
objeto |
Cada linha existe para Result e Result<T>, com uma exceção: não existe ToActionResult(resolver, exposeExceptionDetails?) para o Result não genérico. A forma genérica tem; a unitária não. Passe uma cultura explicitamente, ou passe null pela sobrecarga com options para cair no Accept-Language.
Offside.AspNetCore de um projeto de domínio ou aplicação. Status codes são preocupação de transporte.Error.Arguments — eles acabam nas mensagens, e mensagens são enviadas.errors[].code, nunca por detail.