/2025/03/10/dynamic-translation-notes

Notes on Dynamic Translation

中文

Dynamic translation is useful when the same API response needs to keep one stable shape while presenting text in the language requested by the client.

The naive solution is to add separate fields everywhere: nameCn, nameEn, descriptionCn, descriptionEn, and then let every frontend decide what to display. That works for a small object, but it ages badly. The response becomes noisy, and every client has to repeat the same language-selection logic.

The design I prefer is different: store translations as structured metadata, then choose the visible value at serialization time.

1. Keep the Domain Field Stable

The field the client reads should remain ordinary:

{
"name": "Warehouse A",
"address": "Some visible address"
}

The client should not need to know whether name came from the original field, a Chinese translation, an English translation, or a fallback.

Internally, the object can carry a language source:

{
"name": "仓库 A",
"address": "某个地址",
"language": {
"name": {
"cn": "仓库 A",
"en": "Warehouse A"
},
"address": {
"cn": "某个地址",
"en": "Some visible address"
}
}
}

The public response can still hide language and only render the selected values.

2. Use Request Context for Selection

Language selection should usually come from request context, not from every service method.

The basic rule is:

requestedLanguage = request.headers["Accept-Language"]

Then serialization can decide:

if requestedLanguage is blank:
write original value

if requestedLanguage is "zh-CN":
write language[fieldName].cn if present

if requestedLanguage is "en-US":
write language[fieldName].en if present

otherwise:
write original value

This lets the business method return the same object no matter who calls it. The language decision is a presentation concern, so it belongs close to output formatting.

3. Mark Source and Rendered Fields Explicitly

Two annotations are enough for the shape I like:

class ResultDTO {
@TranslatedField("name")
private String name;

@TranslatedField("address")
private String address;

@TranslationSource
private LanguageMap language;
}

The rendered field says, “when language output is requested, look up this key.” The source field says, “this object contains the translations.”

That makes the serializer generic. It does not need to know about warehouses, contracts, categories, articles, or any other domain model.

4. Serialization Is the Right Hook

A contextual serializer can inspect the field annotation, find the parent object, read the language source, and write the translated value.

Pseudocode:

function serialize(value, fieldAnnotation, generator, provider):
requestedLanguage = getAcceptLanguage()

if value is null:
write null
return

if requestedLanguage is blank:
write value
return

if current field is TranslationSource:
write null
return

if current field has TranslatedField:
parent = generator.currentValue
source = findField(parent, annotatedWith = TranslationSource)
key = fieldAnnotation.value
translated = source.get(key)

if translated exists:
if fieldAnnotation.fixedLanguage is not blank:
write translated[fieldAnnotation.fixedLanguage]
else:
write translated[requestedLanguage]
return

write value

This is dynamic without making every endpoint dynamic. The same DTO can be returned from many methods, and the language behavior follows the response object.

5. Generate Translations When Text Enters the System

There are two places to translate:

  • when the response is read
  • when the text is created or updated

For user-facing business data, I prefer translating when text enters the system. That means a create or update flow can build a language map once:

function buildLanguageInfo(text, sourceLanguage = auto):
info = new LanguageInfo()

try:
info.cn = translate(text, sourceLanguage, "zh-CN")
catch:
info.cn = text

try:
info.en = translate(text, sourceLanguage, "en-US")
catch:
info.en = text

return info

This keeps reads fast and predictable. It also avoids calling an external translation service during every list or detail request.

The tradeoff is that updated source text needs to refresh its language map. That is a reasonable tradeoff for most admin or content-style data.

6. Fallbacks Matter More Than Perfect Translation

A translation system should fail soft.

If translation generation fails, store the original text as fallback. If the requested language value is blank, return the original field. If the request does not include a language header, do nothing.

The fallback chain can be simple:

function chooseText(original, languageInfo, requestedLanguage):
if requestedLanguage is blank:
return original

candidate = languageInfo[requestedLanguage]

if candidate is not blank:
return candidate

return original

This is not glamorous, but it prevents i18n from becoming a source of broken responses.

7. Allow Fixed-Language Fields When Needed

Sometimes a field should always return one language regardless of request header. For example, an external integration may require an English address, or a document export may need a fixed language.

That should be explicit:

@TranslatedField(value = "address", language = "en-US")
private String addressForExport;

The key idea is that fixed language should be local and visible. It should not be hidden in a service method where future callers will miss it.

Current Rule

For dynamic translation, my rule is:

store all language variants once, choose the visible value late

This keeps API shapes stable, keeps clients simple, and keeps language behavior close to serialization instead of spreading it through business code.