Error Localization
How to localize error messages in Pragmatic.Result.
Philosophy: Codes, Not Messages
Section titled “Philosophy: Codes, Not Messages”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
IErrorMessageResolver
Section titled “IErrorMessageResolver”The extension point is IErrorMessageResolver in Pragmatic.Result.AspNetCore:
public interface IErrorMessageResolver{ string? Resolve(string code, object? context = null);}codeis the error’sCodeproperty (e.g.,"NOT_FOUND")contextis the error instance itself (cast to extract details)- Return
nullto use default behavior (code as-is)
The default NullErrorMessageResolver returns null for everything — no localization, codes pass through.
Example: Simple Dictionary Resolver
Section titled “Example: Simple Dictionary Resolver”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>();Example: IStringLocalizer Resolver
Section titled “Example: IStringLocalizer Resolver”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");How It Flows
Section titled “How It Flows”The ProblemDetailsFactory in Pragmatic.Result.AspNetCore calls IErrorMessageResolver.Resolve() when converting an IError to ProblemDetails:
- Your action returns
Result.Failure(NotFoundError.Create("User", 42)) - The ASP.NET Core filter catches the error
ProblemDetailsFactorybuilds ProblemDetails- It calls
IErrorMessageResolver.Resolve("NOT_FOUND", error) - If the resolver returns a message, it becomes
detailin the JSON response - 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"}SG-Generated Error Localization Keys
Section titled “SG-Generated Error Localization Keys”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.
Custom Error Types
Section titled “Custom Error Types”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)Testing
Section titled “Testing”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}Summary
Section titled “Summary”| What | Where |
|---|---|
| Error codes | IError.Code property on each error type |
| Translation point | IErrorMessageResolver.Resolve(code, context) |
| Default behavior | NullErrorMessageResolver — no localization, codes pass through |
| Registration | services.AddSingleton<IErrorMessageResolver, YourResolver>() |
| Trigger | ProblemDetailsFactory calls resolver when building HTTP response |