A containerized microservice providing a centralized translation source of truth. Distributed as a standalone Docker container, it is ready to be deployed via Docker Compose or orchestrated environments (Kubernetes, ECS).
The service provides "working drafts" for active translation editing through a built-in UI, and allows freezing deterministic, immutable published releases for production runtime. It exposes an API that retrieves localized text in multiple native formats (Android, iOS, Web, backend) and user-defined formats via custom templates.
Core principles:
- Canonical YAML storage: Single source of truth saved into the container file system.
- Draft & Release states: Actively edit draft branches (e.g.,
main); freeze deterministic releases (e.g.,1.0.0) for production. - Platform-agnostic output: Native formats (including plurals mapping) plus custom Handlebars templates.
- File-based Generation: Artifacts are generated on demand, cached safely using atomic file system operations, and served rapidly.
Translations, templates, and internally generated files are stored in a centralized storage root that can be mounted into a docker volume or AWS EFS, Kubernetes ReadWriteMany PV to prevent split-brain issues across multiple container replicas. The absolute path to this storage root must be injected into the Spring Boot application via an environment variable (e.g., TRANSLATION_STORAGE_ROOT), defaulting to ./storage for local development.
The structure isolates drafts (working states) from releases (frozen versions), and maintains a .generated directory for compiled artifacts.
<storage>/library-name/drafts/main/config.yaml
<storage>/library-name/drafts/main/custom-format.hbs
<storage>/library-name/drafts/main/login/en-US.yaml
<storage>/library-name/releases/1.0.0/config.yaml
<storage>/library-name/releases/1.0.0/custom-format.hbs
<storage>/library-name/releases/1.0.0/login/en-US.yaml
<storage>/library-name/releases/1.0.0/.generated/...
<storage>/library-name/drafts/1.0.x/... # Hotfix branches
Library, branch and section naming: Lowercase letters, digits, and hyphens only. Must begin and end with a letter or digit. Locale naming: Lower case for the language, and Upper case for the country
When a draft is frozen, all files — YAML translations, config.yaml, and all template files — are cloned into the release directory. After freezing, the release is immutable; only .generated/ artifacts are ever written to it.
Follows Semantic Versioning (MAJOR.MINOR.PATCH). Releases are immutable once frozen and cannot be deleted. Version strings must use the strict MAJOR.MINOR.PATCH format with non-negative integers. The v prefix and partial versions (e.g., 1.0) are not accepted.
| Change type | Bump |
|---|---|
| Removed/renamed keys, modified templates | MAJOR |
| New keys, new section, new locale, or custom format | MINOR |
| Wording fixes or pluralization tweaks only | PATCH |
- Active Development: Edits via the UI typically happen on the
mainbranch. Requests targeting a draft branch are dynamically generated on API calls to reflect immediate updates. - Freezing Releases: A specific endpoint freezes a valid draft state into an immutable release version (e.g.,
1.0.0). Once frozen, releases are static. The source draft branch is not deleted after a successful freeze — it remains editable and continues to serve dynamic requests independently. - Hotfixing (Branching): If
1.0.0is in production butmainhas already moved to2.0.0, developers can branchreleases/1.0.0into a new draftdrafts/1.0.x, apply the fix, and freeze as1.0.1. - Artifact Storage: When a release is requested, the service checks the internal
.generated/path. If missing, it generates the file using atomic write operations (writing to a.tmp-hashfile and renaming) to prevent race conditions, then serves it.
Keys must be declared with underscores as word separator characters (sign_in, login_button).
Duplicate keys are allowed as long as they reside in different sections.
Uses ICU MessageFormat syntax. Standard variables and Plurals are explicitly supported and mapped to native equivalents at generation time (e.g., Android <plurals> and iOS .xcstrings).
hello_username: "Hello {name}"
cart_count: "{count, plural, =0 {Cart is empty} one {1 item in cart} other {{count} items in cart}}"To support arbitrary application requirements, custom formats can be defined using Handlebars (.hbs) templates. Templates define the generation of a single file which may contain one section, N sections or even N libraries. The microservice injects the following JSON data model into the template, regardless of how many sections or libraries were requested:
[{
"library": "users",
"version": "1.0.0",
"locale": "en-US",
"sections": [
{
"name": "login",
"translations": [
{ "key": "sign_in", "value": "Sign in" }
]
}
]
}]Note on
version: For frozen releases,versionis the semver string (e.g.,"1.0.0"). For draft branches,versionis the branch name (e.g.,"main","1.0.x").
Because the data model always exposes the full library/section/key hierarchy explicitly (rather than a flattened key-value map), custom formats never require key prefixing to avoid collisions, no matter how many sections or libraries are requested in a single call. Disambiguation is entirely up to the template author, who has direct access to library, version, and name (section) at every level.
Example Template (env-format.hbs):
The following helpers are available to all templates in addition to the standard Handlebars built-ins ({{#each}}, {{#if}}, {{#unless}}, {{#with}}).
| Helper | Signature | Description |
|---|---|---|
upper |
{{upper value}} |
Converts a string to UPPERCASE |
lower |
{{lower value}} |
Converts a string to lowercase |
Inside {{#each translations}} blocks, standard Handlebars data variables are available: {{@index}}, {{@first}}, {{@last}}.
Required at the root of every library branch. It is created always as part of a new branch, and will be initialized with default values for mandatory settings. A missing or malformed config.yaml will cause freezes to be rejected.
base_locale: "en-US" # Primary locale — defines the master key set
on_missing_translation: "error"
# Optional hierarchical fallback chains.
# Only meaningful when on_missing_translation is "fallback".
# Ignored (with a Phase 1 warning) if any other mode is configured.
fallback_chains:
"es-AR": "es-ES" # If a key is missing in es-AR, look in es-ES
"es-MX": "es-ES"
"fr-CA": "fr-FR"
"en-GB": "en-US"
# Optional custom formats bindings
custom_formats:
- name: "env" # The {format} used in the API URL
extension: "env" # The {extension} used in the API URL
template: "env-format.hbs" # The Handlebars template at the root of the versionon_missing_translation modes:
| Mode | Behavior |
|---|---|
error |
Reject the library at freeze time if any locale in any section is missing keys defined by base_locale. Phase 1 saves emit a non-blocking completeness warning in the API response when this mode is active, alerting editors before freeze. |
fallback |
1. Traverse fallback_chains if the locale is mapped. 2. If unmapped or the chain is exhausted, implicitly drop the region code (e.g., es-AR → es). 3. If still missing, use the value from base_locale. |
return_key |
Replace missing value with the raw key name (e.g. "login"). |
return_empty |
Replace missing value with "" (Preserving plurals if necessary). |
A given {name}/{extension} pair in custom_formats must be used together at request time (see §7.6.1). A mismatched pairing (e.g. a known name requested with the wrong extension) is rejected with 400 BAD_REQUEST.
To prevent workflow gridlock while ensuring production stability, validation rules are split into two phases:
Phase 1: File-Level (Runs on Save/Edit API Calls)
- YAML 1.2 strict syntax (prevents boolean casting of
no,true). - Underscore-separated, alphanumeric lowercase key format.
- Valid ICU MessageFormat syntax (plurals/variables).
config.yamlstructural validation (e.g., ensuringfallback_chainscontains no circular references, such ases-ES→es-AR→es-ES).- Handlebars template compile check:
.hbsfiles are parsed and compiled at save time. Syntax errors are rejected with a400 BAD_REQUESTbefore writing to disk.
Phase 2: Cross-File / Integrity (Runs strictly on Freeze/Publish)
- Placeholder consistency across locales (e.g.,
es-ESmust contain the same ICU variables asen-USfor every key present in both). - No orphan keys in non-primary locales (keys not present in
base_locale). - Locales referenced in
fallback_chainsmust exist within the library. - Template files referenced in
config.yamlexist and compile successfully. config.yamlis present at the branch root.- If
on_missing_translation: erroris set, verifies 100% key completeness across all locales.
/api/v1
None of the microservice endpoints require authentication. This microservice is meant to be used internally, so authentication is delegated to the infrastructure where this container will live.
GET /dashboardServes the admin web UI. Supports: creating and deleting libraries, branches, sections, and locales; editing translations, config.yaml, and Handlebars templates; publishing (freezing) releases.
| Method | Path | Request body | Success | Notes |
|---|---|---|---|---|
GET |
/api/v1/manage |
— | 200 ["users","products"] |
List libraries |
POST |
/api/v1/manage/{library} |
{"base_locale","on_missing_translation"} |
201 {"library","branch"} |
Creates library with main branch + drafts/main/config.yaml |
GET |
/api/v1/manage/{library} |
— | 200 {"library","drafts":[],"releases":[]} |
Library overview |
GET |
/api/v1/manage/{library}/releases |
— | 200 ["1.0.0","1.1.0"] |
List frozen releases |
DELETE |
/api/v1/manage/{library} |
— | 204 |
Deletes library and all drafts; releases retained |
POST |
/api/v1/manage/{library}/branches/{branch} |
{"source"} |
201 {"library","branch","source"} |
source may be a draft name or release version; clones all files |
GET |
/api/v1/manage/{library}/drafts/{branch} |
— | 200 {"sections":{},"templates":[],"has_config":true} |
Branch overview |
DELETE |
/api/v1/manage/{library}/drafts/{branch} |
— | 204 |
— |
GET |
/api/v1/manage/{library}/drafts/{branch}/config |
— | 200 YAML |
Raw config.yaml |
PUT |
/api/v1/manage/{library}/drafts/{branch}/config |
YAML body | 204 |
Phase 1 validation before save |
GET |
/api/v1/manage/{library}/drafts/{branch}/templates/{template} |
— | 200 text |
Raw .hbs content |
PUT |
/api/v1/manage/{library}/drafts/{branch}/templates/{template} |
text body | 204 |
Phase 1 compile check before save |
DELETE |
/api/v1/manage/{library}/drafts/{branch}/templates/{template} |
— | 204 |
config.yaml refs → Phase 2 failure at freeze |
POST |
/api/v1/manage/{library}/drafts/{branch}/{section} |
— | 201 {"section","branch"} |
Create section |
DELETE |
/api/v1/manage/{library}/drafts/{branch}/{section} |
— | 204 |
Frozen data retained |
POST |
/api/v1/manage/{library}/drafts/{branch}/{section}/{locale} |
optional YAML | 201 |
{locale} must be a valid formatted locale; Phase 1 runs if body provided |
GET |
/api/v1/manage/{library}/drafts/{branch}/{section}/{locale} |
— | 200 YAML |
Raw translation file |
PUT |
/api/v1/manage/{library}/drafts/{branch}/{section}/{locale} |
YAML body | 204 / 200+warnings |
Phase 1; returns 200 with warnings body if on_missing_translation: error and keys are missing |
DELETE |
/api/v1/manage/{library}/drafts/{branch}/{section}/{locale} |
— | 204 |
Frozen data retained |
GET |
/api/v1/manage/stream |
— | SSE | Broadcasts save events to all connected clients |
Locale warning response body (200 OK):
{
"warnings": [{
"code": "INCOMPLETE_LOCALE",
"locale": "es-ES",
"section": "login",
"missing_keys": ["sign_out", "forgot_password"],
"message": "2 key(s) present in base locale 'en-US' are missing. Freeze will fail until resolved."
}]
}SSE event shape:
{ "library": "my-app", "branch": "main", "section": "login", "locale": "es-ES", "change": "updated|created|deleted", "timestamp": "2024-05-10T14:30:00Z" }Concurrency management:
- Active sessions are notified of file changes in real time via SSE (
GET /api/v1/manage/stream). - GET endpoints return an ETag header (e.g., a hash of the file contents).
- PUT endpoints require an If-Match: ETag header.
- If the backend detects the file has changed since the user loaded it, it returns 412 Precondition Failed. The UI can then fetch the latest changes and alert the user, completely preventing data loss.
POST /api/v1/manage/{library}/freeze
{"source_branch": "main", "version": "1.0.0"}Runs Phase 2 validation then clones the draft into an immutable release.
| Outcome | Status | Body |
|---|---|---|
| Success | 201 |
{"library","version"} |
| Version already exists | 409 |
Standard error |
| Validation failure | 422 |
{"error","message","validation_errors":[...]} |
Validation error codes:
| Code | Description |
|---|---|
MISSING_CONFIG |
config.yaml absent from source branch |
MISSING_KEY |
Key from base_locale absent in another locale (on_missing_translation: error only) |
ORPHAN_KEY |
Key in non-primary locale has no counterpart in base_locale |
PLACEHOLDER_MISMATCH |
ICU variables differ from the base_locale equivalent |
BROKEN_FALLBACK_CHAIN |
A fallback_chains target locale does not exist in the library |
MISSING_TEMPLATE |
Template referenced in config.yaml not found in branch |
TEMPLATE_COMPILE_ERROR |
Referenced template fails Handlebars compilation |
When the user requests several sections or libraries at once, translations are combined into a single file. How that combination is represented depends on whether the requested format natively supports namespacing multiple sections/libraries within one file — see §7.6.1.
A) Single Section Request:
GET /api/v1/translations/{library}/releases/{version}/{format}/{locale}/{section}.{extension}
GET /api/v1/translations/{library}/drafts/{branch}/{format}/{locale}/{section}.{extension}A single-section request never requires key prefixing, regardless of format, since there is nothing to disambiguate against.
B) Multi-Section or Full Library Request:
GET /api/v1/translations/{library}/releases/{version}/bulk?targets=section1,section2&format={format}&locale={locale}&extension={extension}
GET /api/v1/translations/{library}/releases/{version}/{format}/{locale}.{extension}
GET /api/v1/translations/{library}/drafts/{branch}/bulk?targets=section1,section2&format={format}&locale={locale}&extension={extension}
GET /api/v1/translations/{library}/drafts/{branch}/{format}/{locale}.{extension}The {format}/{locale}.{extension} variant (no targets) returns all sections in the branch/release and is treated as a multi-section request for the purposes of the rules below.
C) Multi-Library request:
GET /api/v1/translations/bulk?targets=library1:version,library2:version,library3:branch&format={format}&locale={locale}&extension={extension}For each entry in targets, the service determines whether the identifier is a release version or a draft branch by checking it against the strict semver format defined in §3: a match is resolved against releases, anything else against drafts. This is treated as a multi-library request (and implicitly multi-section, since it flattens every requested library's sections into one file) for the purposes of the rules below.
format |
extension |
Native multi-section support | Native multi-library support | Notes |
|---|---|---|---|---|
android |
xml |
No | No | Flat key-value resource file; ICU plurals → <plurals> |
ios |
xcstrings |
No | No | Flat string catalog; plurals handled natively |
json |
json |
Yes | Yes | Nested JSON objects (see below); raw ICU strings |
gettext |
po |
Yes | Yes | Namespacing via msgctxt (see below); standard Gettext |
| (custom) | (custom) | Yes (always) | Yes (always) | Resolved via custom_formats in config.yaml; see §4.3 — never prefixed |
Formats without native support (android, ios):
- If multiple sections are requested but the format has no native multi-section support, every key is prefixed with
{section}_. - If multiple libraries are requested but the format has no native multi-library support, every key is prefixed with
{library}_{section}_. - Hyphens in library and section names are converted to underscores when constructing the prefix, to keep the resulting key underscore-only per §4.1.
Formats with native support (json, gettext):
- No key prefixing is ever applied.
json: for a multi-section, single-library request, translations are nested one level under their original section name (e.g.,{"login": {"sign_in": "Sign in"}, "profile": {...}}). For a multi-library request, an additional nesting level is added for the library name (e.g.,{"users": {"login": {...}}, "products": {...}}). Section and library names are used as-is (hyphens preserved, since JSON object keys are not constrained by the §4.1 key format).gettext: for a multi-section, single-library request, each entry'smsgctxtis set to the original section name. For a multi-library request,msgctxtis set to{library}/{section}. Names are used as-is (hyphens preserved).
Custom formats: never require prefixing or implicit nesting — see §4.3. The template always receives the full library/section/key hierarchy and is responsible for its own disambiguation.
For Frozen Releases:
Artifacts are checked against .generated/. Missing files are generated using atomic writes (temp-write → rename) to prevent race conditions during high concurrency.
Response headers: Cache-Control: public, max-age=31536000, immutable.
For Active Drafts:
Dynamically computed on every request to ensure the absolute latest edits are served.
Response headers: Cache-Control: no-store.
- Handlebars template execution must be strictly bounded (e.g., maximum 2000ms execution time, 5MB max output size limit) to prevent malicious or poorly written templates from causing JVM Out-Of-Memory (OOM) crashes or CPU starvation.
All errors return {"error": "ERROR_CODE", "message": "Human-readable description."}.
| HTTP Code | error value |
Meaning |
|---|---|---|
400 |
BAD_REQUEST |
Malformed request, invalid version format, unsupported format/extension combination, or a custom format name/extension mismatch. |
404 |
NOT_FOUND |
Library, branch, version, section, or locale not found. |
409 |
CONFLICT |
Attempted to freeze to a version string that already exists. |
422 |
UNPROCESSABLE_ENTITY |
Integrity validation failures during a freeze operation. See 7.5 for the full validation error structure. |
500 |
INTERNAL_ERROR |
Internal transformation failure (e.g., I/O write error, Handlebars rendering error). |
412 |
PRECONDITION_FAILED |
File modified by another user since last retrieved. UI must resolve the conflict. |
Logging & Auditing:
- Application logs via SLF4J/Logback.
Metrics & Health:
Exposed via Spring Boot Actuator (/actuator):
/actuator/prometheus: Disk-based generation metrics, request latency, JVM memory./actuator/health/liveness&readiness: Standard health probes for Kubernetes/ECS integration.
- Dashboard UI: Serves the admin web UI as a Single Page Application (SPA) built with Angular 22. It must use Angular's idiomatic HttpClient and RxJS Observables to communicate with the REST endpoints, and EventSource for the SSE stream. The UI should be styled using Angular Material and use signals for visual reactivity.
- Backend: Java 21, Spring Boot 3.2+, Gradle, fully containerized. For YAML parsing jackson-dataformat-yaml, handlebars.java for template compilation and ibm.icu4j for ICU MessageFormat/Plural validation.
- Containerization: Multi-stage Dockerfile using eclipse-temurin:21-jdk-alpine for the build step and eclipse-temurin:21-jre-alpine for the runtime step. The container exposes port 8080 and defines a VOLUME for the storage root. Ready to use as a service on docker compose.
- Backend Testing: JUnit 5 integration tests using @SpringBootTest for all possible use cases, including Phase 1 and Phase 2 validation, and use MockMvc to test the API endpoints.
- Frontend Testing: Playwright tests to check the full e2e behaviour of the UI.