How I do custom key lookup with Rails I18n
Why I do custom key lookups
Recently a client asked me to rename the model/entity names of my application to fit their domain and make it clearer to their customers. I run a video chat solution that was originally designed for private doctor-patient conversations. I have found that these private, untraceable conversations are helpful and needed in other businesses as well. In particular, the client is a charity and provides a translation service, so doctors and patients do not apply to their business, but translators and participants do.
The Rails application uses I18n to offer different languages depending on the customer's needs. The application has several tenant configurations. One can be used for an instance of the application.
The problem
I was wondering and thinking about how to solve the problem by using different translations for the relevant keys.
If you haven't used Rails I18n, a locale file looks like this
# config/locales/en.yml
en:
controllers:
accounts:
destroy:
flash_messages:
success: Account fully deleted. Thank you for using Med1.
application:
authenticate:
flash_messages:
please_sign_in: You need to sign in to continue.
and these keys can be used anywhere in your application with
I18n.t("controllers.accounts.destroy.flash_messages.success")
For using translation keys in views there's the t
helper method provided by ActionView::Helpers::TranslationHelper
. There's also l
for dealing with date, datetime and time.
Note: I recently learned from Deepak Mahakale aware, that I18n.t
and t
behave differently. By the way follow him for almost daily tips and tricks on Ruby on Rails!
Doing some research, I found a solution using different locale files, including the tenant name. Assuming I have a tenant named tenant_1
, I'd need to edit the files
config/locales/en.yml
config/locales/en_tenant_1.yml
The latter would contain translation keys specific to tenant_1, which would be used before reverting to the ones from the base file (en.yml
).
While this may work for others, I decided to use this for my application. I didn't want to have tenant customisations in different files. These files would probably have to mix different concerns such as activerecord, activemodel and view translations in one place.
I thought - and still think - it's better to have the customisation close to the original key. On the one hand, it bloats my locale files, but on the other, it keeps things in place.
Enough storytelling, here's the solution.
The solution
I wanted to have tenant-based customisations close to the original key. This means that using a key my_key
should serve my_key_tenant_1
if it's present. With this structure, all tenant-based keys will be next to the original key when sorted alphabetically. See my bonus tip at the end of this article.
While reading the Rails I18n documentation I came across custom i18n backends.
Could I have my own backend that implements my key lookup strategy and falls back to the default simple backend lookup if a custom key is not present...
Here's my solution.
# config/initializers/rails_i18n_tenant_aware_backend.rb
module I18n
module Backend
module TenantAware
def lookup(locale, key, scope = [], options = {})
tenant = Rails.application.config.x.tenant || "default"
# Ensure scope is an array
scope = Array(scope)
scoped_key = (scope + ["#{key}_#{tenant}"]).join('.').to_sym
translation = super(locale, scoped_key, [], options)
return translation if translation.present?
# Fallback to default translation
super(locale, key, scope, options)
end
end
end
end
I18n.backend.class.send(:include, I18n::Backend::TenantAware)
For a given I18n request I18n.t("my_key")
and a tenant name tenant_1
, it will first look for the key my_key_tenant_1
, otherwise it'll default to using my_key
.
Tenant naming is provided with a custom application config
Rails.application.configure do
# ...
config.x.tenant = ENV["APP_TENANT"]
# ...
end
In my application, I provide the tenant name via an environment variable.
That's it. I can now define any tenant-based override for any key I want by adding the tenant name as a suffix.
So far it works well and I don't see any bugs, but I haven't tested any features, e.g. safe html translations.
Bonus tip - using i18n-tasks
I mentioned that the keys are close together in my locale files. This is not necessarily the case, as you can put keys anywhere in config/locales
.
I think it's a good idea to structure your keys well, e.g. by including the controller, action and view name in the key name. An example would be views.users.search.title
for the search form/view of the UsersController
.
This approach has served me well, but use whatever works for you.
Anyway, there's a nifty gem to help you not mess up your locale files: i18n tasks
It provides the tasks to
- normalise keys: basically sort them alphabetically
- missing keys: tells you about missing keys in other locales
- unused keys: keys that are in locale files but have no reference in code
There are several other tasks, but these are the ones I use most often.
They are also part of my test suite.
# test/i18n_test.rb
# frozen_string_literal: true
require "i18n/tasks"
class I18nTest < ActiveSupport::TestCase
def setup
@i18n = I18n::Tasks::BaseTask.new
@missing_keys = @i18n.missing_keys
@unused_keys = @i18n.unused_keys
end
def test_no_missing_keys
assert_empty(
@missing_keys,
"Missing #{@missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them",
)
end
def test_no_unused_keys
assert_empty(
@unused_keys,
"#{@unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them",
)
end
def test_files_are_normalized
non_normalized = @i18n.non_normalized_paths
error_message = "The following files need to be normalized:\n" \
"#{non_normalized.map { |path| " #{path}" }.join("\n")}\n" \
"Please run `i18n-tasks normalize' to fix"
assert_empty(non_normalized, error_message)
end
def test_no_inconsistent_interpolations
inconsistent_interpolations = @i18n.inconsistent_interpolations
error_message = "#{inconsistent_interpolations.leaves.count} i18n keys have inconsistent interpolations.\n" \
"Please run `i18n-tasks check-consistent-interpolations' to show them"
assert_empty(inconsistent_interpolations, error_message)
end
end
It can help you keep your locale files organised.
Wrapping it up
The approach I took and outlined in this article serves we well as of today. I can imagine, this will get messy when having a lot of different tenants but I consider this a luxury problem for future Stefan. I am happy to deal with this 🤑
I hope you found this article useful and that you learned something new.
If you know a better way of dealing with this, let me know.
If you have any questions or feedback, didn't understand something, or found a mistake, please send me an email or drop me a note on twitter / x. I look forward to hearing from you.
Consider subscribing to my blog if you'd like to receive future articles directly in your email. If you're already a subscriber, thank you 🙏