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 🙏