AdaTrack v1.4: Localizing a Modular Monolith End-to-End
Published: | Category: Engineering
IoT platforms do not stay confined to the markets where they were built. Industrial operators in Central Europe run hardware fleets on the same infrastructure as logistics companies in North America — and increasingly, they expect the software to meet them where they are, in the language they work in, without compromising on data protection. AdaTrack launched with English hardcoded across every layer of the stack: HTTP error responses from Go handlers, email subjects, alert notification bodies, Telegram bot command replies, and five hundred strings scattered across a React dashboard. For a single-market deployment, that was acceptable. For a platform expanding into GDPR-regulated European markets, it was a liability. AdaTrack v1.4 ships full internationalization infrastructure across the entire platform, starting with Slovak as the first non-English locale, alongside GDPR-compliant analytics on the marketing site.
The Problem With Localizing a Monolith
Internationalizing a single-page application is a well-understood problem. Internationalizing a modular monolith — where a Go binary handles HTTP requests, dispatches emails via Mailgun or SMTP, evaluates alerting rules, runs JavaScript decoders in Goja VM pools, and forwards notifications to Telegram and Slack — is considerably more involved. Every layer that produces user-visible text must participate, and each layer has different execution contexts, different concurrency models, and different requirements for where the language preference comes from.
The core architectural constraint we imposed was that language context must be threaded through the system, not stored globally. A global locale setting would mean the server processes all requests in one language — obviously wrong in a multi-tenant SaaS environment. Instead, every operation that generates user-visible output needs to know whose output it is producing, and then look up that user's saved language preference to produce the right string.
Backend: Per-Request Language Resolution Without an Extra Round Trip
The Go backend uses go-i18n/v2 with JSON message catalogs embedded directly in the binary via //go:embed. There are no locale files on the filesystem at runtime — the English and Slovak catalogs ship inside the server binary, which means self-hosted operators get i18n support automatically on upgrade with no additional files to deploy.
Language resolution happens in a LanguageMiddleware that wraps all authenticated routes. When a request arrives, the middleware checks whether the authenticated user's context already carries a Language field. If it does — set during JWT validation from the user's saved preference in user_preferences.language — that value is attached to the request context. If not, it falls back to the Accept-Language header, and finally to English. Handlers obtain a *i18n.Localizer from the context via a single helper call — i18n.LocalizerFromContext(ctx) — and never reference the language string directly.
The language preference itself is stored in a new language column on user_preferences (a VARCHAR(10) with a CHECK constraint enumerating supported locales), and included in the JWT claims at login time. This means there is no extra database query per request to resolve language — the preference travels with the authentication token, and the middleware reads it from the request context that the auth layer has already populated.
Async paths — email delivery and alert notification dispatch — receive the language as an explicit parameter rather than through request context. The email service's SendOTP method accepts the recipient's user.Language directly; the alerting service resolves the language from the device owner's preferences before composing the notification string. This makes the language dependency explicit and testable: the email service test suite verifies subject and body strings in both English and Slovak using a test localizer.
Frontend: 500 Strings, 12 Namespaces, Zero Runtime Typos
The dashboard frontend uses i18next with react-i18next, organized into twelve feature namespaces — auth, nav, devices, settings, alerts, billing, workflows, reports, admin, dashboards, errors, and common. Each namespace is a separate JSON file loaded lazily via i18next-http-backend; a page that only touches auth and common does not download the admin catalog until it is needed.
Type safety for translation keys is enforced at compile time through a TypeScript declaration in i18next.d.ts that maps each namespace to its JSON catalog type. Calling t('login.titlee') — a typo — is a TypeScript error, not a silent runtime miss that ships an empty string to production. This was a non-negotiable requirement: with ~500 keys across 12 namespaces, a missing-key bug that survives to production would produce blank UI labels that are harder to catch in review than a compiler error.
Zustand stores presented a subtler problem. Stores dispatch asynchronous operations and set success or error messages from within immer mutations — they run outside any React render cycle and therefore outside any useTranslation hook. Our solution was a message key pattern: stores set a successKey or errorKey string rather than a translated message. Components read these keys and call t(key) at render time, where the i18next context is available. This keeps stores entirely locale-agnostic and avoids any dependency on i18next internals in the state management layer.
Language switching is instantaneous. When a user selects a new language in Settings > Localization, the store calls PATCH /api/v1/me/preferences to persist the change, then calls i18n.changeLanguage() — the UI re-renders in the new language immediately, without a page reload. On next login, the auth flow applies the saved language before the dashboard mounts, so the first rendered screen is already in the user's language.
Plugins: A Contract That Holds Across Both Runtimes
The plugin system supports two distinct runtimes — compiled Go plugins that share the binary's address space, and scripted JavaScript plugins executed in isolated Goja VMs. Both must be able to produce localized user-facing strings, but neither should be required to duplicate locale management logic.
Compiled plugins import internal/i18n directly and call i18n.LocalizerFromContext(ctx) with the same context their event hook or HTTP handler receives. Plugin locale files — prefixed plugin_{id}_en.json and plugin_{id}_sk.json — are loaded into the shared bundle during plugin initialization. The Chat Bot plugin, which sends Telegram and Slack messages to users who have linked AdaTrack accounts, was the first compiled plugin to implement this: it resolves the linked user's language at the point the command context is built, then every command handler downstream calls cmd.Localize(msgID, data) without ever touching the language string again.
Scripted plugins run in a sandboxed Goja VM with no access to Go packages. We expose adatrack.i18n.t(msgID, data) as a host function that wraps the same go-i18n localizer used by the compiled path. Plugin authors ship their own locale JSON files in the plugin manifest package; the platform merges them into the bundle at install time. Plugins that do not call adatrack.i18n.t() continue to work unchanged — localization participation is strictly opt-in.
The fallback chain is uniform across both runtimes: Slovak → English → message ID. An unsupported locale never causes a panic; it degrades gracefully to the default language, which means a plugin can be deployed before its translations are complete without breaking in production.
Consent Before Collection: GDPR-Compliant Analytics
GA4 analytics were added to the marketing site in a prior release — and immediately created a compliance gap. Google Analytics sets _ga and _ga_* first-party cookies on every page load without user knowledge, which violates GDPR Article 6(1)(a), the ePrivacy Directive, and CCPA. Fixing this required more than a cookie banner; it required restructuring when GA4 initializes.
The implementation integrates Google Consent Mode v2. Before any user interaction, gtag('consent', 'default', ...) fires with analytics_storage: 'denied'. GA4 is still loaded but operates in a cookieless mode — no _ga cookies are set, and Google uses behavioral modeling to estimate conversion data for the denied population. When the user clicks "Accept Analytics", gtag('consent', 'update', { analytics_storage: 'granted' }) fires and GA4 switches to full collection. The initGA() call itself is gated on the consent check — GA4 does not initialize at all on page load if the user has previously declined.
Consent preferences are stored in localStorage under a versioned key. Storing in localStorage rather than a cookie avoids the circular dependency of needing consent to store consent. A CONSENT_VERSION constant is compared against the stored version on every load; bumping it triggers a re-prompt for users who consented to an earlier policy version — important when cookie usage changes in a future release.
The banner itself is a non-blocking fixed bottom bar with equal-prominence "Accept All" and "Decline Analytics" buttons — satisfying the ICO's requirement that rejection be no harder than acceptance. A "Cookie Preferences" link in the footer reopens the preferences modal at any time, meeting the GDPR Article 7(3) right to withdraw consent.




