Error text lives in JSON catalogs, one per culture. C# holds the metadata — code, kind, arguments — and never the wording.
A flat JSON object mapping error code to message template:
{
"not_found": "{resource} '{id}' was not found.",
"conflict": "Conflict on {resource}.",
"validation": "{field} is invalid.",
"unexpected": "An unexpected error occurred."
}
The eleven default codes — not_found, gone, conflict, validation, bad_request, unauthorized, forbidden, precondition_failed, unprocessable, too_many_requests, unexpected — cover every built-in factory. Add one entry per custom code you introduce with Error.Custom.
builder.Services.AddOffside(options =>
{
options.AddJson(CultureInfo.InvariantCulture, File.ReadAllText("errors/errors.json"));
options.AddJson(new CultureInfo("pt-BR"), File.ReadAllText("errors/errors.pt-BR.json"));
options.AddJson(new CultureInfo("es"), File.ReadAllText("errors/errors.es.json"));
});
AddJson takes the catalog content, not a path. There is also a Stream overload for embedded resources:
options.AddJson(CultureInfo.InvariantCulture,
typeof(Program).Assembly.GetManifestResourceStream("MyApp.errors.json")!);
Catalogs are parsed once, at registration. A malformed file fails at startup, not on the first failing request.
An invariant-culture catalog is required. Without it, AddOffside throws InvalidOperationException. It is the final fallback, so every code should appear there.
Resolution tries three lookups in order and stops at the first hit:
pt-BRptSo a pt catalog serves pt-BR and pt-PT alike, and you translate the specific ones only where the wording actually differs. If no catalog defines the code at all, the resolver returns the code itself — a response stays well-formed and the missing entry is visible rather than silently blank.
In an ASP.NET Core host the culture comes from the Accept-Language header unless you pass one explicitly. See Cultures.
Template tokens are {name}, filled from Error.Arguments:
Error.NotFound("order", 42)
// arguments: resource = "order", id = 42
{ "not_found": "{resource} '{id}' was not found." }
order '42' was not found.
Three behaviours to know:
Error.NotFound("order") against the template above yields order '{id}' was not found. — a null argument is skipped, not blanked. Visible, not silent.InvariantCulture. Numbers and dates come out stable regardless of the request’s culture. Format them yourself before passing them in if you need locale-aware output.string.Format. There is no {0}, no format specifiers such as {amount:C}, and no escaping — braces that match no argument survive as written.errors/errors.json to errors/errors.<culture>.json.{tokens} untouched.options.AddJson(new CultureInfo("<culture>"), ...).{
"not_found": "{resource} '{id}' não foi encontrado.",
"conflict": "Conflito em {resource}.",
"validation": "{field} é inválido.",
"unexpected": "Ocorreu um erro inesperado."
}
A translated catalog does not have to be complete. Anything it omits falls back to the parent culture and then to the invariant catalog, so you can ship a partial translation and fill it in over time.
offside init writes an English and a Brazilian Portuguese catalog to start from — see the CLI page.
AddOffside registers a JsonErrorMessageResolver as the singleton IErrorMessageResolver. To source messages from somewhere else — a database, satellite resource assemblies, a translation service — implement the interface and register it instead:
public sealed class ResxErrorMessageResolver : IErrorMessageResolver
{
public string GetMessage(Error error, CultureInfo culture) =>
Messages.ResourceManager.GetString(error.Code, culture) ?? error.Code;
}
builder.Services.AddSingleton<IErrorMessageResolver, ResxErrorMessageResolver>();
Do not call AddOffside in that case — it would register the JSON resolver alongside yours. Returning error.Code for an unknown code is the convention worth keeping: it degrades to something diagnosable instead of an empty string.