Translation Architecture (End-to-End)
This document provides a conceptual explanation (Diátaxis) and architectural design overview (Arc42) of the internationalization (i18n) and translation system in Framework M, detailing the end-to-end lifecycle from static extraction to runtime web response resolution.
1. Context and Architectural Goals
Framework M is a multi-tenant, modular platform designed to run globally. The translation system is built around several key design constraints:
- Zero-Dependency Core Domain: Core business logic (DocTypes, workflows, and permissions) must raise translatable exceptions without depending on global state, request contexts, or
ContextVars. - Modular Namespaces: Translation catalogs are compartmentalized per package (e.g.,
desk,ui,common) to prevent monolithic file bloat and allow clean plugin boundaries. - Dynamic Tenant Overrides: Tenants must be able to customize translations in the database (
TenantTranslation) overriding default package catalogs. - Static Extractability: Both backend Python code and frontend TypeScript/React code must support lint-enforced static extraction.
2. Building Block View (Static & Compilation)
The translation pipeline starts with developer code and compiles down to language catalogs.
t() vs. I18nProtocol.translate()
The framework enforces a strict separation between marking a string for translation and resolving the actual translated value:
| Aspect | t(text) | I18nProtocol.translate(...) |
|---|---|---|
| Primary Purpose | Marking: Declares that a literal string is user-facing and extracts it during build. | Resolving: Fetches the translated text from cache/database for the active user/tenant. |
| Execution | Synchronous: Instantaneous wrapper returning a TranslatableString. | Asynchronous: Async database/cache query. |
| Dependencies | None: Completely stateless. Safe to import and call anywhere. | High: Requires request context, resolved locale, and database session. |
| When to Use | Use in core domain logic, workflows, models, or when raising exceptions where request context is unavailable. | Use at application boundaries (controllers, handlers, custom response wrappers, PDF printers) where request/DB states exist. |
| Linter Integration | Required by AST extraction linters (Semgrep) to pass static security validation. | Ignored by static linter extraction. |
Backend String Marking (t function)
The core package defines a stateless marker function, t(text: str), returning a TranslatableString.
# In framework_m_core.interfaces.i18n
def t(text: str) -> TranslatableString:
return TranslatableString(text)
TranslatableString is a string subclass that preserves the template text and intercepts late formatting:
# Usage in business domain
raise PermissionDeniedError(
t("User '{user_id}' lacks '{action}' permission.").format(user_id=user.id, action=action)
)
Extraction and Packaging
- Backend Extraction: A CLI tool parses the AST of Python packages searching for
t("...")calls, generatingNamespaced translation files undersrc/locales/{locale}/{namespace}.json. - Frontend Extraction:
i18next-parseranalyzes TypeScript and JSX files foruseTranslation("namespace")hooks andt("...")calls, outputting tolocales/{locale}/{namespace}.json. - Sync/Packaging: Build-time scripts merge catalogs and prepare them for fast runtime delivery.
3. Runtime View (End-to-End Lifecycle)
The following sequence diagram illustrates the end-to-end execution flow of a translation, from a web request through exception boundaries down to client-side localization.
Step 1: Locale Resolution
Litestar's LocaleResolutionMiddleware resolves the active locale on every request in the following priority:
- Explicit
langorlocalequery parameters (e.g.,?lang=es). - A
localecookie. - User preference (
user.locale) for authenticated users. - Tenant default configuration (
tenant.default_locale). Accept-Languageheaders sent by the browser.- System default fallback (
DEFAULT_LOCALE = "en").
The resolved code (e.g. "es") is stored in request.state.locale.
Step 2: Late Translation at the Web Boundary
Because exceptions are raised inside domain contexts that do not have access to HTTP state, translation is deferred.
- Handler Execution: Litestar invokes
permission_denied_handlersynchronously. It returns aTranslatableResponseinitialized with the default English stringstr(exc)(ensuring synchronous testing remains simple). - ASGI Hooking: The application prepares to stream the response by calling
to_asgi_response(), which returnsTranslatableASGIResponse. - Asynchronous Translation: When Litestar awaits the ASGI response (
__call__),TranslatableASGIResponseuses the request context to retrieve database sessions and active locales. It calls_translate_message(), which queriesDefaultI18nAdapter(pulling fromTenantTranslationfirst, thenTranslationcaches). - Body Injection: The handler parses the serialized JSON body, swaps the placeholder
"message"with the localized string, updates thecontent-lengthheader, and transmits the payload.
Database Session & Static Catalog Fallback
The DefaultI18nAdapter is designed to be resilient and does not strictly require a database session (session=None is fully supported):
- Static Local Catalogs: Translations are first resolved and loaded from static local JSON catalogs packaged within the code repository (e.g. under the
locales/<locale>/<namespace>.jsondirectory). - Optional Database Override: If an active database
sessionis passed, the adapter attempts to query the database to retrieve tenant-specific overrides (TenantTranslation) or system-wide overrides (Translation), which override the static catalog translations. - Offline/Sessionless Fallback: If the
sessionisNoneor the database queries fail (e.g., due to database offline status or network errors), the adapter catches the exception silently and falls back to the static translations loaded from the local JSON files.
Translation in CLI & Worker Environments
Since background workers and CLI command runs execute outside the lifecycle of an HTTP request/response loop, they do not trigger the LocaleResolutionMiddleware or TranslatableASGIResponse boundary:
- Background Workers: Tasks running in worker threads operate under a specific
TenantContextand potentially aUserContext(representing the user who queued the task).- Resolution: To send localized emails, system notifications, or report generation outputs, workers must explicitly resolve the locale from
user.localeortenant.default_locale. - Translation: Workers call the
I18nProtocol.translatemethod asynchronously inside the task body:locale = user.locale or tenant.default_locale or "en"subject = await i18n.translate(t("Task Completed Successfully"), locale=locale)
- Resolution: To send localized emails, system notifications, or report generation outputs, workers must explicitly resolve the locale from
- CLI Commands: CLI outputs are geared towards developers and system administrators.
- Resolution: By design, CLI commands do not perform translation lookups and output directly in English (using the default fallback value of the
TranslatableString). There is no active requirement or implementation for translating standard console logs. - Extensibility (If CLI translation is needed): Yes, CLI translation is fully possible because the backend
I18nProtocolandDefaultI18nAdapterare completely decoupled from HTTP request scopes. If localized console output is ever required, the CLI bootstrap layer can configure and call the translation adapter:import osfrom framework_m_core.interfaces import tfrom framework_m_standard.adapters.i18n import DefaultI18nAdapterasync def execute_cli_command(db_session, translation_repo):# Resolve system environment locale (e.g., "es_ES.UTF-8" -> "es")env_lang = os.environ.get("LANG", "en_US").split("_")[0]# Initialize standard translation adapteri18n = DefaultI18nAdapter(translation_repo=translation_repo)# Translate and printmsg = t("Operation completed successfully")translated = await i18n.translate(msg, locale=env_lang, session=db_session)print(translated) # Prints: "Operación completada con éxito"
- Resolution: By design, CLI commands do not perform translation lookups and output directly in English (using the default fallback value of the
4. Frontend Localization
On the client side, components consume translation namespaces directly.
- Bootstrap: When the shell initializes, it fetches active translation catalogs from the backend
/api/v1/Translation?locale={{lng}}endpoint. - i18next Registry: These catalogs are registered into the local
i18nextinstance. - useTranslation Hook: Components load keys dynamically using hooks:
const { t } = useTranslation("desk");return <Heading>{t("Welcome back")}</Heading>;
5. Architectural Advantages & Tradeoffs
Advantages
- Separation of Concerns: Domain logic raises clean exceptions without knowing anything about HTTP request lifetimes or databases.
- No contextvars: Avoids thread-safety and compatibility issues with async event loops in multi-threaded Python servers.
- Robust fallback: Synchronous tests instantly see the default English message without mocking translation layers, while users receive fully translated messages.
Tradeoffs
- Serialization Overhead: Response body modification requires parsing and re-serializing JSON under error states. However, because exceptions are exceptional and payloads are small, this overhead has negligible impact on production performance.
6. DX Mental Model (For Developers Coming From Other Frameworks)
If you are coming from Laravel (__()), Django (_()), Frappe (_()), or React (i18n.t()), the two-step translation pattern in Framework M can be confusing. Here is how to map your existing mental model:
The Paradigm Shift: One-Step vs. Two-Step
- Traditional Frameworks (One-Step): A global/thread-local request context resolves the translation immediately.
- Framework M (Two-Step): Keeps your business logic async-safe, stateless, and clean.
- Marking (
t): Synchronously marks strings for compile-time/static extraction. - Resolving (
translate): Asynchronously queries the translation catalogs at the boundaries.
- Marking (
The Developer's Rule of Thumb
90% of your code (DocTypes, Exceptions, RPC returns, validation errors) only needs t(). You do not need to call translate manually.
-
Exceptions:
# Django/Frappe:raise PermissionDenied(_("You do not have access"))# Framework M:raise PermissionDenied(t("You do not have access")) # Handled automatically by the web boundary! -
DocType Fields & Descriptions:
# Framework M:class Invoice(BaseDocType):class Meta:label = "Invoice Document" # Automatically extractedcustomer: str = Field(description="Customer name", # Automatically extractedtitle="Customer Name" # Automatically extracted)Raw string literals passed to
Field(description=...),Field(title=...), orclass Meta: label = ...are automatically parsed and extracted by the framework's translation CLI tool. At runtime, the Meta API automatically translates these fields before they are served to the frontend.
Only call await i18n.translate(...) manually when generating output outside the standard HTTP request/response cycle (e.g. background worker emails, PDF generation, or custom CLI commands).