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(). Passe um callback de configure para definir ganchos depois — ele prevalece sobre o default do ambiente:
builder.Services.AddOffsideAspNetCore(options =>
{
options.LegacyAliases = LegacyProblemAliases.MessageReasonAndTechnicalDetail;
options.TelemetryProperties = (problem, errors, http) =>
new Dictionary<string, string> { ["Operation"] = http.Request.Path };
});
Quando um IDomainErrorRecorder está registrado (AddOffsideOpenTelemetry ou AddOffsideApplicationInsights), o pipeline grava segundo RecordMode — um evento por erro por padrão, ou só o erro que dirige o status com PrimaryErrorOnly. OnProblem não é telemetria.
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.",
"errorCode": "ORDER_ALREADY_SHIPPED",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"errors": [
{
"code": "order.already_shipped",
"errorCode": "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 |
errorCode |
O identificador de tela do erro primário |
traceId |
Activity.Current.TraceId (32 hex), caindo para HttpContext.TraceIdentifier. Substitua com ResolveTraceId |
errors |
Todos os erros do resultado, na ordem em que o domínio os reportou |
errors[].code |
Chave do catálogo (order.already_shipped) |
errors[].errorCode |
Identificador de tela (ORDER_ALREADY_SHIPPED) |
debug |
Presente apenas em um 500 com ExposeExceptionDetails ligado; omitido nos demais casos |
Campos extras adicionados por CustomizeProblem são achatados no documento (e em errors[]) como o ProblemDetails.Extensions do ASP.NET. Chaves que colidem com o contrato (type, title, status, detail, instance, traceId, errorCode, debug, errors) são removidas. Use primitivos seguros para JSON.
Clientes devem fazer branch em errorCode (topo ou errors[].errorCode), não em detail. code é a chave do catálogo de mensagens.
| ErrorKind | Status |
|---|---|
Unexpected |
500 |
Unauthorized |
401 |
Forbidden |
403 |
TooManyRequests |
429 |
Conflict |
409 |
PreconditionFailed |
412 |
Gone |
410 |
Unprocessable |
422 |
NotFound |
404 |
Validation |
400 |
BadRequest |
400 |
ServiceUnavailable |
503 |
Timeout |
504 |
O mesmo mapeamento é OffsideHttp.StatusCode(kind). OffsideHttp.StatusCodes é o conjunto distinto (400, 401, 403, 404, 409, 410, 412, 422, 429, 500, 503, 504) usado como respostas esperadas. OffsideHttp.SelectPrimary escolhe o erro que dirige o status quando você escreve uma resposta própria.
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 | ServiceUnavailable, Timeout |
| 4 | Conflict |
| 5 | PreconditionFailed |
| 6 | Gone |
| 7 | Unprocessable |
| 8 | NotFound |
| 9 | 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. Auth e rate-limit vencem 503/504 para o cliente não ser instruído a retentar um pedido não autenticado ou limitado.
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.errorCode de todo erro inesperado é forçado para UNEXPECTED.debug apenas quando ExposeExceptionDetails está ligado.ILoggerFactory na categoria Offside.AspNetCore, junto com o traceId, a menos que LogUnexpected seja false. Quando há um recorder registrado, esta linha assume desligada para um 500 não ser duplicado. Defina LogUnexpected = true para manter as duas.return Result.Failure(Error.Unexpected(ex.ToString()));
Em produção:
{
"type": "https://httpstatuses.io/500",
"title": "Unexpected",
"status": 500,
"detail": "Ocorreu um erro inesperado.",
"errorCode": "UNEXPECTED",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"errors": [
{ "code": "unexpected", "errorCode": "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. O default é o TraceId W3C de 32 hex (o valor que o Application Insights guarda como operation_Id), não o traceparent completo em Activity.Id. Restaure o formato antigo com ResolveTraceId:
builder.Services.AddOffsideAspNetCore(options =>
{
options.ResolveTraceId = http =>
Activity.Current?.Id ?? http.TraceIdentifier;
});
Defina as options explicitamente se não quiser depender do ambiente. Prefira o callback de configure em AddOffsideAspNetCore; registrar o próprio singleton ainda funciona:
builder.Services.AddSingleton(new OffsideAspNetCoreOptions { ExposeExceptionDetails = false });
Ou construa direto no ponto de chamada (esta forma não tem ganchos de DI a menos que você os coloque no objeto):
result.ToHttpResult(resolver, culture: null, new OffsideAspNetCoreOptions { ExposeExceptionDetails = false });
O host configura o recorder uma vez. ToHttpResult, ToActionResult e SendOffsideAsync fazem o resto — não há RecordTo no endpoint HTTP. Workers e handlers MediatR sem HttpContext ainda chamam RecordTo.
Dimensões extras (nome de operação, tenant) vêm de TelemetryProperties. O pipeline sempre grava HttpStatus. As dimensões do Offside ainda vencem dentro do recorder.
O RecordMode default é PerError: N campos inválidos geram N traces, eventos de span e incrementos no counter. Hosts que alertam por falha de requisição, não por cada campo, optam por:
options.RecordMode = ProblemRecordMode.PrimaryErrorOnly;
Isso reusa OffsideHttp.SelectPrimary. RecordTo em worker ou handler MediatR continua um evento por erro; o modo vale só no pipeline HTTP (ToHttpResult, ToActionResult, SendOffsideAsync).
OnProblem não emite telemetria. Continua sendo um gancho do host para qualquer outra coisa que deva rodar depois que o documento é montado.
Hosts que ainda não podem mudar o cliente achatam os nomes antigos no documento:
builder.Services.AddOffsideAspNetCore(options =>
options.LegacyAliases = LegacyProblemAliases.MessageReasonAndTechnicalDetail);
Isso acrescenta message (= detail), por item reason (= detail), por item name (= field quando o erro tem um, senão LegacyGeneralErrorName, default "generalErrors" — o sentinela do FastEndpoints para erros não atribuíveis a um campo), e technicalDetail só quando debug está presente (Unexpected + ExposeExceptionDetails). Fora isso o campo é omitido; nunca copia o detail de negócio. Defina LegacyGeneralErrorName como null ou vazio para omitir name em erros sem campo. O Field canônico continua null — o sentinela é só o alias. CustomizeProblem fica para extensões raras.
CustomizeProblem roda depois que o documento é montado. As propriedades centrais continuam init; acrescente campos do host por Extensions (e Item.Extensions). Mantenha valores seguros para JSON. Um callback que lança é logado em Offside.AspNetCore e o documento ainda é escrito.
builder.Services.AddOffsideAspNetCore(options =>
{
options.CustomizeProblem = (problem, errors) =>
{
problem.Extensions["correlation"] = "…";
};
options.OnProblem = (problem, errors, http) =>
{
// trabalho específico do host — não é telemetria de erro de domínio
};
});
O ResponseBuilder de validação do FastEndpoints usa o mesmo pipeline, então ganchos, aliases e o traceId de 32 hex valem lá também.
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 — obsoleto, constrói options vazias |
ToHttpResult(resolver, culture, exposeExceptionDetails?) |
explícita | flag — obsoleto, constrói options vazias |
ToHttpResult(resolver, culture?, options) |
explícita ou Accept-Language |
objeto |
ToHttpResult(httpContext) |
Accept-Language |
do DI; lança se AddOffsideAspNetCore não rodou |
ToActionResult(resolver, culture, exposeExceptionDetails?) |
explícita | flag — obsoleto, constrói options vazias |
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.errorCode, nunca por detail.ErrorKind.ServiceUnavailable / Timeout, não em Unexpected. Não coloque texto de exceção em templates {reason}.