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{ // Resolves the localized "detail" message for the error code. string? Resolve(string code, object? context = null);
// Resolves the localized "title". Default implementation returns null. string? ResolveTitle(string code, object? context = null) => null;}codeis the error’sCodeproperty (e.g.,"NOT_FOUND")contextis the error instance itself (cast to extract details)Resolvereturningnullleavesdetailunset;ResolveTitlereturningnullkeeps the error’s ownTitle(or the RFC default title for the status code)
The default NullErrorMessageResolver returns null for everything (no localization); responses carry the code, title, and the error’s structured extension fields.
Example: Simple Dictionary Resolver
Section titled “Example: Simple Dictionary Resolver”The simplest approach (no Resx, no external dependencies):
using Pragmatic.Result.Http;
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.AddPragmaticResult<DictionaryErrorMessageResolver>();or equivalently:
builder.Services.AddSingleton<IErrorMessageResolver, DictionaryErrorMessageResolver>();builder.Services.AddPragmaticResult();Example: IStringLocalizer Resolver
Section titled “Example: IStringLocalizer Resolver”If you use ASP.NET Core’s localization with resource files or JSON providers:
using Pragmatic.Result.Http;
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.AddPragmaticResult<LocalizedErrorMessageResolver>();app.UseRequestLocalization("en", "it", "de");How It Flows
Section titled “How It Flows”AddPragmaticResult() registers DefaultProblemDetailsFactory as the IProblemDetailsFactory. The ASP.NET Core filters (ResultEndpointFilter for Minimal APIs, ResultActionFilter for MVC) resolve it from DI and call it for every failed Result:
- Your action returns
NotFoundError.Create("User", 42)as its failure - The filter catches the failure and calls
IProblemDetailsFactory.Create(error) DefaultProblemDetailsFactorysetsstatusfromerror.StatusCode,typefrom the status code, andtitlefromerror.Title(falling back to the RFC default title)- It calls
IErrorMessageResolver.ResolveTitle("NOT_FOUND", error): a non-null return overridestitle - It calls
IErrorMessageResolver.Resolve("NOT_FOUND", error): a non-null return becomesdetail; null leavesdetailunset - It writes
codeas an extension, then callsError.WriteExtensions()to emit the error’s custom properties as extensions (andretryAfterfor transient errors)
{ "type": "https://httpstatuses.io/404", "title": "Not Found", "status": 404, "detail": "User with ID 42 was not found", "code": "NOT_FOUND", "entityType": "User", "entityId": "42"}Localization Keys: Code and MessageKey
Section titled “Localization Keys: Code and MessageKey”Two related mechanisms, from wire identifier to resource key:
Code (on IError) is the semantic identifier: UPPER_SNAKE_CASE, stable across cultures, always present in the response. It is what IErrorMessageResolver receives.
MessageKey (on the Error base record) is a resource-lookup key derived from Code: lowercase, underscores become dots, prefixed with error., so NOT_FOUND → error.not.found. It is virtual: built-in errors override it per scenario (e.g., ConflictError.AlreadyExists(...) yields error.conflict.already_exists). The derivation is cached per code. Use it in a resolver when your resource files are organized by dotted keys:
public string? Resolve(string code, object? context = null) => context is Error error ? localizer[error.MessageKey].Value : null;Localization is driven entirely by Code (via MessageKey) and the IErrorMessageResolver.Resolve(code) / ResolveTitle(code) pair; there is no generated TitleKey/DescriptionKey property. The source generator’s only Result-related output is the WriteExtensions override for partial Error-derived types with custom properties, so those properties flow into ProblemDetails extensions with zero reflection.
Custom Error Types
Section titled “Custom Error Types”For domain-specific errors, the same pattern applies:
public sealed partial record InsufficientFundsError : Error{ public override string Code => "INSUFFICIENT_FUNDS"; public override int StatusCode => 422; public override string Title => "Insufficient Funds";
public decimal Available { get; init; } public decimal Required { get; init; }}Declaring it partial lets the source generator emit the WriteExtensions override (so available and required appear as ProblemDetails extensions automatically).
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 |
| Detail translation | IErrorMessageResolver.Resolve(code, context) |
| Title translation | IErrorMessageResolver.ResolveTitle(code, context) |
| Resource-key convention | Error.MessageKey (derived from Code) |
| Default behavior | NullErrorMessageResolver: no localization, codes pass through |
| Registration | services.AddPragmaticResult<YourResolver>() |
| Trigger | DefaultProblemDetailsFactory calls the resolver when the filters build the HTTP response |
