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>andIStringLocalizerFactory, 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:
-
EfStringLocalizerbuilds the culture chain, for exampleit-IT → it → global fallback → invariant. - For each culture, it asks FusionCache for the snapshot of that
(resource, culture)pair. - On a cache miss,
LocalizationDbContextruns one query that loads all keys of that resource and culture, and caches the result. - 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
frtranslations, you do not query forfron every request.
Getting started
Install the package:
dotnet add package TinyLocalization
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";
});
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();
}
}
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);
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
.resxfiles 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)