Skip to content

Error Localization

How to localize error messages in Pragmatic.Result.

Pragmatic.Result works with semantic error codes (NOT_FOUND, VALIDATION_FAILED, INSUFFICIENT_FUNDS), not human-readable messages. Your business logic never allocates message strings. Translation happens at the serialization boundary, when the error becomes an HTTP response.

Internal (code everywhere) Serialization boundary External (API response)
───────────────────────── ────────────────────── ──────────────────────
NotFoundError { Code="NOT_FOUND" } → IErrorMessageResolver → { "code": "NOT_FOUND",
EntityType = "User" → Resolve("NOT_FOUND", error) "detail": "User 42 not found" }
EntityId = "42"

Benefits:

  • Zero string allocation on hot paths
  • Tests assert on codes, not culture-dependent text
  • Centralized translation in one place
  • Different frontends can translate differently

The extension point is IErrorMessageResolver in Pragmatic.Result.AspNetCore:

public interface IErrorMessageResolver
{
string? Resolve(string code, object? context = null);
}
  • code is the error’s Code property (e.g., "NOT_FOUND")
  • context is the error instance itself (cast to extract details)
  • Return null to use default behavior (code as-is)

The default NullErrorMessageResolver returns null for everything — no localization, codes pass through.

The simplest approach — no Resx, no external dependencies:

public sealed class DictionaryErrorMessageResolver : IErrorMessageResolver
{
private static readonly Dictionary<string, string> Messages = new()
{
["NOT_FOUND"] = "{0} with ID {1} was not found",
["FORBIDDEN"] = "You don't have permission to access this resource",
["CONFLICT"] = "{0} already exists",
["VALIDATION_FAILED"] = "One or more validation errors occurred"
};
public string? Resolve(string code, object? context = null)
{
if (!Messages.TryGetValue(code, out var template))
return null;
return context switch
{
NotFoundError nf => string.Format(template, nf.EntityType ?? "Resource", nf.EntityId ?? "unknown"),
ConflictError cf => string.Format(template, cf.EntityType ?? "Resource"),
_ => template
};
}
}

Register:

builder.Services.AddSingleton<IErrorMessageResolver, DictionaryErrorMessageResolver>();

If you use ASP.NET Core’s localization with resource files or JSON providers:

public sealed class LocalizedErrorMessageResolver(IStringLocalizer<ErrorMessages> localizer)
: IErrorMessageResolver
{
public string? Resolve(string code, object? context = null)
{
var localized = localizer[code];
if (localized.ResourceNotFound)
return null;
return context switch
{
NotFoundError nf => string.Format(localized, nf.EntityType ?? "Resource", nf.EntityId ?? "unknown"),
ConflictError cf => string.Format(localized, cf.EntityType ?? "Resource"),
_ => localized.Value
};
}
}

Register with standard ASP.NET Core localization:

builder.Services.AddLocalization(opts => opts.ResourcesPath = "Resources");
builder.Services.AddSingleton<IErrorMessageResolver, LocalizedErrorMessageResolver>();
app.UseRequestLocalization("en", "it", "de");

The ProblemDetailsFactory in Pragmatic.Result.AspNetCore calls IErrorMessageResolver.Resolve() when converting an IError to ProblemDetails:

  1. Your action returns Result.Failure(NotFoundError.Create("User", 42))
  2. The ASP.NET Core filter catches the error
  3. ProblemDetailsFactory builds ProblemDetails
  4. It calls IErrorMessageResolver.Resolve("NOT_FOUND", error)
  5. If the resolver returns a message, it becomes detail in the JSON response
  6. If the resolver returns null, the code itself is used
{
"type": "https://httpstatuses.io/404",
"title": "Not Found",
"status": 404,
"detail": "User with ID 42 was not found",
"code": "NOT_FOUND"
}

The ErrorLocalizationGenerator in Pragmatic.Result.SourceGenerator scans your custom error types and generates localization key constants. This gives you compile-time safety for your resource keys.

For domain-specific errors, the same pattern applies:

public readonly struct InsufficientFundsError : IHttpError
{
public string Code => "INSUFFICIENT_FUNDS";
public int StatusCode => 422;
public string Title => "Insufficient Funds";
public decimal Available { get; init; }
public decimal Required { get; init; }
}

In your resolver, add a case:

InsufficientFundsError isf => string.Format(
Resolve("INSUFFICIENT_FUNDS") ?? "Insufficient funds: {0:C} available, {1:C} required",
isf.Available, isf.Required)

Tests work with codes, never messages:

[Fact]
public async Task GetUser_NotFound_ReturnsCorrectCode()
{
var result = await service.GetUserAsync(999);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("NOT_FOUND");
// Don't assert on messages — they're culture-dependent
}
WhatWhere
Error codesIError.Code property on each error type
Translation pointIErrorMessageResolver.Resolve(code, context)
Default behaviorNullErrorMessageResolver — no localization, codes pass through
Registrationservices.AddSingleton<IErrorMessageResolver, YourResolver>()
TriggerProblemDetailsFactory calls resolver when building HTTP response