Adding multi-language support to a monolith isn’t just about translation; it’s about fundamentally decoupling your UI from its underlying language.
Let’s watch a user interact with a hypothetical e-commerce site. Their browser sends an Accept-Language: es-ES,es header. The monolith, upon receiving this, doesn’t just grab the Spanish translation for "Add to Cart." Instead, it consults a locale context that’s been set for this request. This locale context, determined early in the request lifecycle (often by middleware), contains not only the language code (es) but also region-specific formatting for dates, numbers, and currencies.
The application code, when it needs to display a product title, doesn’t have a hardcoded string. It calls a translation function, like t('product.title'). This function looks up product.title in a language-specific resource file (e.g., locales/es.json). The same function is used everywhere: for button labels, error messages, validation feedback, and even dynamic content.
Here’s a typical setup.
config/initializers/i18n.rb (Rails example):
I18n.available_locales = [:en, :es, :fr]
I18n.default_locale = :en
# Load translations from a specific directory
Rails.application.config.i18n.load_path += Dir[Rails.root.join('config', 'locales', '*.{rb,yml}')]
config/locales/en.yml:
en:
product:
title: "Awesome Gadget"
price: "Price"
cart:
add_button: "Add to Cart"
config/locales/es.yml:
es:
product:
title: "Gadget Increíble"
price: "Precio"
cart:
add_button: "Añadir al Carrito"
app/controllers/products_controller.rb (Rails example):
class ProductsController < ApplicationController
before_action :set_locale
def show
@product = Product.find(params[:id])
# No translation calls here, but the view will use them
end
private
def set_locale
# This is where the magic happens: determining the locale from the request
locale = request.env['HTTP_ACCEPT_LANGUAGE']&.scan(/^[a-z]{2}/i)&.first || I18n.default_locale
I18n.locale = locale.to_sym if I18n.available_locales.include?(locale.to_sym)
end
end
app/views/products/show.html.erb (Rails example):
<h1><%= t('product.title') %></h1>
<p><%= t('product.price') %>: <%= number_to_currency(@product.price) %></p>
<%= button_to t('cart.add_button'), add_to_cart_path(@product) %>
The core problem this solves is avoiding hardcoded strings and providing a consistent mechanism for selecting and using translations across the entire application. It allows for runtime switching of language without redeploying code, driven by user preferences or browser headers.
The locale context is the linchpin. It’s not just a string; it’s a state object that influences how everything is rendered. This means that even if your UI components are generic, their behavior regarding localization can be specific to the current locale. For instance, a date picker component might automatically format dates according to the I18n.locale setting, displaying DD/MM/YYYY for es and MM/DD/YYYY for en.
A common, subtle pitfall is when you have locale-specific logic in addition to translation. For example, a shipping cost calculation might differ between the US and Europe. Simply translating strings won’t cover this. You need to ensure that the locale context influences business logic where necessary, not just UI text. This often involves passing the I18n.locale down through service objects or ensuring that your data models are locale-aware if they contain translatable attributes themselves (though this is usually avoided in favor of separate translation tables).
When you introduce a new language, you’ll typically add a new YAML file (e.g., config/locales/de.yml) and update I18n.available_locales in your initializer. The next step will be to ensure all your backend services and APIs also respect the I18n.locale context if they generate any user-facing output.