Offside

Guia ASP.NET Core

English · Voltar às docs

Offside.AspNetCore transforma um Result em uma resposta HTTP. É a única camada que conhece status codes — o domínio permanece agnóstico de transporte.

Registro

builder.Services.AddOffside(options => { /* catálogos */ });
builder.Services.AddOffsideAspNetCore();

AddOffsideAspNetCore registra OffsideAspNetCoreOptions. Quando há um IHostEnvironment no container, ExposeExceptionDetails assume IsDevelopment().

Minimal APIs

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.

Controllers MVC

public sealed class OrdersController(OrderService orders, IErrorMessageResolver resolver) : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult Get(string id) =>
        orders.Get(id).ToActionResult(resolver, CultureInfo.CurrentUICulture);
}

Mapeamento de sucesso

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);
});

Mapeamento de falha

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.

Status codes

ErrorKind Status
Unexpected 500
Unauthorized 401
Forbidden 403
TooManyRequests 429
Conflict 409
PreconditionFailed 412
Gone 410
Unprocessable 422
NotFound 404
Validation 400
BadRequest 400

Escolhendo o erro primário

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.

Erros inesperados e 500

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:

  1. O 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.
  2. O detalhe real aparece em debug apenas quando ExposeExceptionDetails está ligado.
  3. A falha é logada via 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 });

Culturas

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.

Referência de sobrecargas

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.

Regras práticas