I made translations editable without replacing Gettext
Plantronic needed domain labels that could change without a release. I kept compiled Gettext catalogs for application copy, added a database fallback for operational vocabulary, and used ETS and PubSub to make edits visible.
- Published
- Updated
- Reading time
- 12 min read
The first translations I needed to make editable were not button labels or paragraphs. They were field names arriving from another system.
Plantronic could receive task metadata with identifiers such as project_manager or machine_type. The metadata renderer knew where to place each field, but it did not know every field at compile time. Showing those identifiers directly would expose integration vocabulary to planners. Turning each possible value into a template branch would defeat the point of accepting flexible metadata.
At the same time, Plantronic was gaining conventional interface localization. Headings, buttons, validation errors, and process labels belonged in Gettext catalogs. They changed with the code, could be extracted from source, reviewed in .po files, and shipped with a release.
I did not want two translation APIs: Gettext for application copy and a custom database lookup whenever a string happened to come from metadata. Call sites should be able to ask for a translation without knowing where it was stored.
The solution was smaller than replacing Gettext with a content-management system. I kept compiled catalogs as the primary source and used Gettext’s missing-translation callbacks as an extension point. If a locale file knew the message, Gettext returned it normally. Only when the catalog had no entry did Plantronic look in a database-backed cache.
That ordering is the most important part of the design.
There were two kinds of vocabulary
Static application text and operational labels may look identical on a screen, but they have different owners and change for different reasons.
A phrase such as “Change Password” belongs to the application. Its location in source matters, translators benefit from surrounding context, and a wording change may accompany changed behavior. Keeping it in normal Gettext files makes the translation part of the same review and release as the feature.
A metadata key is different. It can originate in a database view or external API and may not appear as a literal anywhere in the Elixir source. Gettext extraction cannot discover every value that another system might send. A domain expert may also need to improve the displayed term without waiting for a software release.
Plantronic normalized these source labels into stable identifiers and passed them through the same helper used for other translations:
t(metadata["field_name"])
The helper called Gettext rather than querying a translations table directly. That preserved one vocabulary interface for templates while leaving storage policy inside the translation backend.
The effective lookup path was:
compiled Gettext catalog
→ database translation cache when the message is missing
→ original message identifier as the final fallback
This was not an override mechanism. An administrator could add a translation for dynamic user content, but could not silently replace a translation already compiled into the application. Correcting ordinary interface copy still went through the repository and a release. The administration screen eventually described its contents more precisely as “additional translations of user content.”
I like that boundary in hindsight. Editable text is useful, but making every word in an application mutable at runtime creates a second deployment system with weaker review.
The database used Gettext’s vocabulary
The translation table did not invent a simpler key-value convention. Its columns followed Gettext concepts: locale, domain, message context, singular message identifier, plural message identifier, translated string, plural strings, and a comment.
A unique index covered the identifying fields, expressing the intent that one row should answer a given lookup. There was a subtle hole: context and plural identifiers were nullable, and ordinary PostgreSQL uniqueness treats null values as distinct. The index therefore did not prevent every duplicate identity unless those nulls were normalized or declared NULLS NOT DISTINCT. The Ecto changeset required the basic identity and translation values before an administrator could save a row.
Keeping domains and context may seem excessive for translating metadata labels. It avoided narrowing the extension so far that it stopped behaving like Gettext. The missing-translation callback already received those values, and storing them preserved the lookup semantics callers expected.
The Gettext backend documentation defines callbacks for singular and plural messages that are absent from a locale. That is exactly where Plantronic connected the database layer. The backend passed the locale, domain, context, message identifiers, count, and interpolation bindings to the cache-backed fallback.
Bindings still went through Gettext’s runtime interpolation after lookup. A database translation such as Hallo %{name}! therefore behaved like the same message from a .po file. The fallback to the original message identifier also remained interpolated, so a missing translation did not leave raw placeholders on the page.
This reuse mattered more than the table itself. Templates continued using gettext, ngettext, or a small dynamic helper. They did not acquire database concerns.
Locale selection had to work for requests and LiveView
Adding translations is only useful if every rendering process agrees on the active locale.
Plantronic configured an explicit default and a short list of allowed locales. A Plug accepted a valid locale parameter, otherwise restored a saved cookie, and finally fell back to the default. The chosen locale was written to the response cookie and session.
LiveView needed the same policy during mount. Gettext locale state is associated with the process doing the translation, so setting it in the initial HTTP request was not enough for a long-lived LiveView process. An on_mount hook restored the locale from parameters or session before rendering.
The user settings page exposed the allowed locales and made the persistence model clear: the choice belonged to that browser. It was not yet an account preference synchronized across devices.
This part of localization is easy to overlook because the visible feature is a language selector. The actual contract spans a request process, session, cookie, and LiveView process. Without one shared policy, the initial page and subsequent live updates can render in different languages.
The first cache made every read visit a process
Database lookup on every translated metadata label would have been wasteful. A task page can render many labels, and translations change rarely compared with how often they are read.
My first implementation loaded every translation into a map held by an Agent. The key contained the same five-part identity used by Gettext:
{locale, domain, context, msgid, msgid_plural}
The value held the singular and plural translated forms. On a cache miss, the Agent version could query the database directly. A full refresh replaced the map with a new snapshot.
This worked, and the tests made the snapshot behavior explicit. A direct database update did not change the rendered translation until the cache was refreshed.
It was not a good final read path. Agent.get/2 sends every lookup through one process. Translation reads are independent and overwhelmingly concurrent; serializing them through an Agent added coordination where none was needed.
I replaced the Agent with a GenServer-owned ETS table shortly afterward. The GenServer created the named table, loaded it, and handled refresh commands. Callers read ETS directly without sending a message to the owner.
The ETS documentation describes set lookup as constant time regardless of table size and notes that the creating process owns the table. That split suited the workload: one supervised process managed lifecycle and replacement, while request and LiveView processes performed direct reads.
A missing table or lookup failure returned the original message identifiers rather than crashing page rendering. Startup later tolerated an unavailable translation query as well. The cache made labels readable, but its failure could not prevent the application from starting or a task page from loading.
The administration screen exposed an invalidation gap
The first ETS implementation made reads fast, but it weakened the promise made by the administration screen.
Creating or editing a translation updated PostgreSQL. It did not automatically rebuild ETS. Tests called update_all/0 explicitly, but the normal context operations did not. An administrator could save a correction successfully and continue seeing the old value until some other refresh occurred.
That is the familiar half-finished state of a cache: the read path is optimized before the write path has a complete invalidation story.
I later closed the gap with Phoenix PubSub. Successful translation inserts and updates broadcast an event on a translations topic. The cache process subscribed to that topic and rebuilt its local ETS table after receiving an event. Deletion needed its own event shape and two follow-up fixes before it followed the same path.
Phoenix PubSub provides the subscribe-and-broadcast interface and can distribute messages across connected Elixir nodes. Each Plantronic node could therefore keep a local ETS read path while hearing about edits made through another process or node.
The implementation rebuilt the complete table after every change. For a small translation set and rare administrative writes, that was easier to reason about than updating individual keys correctly across create, identity-changing update, and delete operations.
It still has a trade-off. The table is cleared before it is repopulated, so a concurrent read can briefly fall back to the message identifier. A larger or more frequently edited catalog would justify building a replacement table and swapping it, versioning snapshots, or applying precise key updates. The simple rebuild matched the actual scale, but the fallback behavior should be understood rather than treated as magic.
Editable translations carry more risk than their size suggests
A translation row is small, but it can affect every page that asks for that message. The administration feature therefore sits closer to code than ordinary page content.
Interpolation placeholders are part of the contract. If the source message contains %{name}, a translation that drops or renames the binding can produce missing-binding behavior. Long text can damage a layout designed around a short label. Terminology changes can make training material and support instructions inconsistent with the application. A mistaken domain or context can make a valid translation appear to be missing.
Restricting the screen to administrators was necessary, but permissions are only one part of ownership. For important operational vocabulary I would also want clear change history, preview in context, and validation that translated placeholders match the source message.
The compiled-catalog-first rule limited the blast radius. Runtime edits applied to additional vocabulary rather than every static sentence in the interface. If the cache was absent or a row could not be found, users saw a stable identifier instead of an exception.
Plural rules were where the abstraction leaked
The weakest part of the implementation was plural selection.
The database schema could store a list of plural translations, and the missing-plural callback received n. The fallback code selected a list element using the count and returned the first element again for larger values. That happened to satisfy the narrow tests, but it was not Gettext plural semantics.
Plural forms are selected by a locale-specific formula, not by treating the quantity as an array index. The GNU Gettext manual explains that languages differ both in how plurals are formed and in how many plural forms they require. Even languages with two forms generally select singular for one and plural for every other relevant count; they do not have separate array entries for two, three, four, and so on.
The compiled .po path already had correct plural machinery. By implementing database plural selection separately, I created exactly the kind of second translation system I had tried to avoid.
I would change that design before relying on editable plural messages. The database should store forms in a representation compatible with the locale’s plural module, and Gettext’s plural-rule implementation should choose the form. At minimum, tests should cover counts beyond one and two for every allowed locale and assert linguistically correct output rather than codifying the current indexing behavior.
This does not invalidate the singular operational-label use case that motivated the feature. It does show where an extension point stops being a thin adapter and starts reimplementing a mature library.
What I would keep
I would keep the separation between compiled application translations and editable operational vocabulary.
Gettext remains the public API and the first source of truth. Dynamic identifiers that extraction cannot see have a controlled fallback. The database uses Gettext’s identity fields instead of inventing a parallel naming convention. ETS makes the common read path local and cheap. A supervised process owns cache lifecycle, and PubSub connects successful writes to refreshes on running nodes.
I would improve several details: normalize and validate placeholders, delegate plural selection, make cache replacement atomic, add stronger audit history, and test invalidation through the same context functions used by the administration screen.
“Editable translations” turned out to be a cluster of responsibilities. Vocabulary ownership and precedence determined which text could change. Locale propagation, fallback, and interpolation shaped each lookup. Cache invalidation, multi-node visibility, permissions, and plural rules appeared after the table and form were already working.
Plantronic avoided the most confusing outcome because database translations never silently displaced the catalogs shipped with the application. They filled a specific gap: operational labels that existed as data before they existed as source code.
That made translations editable where runtime editing was useful, while Gettext continued to define what translation meant.