DEV Community

Cover image for TinyLocalization: database-backed translations for .NET, without redeploying
Angelo Pirola
Angelo Pirola

Posted on

TinyLocalization: database-backed translations for .NET, without redeploying

Every .NET developer has been there. A customer finds a typo in the Italian translation, and fixing it means editing a .resx file, rebuilding, and redeploying the whole application. For one word.

I wanted translations to behave like data, not like code. So I built TinyLocalization, a small open-source library that stores your translations in a database and plugs into the standard Microsoft.Extensions.Localization abstractions.

The repository is here: AngeloDotNet/TinyDatabaseLocalization.

What it does

The idea is deliberately simple: you keep using IStringLocalizer, but the strings come from a database table instead of resource files. You can add, fix or remove a translation at runtime, and the change is live without a redeploy.

Main features:

  • Standard API: works with IStringLocalizer, IStringLocalizer<T> and IStringLocalizerFactory, so existing code does not need to change.
  • Culture fallback: current culture, then parent cultures, then an optional global fallback, then the invariant culture.
  • Format arguments: localizer["Hello {0}", name] works as you would expect.
  • Four database providers: SQLite, SQL Server, PostgreSQL and MySQL.
  • FusionCache under the hood: fail-safe, timeouts, eager refresh and stampede protection.
  • Optional Redis: as a second-level cache and as a backplane for multi-instance invalidation.
  • One simple table: Translations, unique on (Resource, Key, Culture).

The stack

The library targets .NET 10 and is built on Entity Framework Core. Caching is handled by FusionCache, which I picked over a hand-rolled IMemoryCache wrapper for one reason: caching translations looks trivial until you meet cache stampedes, slow databases and multi-node invalidation. FusionCache already solved those problems, so I did not have to.

Tests are written with xUnit v3 and run against a real SQLite database and a real FusionCache instance, not mocks. Run them with dotnet test.

How a lookup works

This is the part I like most, because it is short:

  1. EfStringLocalizer builds the culture chain, for example it-IT → it → global fallback → invariant.
  2. For each culture, it asks FusionCache for the snapshot of that (resource, culture) pair.
  3. On a cache miss, LocalizationDbContext runs one query that loads all keys of that resource and culture, and caches the result.
  4. The first culture in the chain that contains the key wins.

Two details make a real difference:

  • Loading the whole resource/culture in one query means you do not hit the database once per string.
  • Empty cultures are cached too (negative caching). If you have no fr translations, you do not query for fr on every request.

Getting started

Install the package:

dotnet add package TinyLocalization
Enter fullscreen mode Exit fullscreen mode

Register the services:

builder.Services.AddLocalizationDbContext<LocalizationDbContext>(options =>
{
    options.DatabaseType = DatabaseType.SQLite;
    options.ConnectionString = "Data Source=localization.db";
});

builder.Services.AddDefaultFusionCache();

builder.Services.AddDatabaseLocalization(options =>
{
    options.GlobalFallbackCulture = "en";
});
Enter fullscreen mode Exit fullscreen mode

Then configure request localization in your pipeline as you normally would, and use the localizer:

public class HomeController(IStringLocalizer<HomeController> localizer) : Controller
{
    public IActionResult Index()
    {
        ViewData["Title"] = localizer["Welcome"];
        ViewData["Greeting"] = localizer["Hello {0}", User.Identity?.Name ?? "guest"];
        return View();
    }
}
Enter fullscreen mode Exit fullscreen mode

The resource name for IStringLocalizer<T> is the full name of T.

A fair warning about the schema: the library ships no migrations. For a quick start, EnsureCreated() is fine. For production, create migrations in your own project and point the MigrationsAssembly option at them. I preferred this to forcing my migration history into your database.

Managing translations at runtime

Translations are edited through ILocalizationManager, which writes to the database and invalidates the cache in one go:

await manager.AddOrUpdateAsync(new Translation
{
    Resource = "MyApp.Controllers.HomeController",
    Key = "Welcome",
    Culture = "it",
    Value = "Benvenuto"
}, ct);

await manager.RemoveAsync("MyApp.Controllers.HomeController", "Welcome", "it", ct);
await manager.InvalidateResourceAsync("MyApp.Controllers.HomeController", ct);
Enter fullscreen mode Exit fullscreen mode

An empty culture string means the invariant culture. Concurrent inserts of the same translation are resolved as updates, so two admins saving the same key at the same time will not crash anything.

One thing to remember: if you edit rows directly in the database, the application will not see the change until the cache entry expires (10 minutes by default) or you call InvalidateResourceAsync.

Scaling out with Redis

With a single instance, the in-memory cache is enough. With several instances behind a load balancer, you want an edit on node A to reach node B immediately. For that, use AddDistributedFusionCache (or AddDistributedLogsFusionCache if you want control over FusionCache log levels) and set RedisCacheConnectionString and RedisBackplaneConnectionString.

If you need to tell other systems that translations changed, implement ICacheInvalidationPublisher.

Sensible defaults

Everything is configurable, but the defaults are meant to be safe:

  • Cache duration: 10 minutes
  • FallbackToParentCultures: enabled
  • Missing key: returns the key itself (or an empty string if you set ReturnKeyIfNotFound = false)
  • Fail-safe: enabled, with a 120-minute maximum stale duration, so a database outage does not take your UI strings down with it
  • Factory timeouts: 100 ms soft, 1500 ms hard
  • Malformed format strings stored in the database are returned unchanged instead of throwing

That last one comes from experience: a typo in a translated string should never become a 500 error.

What is missing

The project is young, so let me be honest about the gaps:

  • No admin UI for editing translations. You build your own on top of ILocalizationManager.
  • No migrations shipped, as explained above.
  • No import/export tooling from existing .resx files yet.

If one of these matters to you, open an issue. Priorities follow real needs.

Conclusion

TinyLocalization will not replace .resx for every project, and that is fine. If your translations change often, are edited by non-developers, or must be fixed without a deploy, keeping them in a database with a solid cache in front is a very practical option, and you keep the standard .NET localization API.

The project is MIT licensed. If you want to try it, star it, or contribute, fork the repo, branch from develop, add or update tests, and open a pull request against develop.

A special thanks to Jody Donetti, the author of FusionCache. A big part of this library's resilience is his work.

And you, where do you keep your translations: .resx, JSON, or a database?

Top comments (0)