This guide explains how to provide translated strings at runtime, configure providers, and integrate with dependency injection.
When you use a generated translation like App.Messages.Welcome("Marco"), here's what happens:
- A
TranslationStringobject is created, holding the key ("App.Messages.Welcome") and the parameter ("Marco") - When it's converted to a
string(implicitly or via.Resolve()), the system checks for a translation provider - If a provider is supplied and has a template for the key, the template is formatted with the parameters
- If no provider is supplied or the key isn't found, the fallback text from the
.tkeysfile is used
flowchart TD
A([TranslationString created]) --> B{Provider given?}
B -- No --> F1["Use fallback text\n'Welcome back, Marco!'"]
B -- Yes --> C{Key found?}
C -- No --> F2["Use fallback text\n'Welcome back, Marco!'"]
C -- Yes --> D["Format template\n'Bentornato, Marco!'"]
LocalizationContext is the central runtime service that holds the active ITranslationProvider and notifies subscribers when it changes. It is designed for dependency injection and supports reactive UI patterns.
using Deck.Dev.Translation.Runtime;
var ctx = new LocalizationContext();
// Set a provider directly
ctx.Provider = new JsonTranslationProvider("Translations/localization.it-IT.json");
// Resolve translations using the provider
TranslationString greeting = App.Messages.Welcome("Marco");
string text = greeting.Resolve(ctx.Provider);
// "Bentornato, Marco!"For automatic provider resolution, supply an ITranslationProviderFactory at construction time:
using System.Globalization;
using Deck.Dev.Translation.Abstractions;
using Deck.Dev.Translation.Runtime;
var factory = new MyProviderFactory();
var ctx = new LocalizationContext(factory);
// Switch to Italian — the factory creates the provider
ctx.SetCulture(new CultureInfo("it-IT"));
// ctx.Provider is now set, ctx.CurrentCulture is "it-IT"
// Revert to fallback (default language)
ctx.SetCulture(null);
// ctx.Provider is null, ctx.CurrentCulture is nullCalling SetCulture without a factory (on the parameterless constructor) throws InvalidOperationException.
using System.Globalization;
using System.IO;
using Deck.Dev.Translation.Abstractions;
using Deck.Dev.Translation.Runtime;
public class JsonFileProviderFactory : ITranslationProviderFactory
{
private readonly string _basePath;
public JsonFileProviderFactory(string basePath) => _basePath = basePath;
public ITranslationProvider? Create(CultureInfo culture)
{
string jsonPath = Path.Combine(_basePath, $"localization.{culture.Name}.json");
return File.Exists(jsonPath) ? new JsonTranslationProvider(jsonPath) : null;
}
}Register LocalizationContext and its dependencies in your DI container. Register the interface ILocalizationContext so consumers can depend on the abstraction (making unit tests easy to mock):
var services = new ServiceCollection();
services.AddSingleton<ITranslationProviderFactory>(
new JsonFileProviderFactory(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Translations")));
services.AddSingleton<LocalizationContext>();
services.AddSingleton<ILocalizationContext>(sp => sp.GetRequiredService<LocalizationContext>());LocalizationContext has a constructor that accepts ITranslationProviderFactory, so the DI container will automatically inject it. Consumers (ViewModels, controllers) should depend on ILocalizationContext rather than the concrete class.
LocalizationContext exposes a ProviderChanged observable that emits the current provider on subscription and again every time the provider changes. This enables reactive UI patterns.
Note: The library itself has no dependency on System.Reactive.
ProviderChangedandObserve()return BCLIObservable<T>. To use the convenient.Subscribe(Action<T>)syntax, addSystem.Reactiveto your own project — or use a framework that already includes it (e.g., ReactiveUI).
The TranslationString.Observe(context) extension method returns an IObservable<string> that re-resolves the translation whenever the provider changes:
using Deck.Dev.Translation.Runtime;
TranslationString greeting = App.Messages.Welcome("Marco");
// Emits immediately with current translation, then again on every provider change
greeting.Observe(ctx)
.Subscribe(text => Console.WriteLine(text));You can also subscribe directly to ProviderChanged for custom logic:
ctx.ProviderChanged
.Subscribe(provider =>
{
// Re-resolve all translations, update UI, etc.
});You can pass a specific provider to a single .Resolve() call:
var italianProvider = new JsonTranslationProvider("Translations/localization.it-IT.json");
var germanProvider = new JsonTranslationProvider("Translations/localization.de-DE.json");
TranslationString greeting = App.Messages.Welcome("Marco");
string italian = greeting.Resolve(italianProvider);
// "Bentornato, Marco!"
string german = greeting.Resolve(germanProvider);
// "Willkommen zurück, Marco!"This is useful for scenarios where you need multiple languages simultaneously (e.g., sending emails in the recipient's language).
By default, Resolve() formats values using the current thread culture (CultureInfo.CurrentCulture). You can pass an explicit IFormatProvider to control locale-specific formatting:
var german = CultureInfo.GetCultureInfo("de-DE");
TranslationString price = App.Messages.OrderTotal(1234.50m);
// Default: uses thread culture
string defaultResult = price.Resolve(); // "Total: $1,234.50" (en-US thread)
// Explicit culture: German formatting
string germanResult = price.Resolve(formatProvider: german); // "Total: 1.234,50 €"This also works with translation providers:
string result = price.Resolve(italianProvider, CultureInfo.GetCultureInfo("it-IT"));For full control over how any value (including object) is converted to a string, implement ICustomFormatter:
class MyFormatter : IFormatProvider, ICustomFormatter
{
public object? GetFormat(Type? formatType)
=> formatType == typeof(ICustomFormatter) ? this : null;
public string Format(string? format, object? arg, IFormatProvider? formatProvider)
{
if (arg is MyMoney m) return m.Display(); // custom types
if (arg is string s) return s.ToUpperInvariant(); // transform strings
if (arg is IFormattable f) return f.ToString(format, formatProvider);
return arg?.ToString() ?? "";
}
}
// Every placeholder value passes through your formatter
string result = greeting.Resolve(provider, new MyFormatter());This follows the same ICustomFormatter convention used by string.Format — your formatter gets first priority, and returning null falls back to the default formatting.
The built-in JsonTranslationProvider loads translations from a JSON file. Keys use the ClassName.Section.Key format, where ClassName comes from the @class directive in the .tkeys file. Both flat and nested JSON formats are supported (or any mix of both):
Flat format:
{
"App.Nav.Home": "Pagina iniziale",
"App.Nav.Settings": "Impostazioni",
"App.Messages.Welcome": "Bentornato, {userName}!",
"App.Messages.ItemCount": "Hai {count} articoli",
"App.Messages.OrderTotal": "Totale: {total:C2}"
}Nested format (recommended):
{
"App": {
"Nav": {
"Home": "Pagina iniziale",
"Settings": "Impostazioni"
},
"Messages": {
"Welcome": "Bentornato, {userName}!",
"ItemCount": "Hai {count} articoli",
"OrderTotal": "Totale: {total:C2}"
}
}
}Nested objects are flattened by joining property names with ., so both formats produce the same keys at runtime.
- Use the same parameter names as in the
.tkeysfile —{userName}, not{name}or{0} - Format specifiers are optional — if omitted, the default from the
.tkeysfile is used - Translators can change format specifiers — e.g., use
{total:N2}instead of{total:C2}if the target locale formats currency differently - Escaped braces — use
{{and}}for literal{and}in translated text
Source file:
Price : Total: {price:C2|decimal}English translation (localization.en-US.json):
{
"Price": "Total: {price:C2}"
}German translation (localization.de-DE.json):
{
"Price": "Gesamt: {price:N2} EUR"
}The German translation uses N2 (plain number) instead of C2 (currency symbol) because it appends "EUR" manually.
Implement ITranslationProvider to load translations from any source — database, API, resource files, etc.:
using System.Diagnostics.CodeAnalysis;
using Deck.Dev.Translation.Abstractions;
public class DatabaseTranslationProvider : ITranslationProvider
{
private readonly Dictionary<string, string> _cache;
public DatabaseTranslationProvider(string locale, IDbConnection db)
{
// Load all translations for this locale into memory
_cache = db.Query("SELECT Key, Template FROM Translations WHERE Locale = @locale",
new { locale })
.ToDictionary(r => r.Key, r => r.Template);
}
public bool TryGetTemplate(string key, [NotNullWhen(true)] out string? template)
{
return _cache.TryGetValue(key, out template);
}
}Then use it via LocalizationContext:
ctx.Provider = new DatabaseTranslationProvider("it", dbConnection);If you never set a provider, everything still works — you just get the fallback text from your .tkeys file. This is the expected behavior during development: you write your keys and fallback text, use the generated API, and add actual translations later.
// No provider set — fallback text is used
string msg = App.Messages.Welcome("Marco");
// "Welcome back, Marco!"
// This is perfectly fine for development and for the default languageKeyTranslationProvider is a diagnostic provider that returns the translation key instead of translated text. This is useful during development to see which keys map to which UI elements.
using Deck.Dev.Translation.Runtime;
// Set the debug provider — all translations show their keys
ctx.Provider = KeyTranslationProvider.Instance;
string text = App.Messages.Welcome("Marco").Resolve(ctx.Provider);
// "Messages.Welcome" (not "Welcome back, Marco!")
string label = App.Navigation.Home.Resolve(ctx.Provider);
// "Navigation.Home"KeyTranslationProvider.Instance is a shared singleton — the provider is stateless, so one instance is safe to reuse everywhere. This makes it easy to add a "show translation keys" toggle in your app's debug settings.
A TranslationString is already a deferred carrier — calling App.Messages.Welcome("Marco") creates an object holding the key and parameters, and resolves only when .Resolve() or implicit string conversion is triggered. For in-process messaging (e.g., passing through an event bus or queue), you can pass TranslationString objects directly and resolve at the UI layer.
For cross-boundary scenarios (serialization, database storage, network transmission), every TranslationString exposes its data for DTO mapping:
TranslationString ts = App.Messages.Welcome("Marco");
// Extract serializable data
string key = ts.Key; // "Messages.Welcome"
string template = ts.FallbackTemplate; // "Welcome back, {userName}!"
var args = ts.GetArguments(); // [{ "userName", "Marco" }]
// Serialize to your DTO format (JSON, protobuf, etc.)
var dto = new TranslationDto(key, template, args);On the receiving side, reconstruct with DeferredTranslationString:
using Deck.Dev.Translation.Runtime;
// Reconstruct from serialized data
var deferred = new DeferredTranslationString(dto.Key, dto.Template, dto.Arguments);
// Resolve — uses TemplateEngine just like generated types
string text = deferred.Resolve(provider);
// "Bentornato, Marco!"
// Or resolve with fallback if no provider
string fallback = deferred.Resolve();
// "Welcome back, Marco!"Note: DeferredTranslationString stores parameters as object? values, so value types are boxed. This is acceptable for the serialization path. The in-process hot path (generated types) remains zero-boxing.
| Member | Description |
|---|---|
Key |
The translation key (e.g., "Messages.Welcome") |
FallbackTemplate |
The fallback template in runtime format ({name} / {name:format}, no |Type annotations) |
Resolve(provider?, formatProvider?) |
Returns the translated and formatted string. Uses the given provider (or fallback text) and format provider (or current thread culture). If the format provider yields an ICustomFormatter, it controls all value formatting. |
GetArguments() |
Returns stored parameters as IReadOnlyList<KeyValuePair<string, object?>>. Empty for literal entries. |
ToString() |
Same as Resolve() |
implicit operator string |
Lets you use a TranslationString anywhere a string is expected |
The interface for testability. Consumers should depend on this rather than the concrete LocalizationContext:
| Member | Description |
|---|---|
Provider |
The active ITranslationProvider. Setting it notifies all ProviderChanged subscribers. |
CurrentCulture |
The culture last passed to SetCulture, or null. |
ProviderChanged |
IObservable<ITranslationProvider?> — emits on subscription and on every Provider change. |
SetCulture(CultureInfo?) |
Switches the active culture via the factory. Pass null to revert to fallback. |
Concrete implementation of ILocalizationContext.
| Member | Description |
|---|---|
LocalizationContext() |
Creates a context without a factory. Provider can be set directly but SetCulture is limited to null. |
LocalizationContext(ITranslationProviderFactory) |
Creates a context with a factory, enabling SetCulture. |
Dispose() |
Completes the ProviderChanged observable. |
| Member | Description |
|---|---|
Create(CultureInfo) |
Creates an ITranslationProvider for the given culture, or returns null. |
| Constructor | Description |
|---|---|
JsonTranslationProvider(string filePath) |
Loads translations from a JSON file on disk |
JsonTranslationProvider(Dictionary<string, string> translations) |
Uses an in-memory dictionary (useful for testing) |
| Member | Description |
|---|---|
Instance |
Shared singleton instance (stateless, safe to reuse) |
TryGetTemplate(key, out template) |
Always returns true with template = key — every entry resolves to its key |
| Constructor | Description |
|---|---|
DeferredTranslationString(string key, string fallbackTemplate, IReadOnlyList<KeyValuePair<string, object?>> arguments) |
Creates a deferred entry from serializable components. Uses TemplateEngine for resolution. |
| Method | Description |
|---|---|
TranslationString.Observe(ILocalizationContext, IFormatProvider?) |
Extension method returning IObservable<string> that re-resolves on provider change. Optional format provider for locale-aware formatting. |