Data: 2026-08-12
Estado: aprovado em conversa; à espera de revisão do ficheiro
Nome de mercado: Offside.
Tagline: the domain called offside.
Pacotes NuGet: Offside, Offside.AspNetCore.
Namespace: Offside / Offside.AspNetCore. Os tipos continuam Error, ErrorKind, Result.
Biblioteca .NET para erros de domínio: o domínio e a aplicação devolvem Result com uma lista de Error; a borda HTTP traduz isso em Problem Details (RFC 7807). Mensagens traduzíveis vivem em JSON por cultura; metadados (espécie, HTTP, type) vivem no C#.
Result / Result<T>.Result → HTTP.Fora de âmbito (v1): source generators, FluentValidation, gRPC interceptors, persistência de erros, UI.
| Pacote | Targets | Responsabilidade |
|---|---|---|
Offside |
netstandard2.0;net8.0;net10.0 |
Error, ErrorKind, Result/Result<T>, factories, Combine, resolvedor JSON |
Offside.AspNetCore |
net8.0;net10.0 |
ToHttpResult / IActionResult, Problem Details, DI, 500 sanitizado |
Dependência: Offside.AspNetCore → Offside. O pacote Core não referencia ASP.NET.
Solução em C:\Users\vpcam\dev\Offside (pasta actual: DomainErrors; renomear na implementação).
ErrorKindEspécies built-in e status HTTP default:
| Kind | Status | Code default | type RFC 7807 |
|---|---|---|---|
Unexpected |
500 | unexpected |
https://httpstatuses.io/500 |
Unauthorized |
401 | unauthorized |
https://httpstatuses.io/401 |
Forbidden |
403 | forbidden |
https://httpstatuses.io/403 |
TooManyRequests |
429 | too_many_requests |
https://httpstatuses.io/429 |
Conflict |
409 | conflict |
https://httpstatuses.io/409 |
PreconditionFailed |
412 | precondition_failed |
https://httpstatuses.io/412 |
Gone |
410 | gone |
https://httpstatuses.io/410 |
Unprocessable |
422 | unprocessable |
https://httpstatuses.io/422 |
NotFound |
404 | not_found |
https://httpstatuses.io/404 |
Validation |
400 | validation |
https://httpstatuses.io/400 |
BadRequest |
400 | bad_request |
https://httpstatuses.io/400 |
ErrorKind não é extensível na v1. Erros de regra de negócio usam Error.Custom(code, kind, args) reutilizando um Kind existente para o HTTP. Não há httpStatus livre no Error v1.
ErrorError
Code: string // chave no JSON; built-in = code default do Kind
Kind: ErrorKind
Arguments: IReadOnlyDictionary<string, object?>
Field: string? // preenchido por Error.Validation; opcional em Custom
Invariantes:
Code não vazio.Arguments imutável; chaves usadas como placeholders {chave} nas mensagens.Code, Kind, Field e Arguments.ToException() devolve DomainException (mensagem = Code; Errors expostos na exceção). Só escape (bugs / invariantes), não caminho de domínio.Não interpolar HTML. Não colocar segredos em Arguments. Números/datas formatados com cultura invariante na interpolação.
Error.NotFound(string resource, object? id = null)
Error.Gone(string resource, object? id = null)
Error.Conflict(string resource, string? reason = null)
Error.Validation(string field, string? code = null, object? attemptedValue = null)
// se `code` for passado, torna-se Error.Code (chave JSON); senão "validation"
Error.BadRequest(string? reason = null)
Error.Unauthorized(string? reason = null)
Error.Forbidden(string? reason = null)
Error.PreconditionFailed(string? reason = null)
Error.Unprocessable(string? reason = null)
Error.TooManyRequests(string? reason = null)
Error.Unexpected(string? detail = null) // `detail` é para log; não vai no HTTP de produção
Error.Custom(string code, ErrorKind kind, object? arguments = null, string? field = null)
arguments em Custom aceita IDictionary<string, object?> ou objeto anónimo convertido a dicionário.
Argumentos típicos das factories built-in: resource, id, reason, field, attemptedValue.
Error.Custom("order.already_shipped", ErrorKind.Conflict, new { orderId });
A mensagem vive no JSON sob a chave order.already_shipped. O Kind continua a decidir o HTTP.
Result / Result<T>Result e Result<T> são readonly struct.
IsSuccess / IsFailureErrors: IReadOnlyList<Error> (vazia em sucesso)Result.Success() / Result<T>.Success(value)Failure(params Error[] errors) e Failure(IEnumerable<Error> errors)T → Result<T>: não na v1 (esconde falhas)Invariantes (estados ilegais):
Failure sem erros → throw na construção.Value em falha → throw. Usar TryGetValue / Match.Combinators:
| Método | Comportamento |
|---|---|
Map |
Transforma o valor; short-circuit em falha |
Bind |
Encadeia Result; short-circuit em falha |
Match |
Bifurca sucesso / falha |
Combine |
Junta erros de vários Result (validação de vários campos). Nome único na v1; não há Apply. |
Bind não substitui Combine. Sem Combine, a lista de erros quase não seria usada.
O resolvedor vive no Core. A borda HTTP só escolhe a CultureInfo do pedido. Workers/CLI passam a cultura que quiserem.
Interface:
string GetMessage(Error error, CultureInfo culture);
Ficheiros: errors.{culture}.json (ex. errors.pt-BR.json, errors.en.json) e errors.json como default.
Formato:
{
"not_found": "{resource} '{id}' não foi encontrado.",
"order.already_shipped": "A encomenda {orderId} já foi expedida."
}
Política de fallback:
pt-BR) → pai (pt) → errors.json.Error.Code (nunca throw em runtime por mensagem).errors.json default em falta no arranque → falhar o startup.{x} sem argumento → deixar o token na string (não throw).Descoberta: o Core não conhece ContentRoot. O host regista streams/ficheiros via DI (IOptions / AddOffside(...)). O Core só lê Stream + CultureInfo.
AddOffside():
Accept-Language / CurrentUICulture);ExposeExceptionDetails (default = IHostEnvironment.IsDevelopment()).Mapeamento de Result:
IResult ToHttpResult(this Result) e ToHttpResult<T>(this Result<T>).IActionResult.application/problem+json.O Kind mais grave manda. Ordem (mais → menos grave):
UnexpectedUnauthorized, Forbidden (mesmo nível; desempate = primeiro na lista Errors)TooManyRequestsConflictPreconditionFailedGoneUnprocessableNotFoundValidation, BadRequest (mesmo nível; desempate = primeiro na lista Errors)title / type / status vêm do Kind vencedor. detail é a mensagem resolvida do erro primário (primeiro erro desse Kind na lista). Todos os erros vão em errors[].
Se o Kind vencedor for Unexpected, aplica-se o caminho 500 sanitizado (§6.3), mesmo que a lista tenha outros erros.
Um único JSON, sempre:
{
"type": "https://httpstatuses.io/409",
"title": "Conflict",
"status": 409,
"detail": "mensagem do erro primário",
"traceId": "00-…",
"errors": [
{
"code": "order.already_shipped",
"kind": "Conflict",
"detail": "A encomenda 123 já foi expedida.",
"field": null
}
]
}
Não há ValidationProblemDetails paralelo na v1.
Exceções não tratadas e ErrorKind.Unexpected:
status 500, title/detail genéricos (localizados se o JSON tiver unexpected), traceId, errors[] sem detalhe interno.traceId.Error.Unexpected(detail) e Exception.Message não vão para detail em produção.ExposeExceptionDetails: extensão debug (não detail) com a mensagem; nunca o stack no body.Offside.sln
src/Offside/
src/Offside.AspNetCore/
tests/Offside.Tests/
tests/Offside.AspNetCore.Tests/
docs/superpowers/specs/
Exemplos de JSON de mensagens em src/Offside/errors.json (default EN) e samples/docs; os consumidores trazem os seus errors.pt-BR.json.
Core:
Custom com objeto anónimo.Combine junta erros; Bind curto-circuita. Não existe Apply.Failure() vazio e Value em falha throw.Code.{args}; placeholder em falta permanece.ASP.NET:
application/problem+json e errors[] completo.Accept-Language.debug só com a opção Development.traceId presente.errors[]).traceId; debug só em Development.ErrorKind fechado na v1; Custom reusa um Kind.T → Result<T> na v1.