we develop communication with weLaika Advertising

Condominium chitchat - Multi-tenant Architecture

Tags: rails, tenants, architecture, ruby, pattern
Stefano Pau -
Stefanopau

It is so difficult to round up everyone for a condominium meeting, there always is something wrong and you end up not agreeing on anything. It is the same unease one feels when we deal with a multi-tenant architecture. What does this mean? In practice, to gather several applications, similar to each other, under the same roof.

Suppose we have to develop a series of e-commerces using Ruby on Rails, sharing the same core, but each with small customizations that make it impossible to use the same code in full. The approach I would have adopted in the past would have been to use a branch in my repository for every customization, leaving the main branch alone.

Something like this:

1
2
3
4
5
6
master
  |__shop_01
  |__shop_02
  |__shop_03
  ...
  |__shop_30

This basically causes 2 issues: 1. it is manageable if we have only a few tenants – if we open a generic feature from master, we shall then merge it into every environment. Sooner or later we will lose control of the situation. 2. individual tests shall be launched prior to every deploy. With 30 tenants it might take days to be able to deploy a complete release.

Which approach to adpot then? Well, Rails is a huge monolith – let’s take advantage of this and keep all tenants in one application. A big condominium with all its residents. Let’s locate the main groups that will certainly require an intervention: * environment vars * assets * locales * views * business logic * tests

ENV VARS

Let’s begin with environmental variables – it will definitely be one of the main problems you will have to cope with at beginning. Every tenant will have different variables, that we have to manage. I have solved this problem by using the gem Chamber . I won’t say anything more about this gem, please go read the documentation. This was a fundamental element in my implementation.

First thing, we must declare among the environment variables in which tenant we are:

1
TENANT=shop05

The whole logic will revolve around this environment variable.

ASSETS

Take the stylesheet for our hypothetical e-commerce:

1
2
3
4
5
6
7
app/
  |__assets/
      |__stylesheets/
      |__partials/
          |__ _chart.sass
          |__ _product.sass
          |__application.sass

Inside partials/ we have the partials that will be imported into application.sass. This is a canonical situation.

1
2
3
4
// application.sass
...
@import partials/chart.sass
@import partials/products.sass

Let’s insert our customizations for the current tenant. We do not need to replicate code, let’s follow the DRY principle and apply the customizations while replicating as little code as possible.

1
2
3
4
5
6
7
8
9
10
11
12
13
app/
  |__assets/
      |__stylesheets/
      |__partials/
          |__ _chart.sass
          |__ _product.sass
          |__application.sass
          |__tenants/
              |__shop05/
                  |__partials/
                      |__ _chart.sass
                      |__ _banner.sass
                  |__application.sass

I have inserted my customizations of _chart.sass and created a new stylesheet _banner.sass. What I have to do now is to re-create the import tree for the partials in the tenant’s application.sass.

1
2
3
4
5
6
7
8
// tenant application.sass
...
@import '../../partials/chart.sass'
@import 'partials/chart.sass'

@import '../../partials/products.sass'

@import 'partials/banner.sass'

It might seem annoying to have to re-create the application.sass for every tenant, but the import order of partials in SASS is really important and we cannot simply append the changes to the main application.sass.

The same logic is to be applied to the Javascript assets. Let’s conform to what we have said for the images as well.

1
2
3
4
5
6
7
8
9
10
app/
  |__assets/
      |__images/
      |__icons/
          |__icon01.jpg
          |__icon02.jpg
          |__tenants
              |__shop05/
                  |__icons/
                      |__icon02.jpg

We shall pay specific attention to the image_tag method. It is possible to override it, or to write a helper that takes the tenant’s images into account. The method shall return the path to the image of the current tenant, in case it exists, or otherwise the path to the image in our more generic assets.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// view helper

def format_path (img_path)
  return nil unless img_path

  # if the tenant is not declared, return the path provided by the view
  return img_path if ENV['TENANT'].blank?

  # if the tenant is declared, rebuild the path to the tenant's custom image
  new_path = ['tenants', ENV['TENANT'], img_path].join('/')

  # if the tenant's custom image actually exists, return the path to it...
  return img_path unless File.exist?(Rails.root.join(new_path))

  # ...otherwise return the path to the original image
  new_path
end

Finally, the call to our image_tag will be as follows:

1
= image_tag format_path('path/of/image')

LOCALES

The locales are probably the fundamental reason because it’s convenient to work in multi-tenants architecture than creating isolated applications. It’s a trouble to handle a large amount of YAML, often shared by at least 80% of others applications. To duplicate from time to time all this amount of text involves stress and natural errors of copying, typing and distraction.

To make the minimum possible effort, we use DRY principle like landmark: we overwrite the keys we want to replace. Like the stylesheets, the most recent keys replace older ones. Just create our dedicated translations for the tenants and add them to the general translations in our application.rb.

1
2
3
4
5
if ENV['TENANT'].present?
  config.i18n.load_path += Dir[
    Rails.root.join('config', 'locales', 'tenants', ENV['TENANT'], '*.{rb,yml}').to_s
  ]
end

VIEWS

With views, the matter is way simpler. We can operate at the controller level to redirect the rendering to the relevant view with this simple override:

1
2
3
4
5
def prepend_view_paths
    subdomain = ENV['TENANT'] || nil
    prepend_view_path "app/views/tenants/#{subdomain}" if subdomain
end

Our task is to respect the path we decided to include in the prepend_view_paths method, and therefore:

1
"app/views/tenants/#{subdomain}”
1
2
3
4
5
6
7
8
9
app/
  |__views/
      |__products/
      |__form.html.slim
          |__new.html.slim
      |__tenants
          |__shop05/
              |__products/
              |__form.html.slim

In this case, the view views/tenants/shop05/products/form.html.slim will be loaded only in presence of the shop05 tenant, whereas in all other cases the default view will be used.

For the rendering of partial views, we can operate in the same way as in the case of images and create a view helper that dynamically modifies the path to pass to the render method:

1
= render partial: format_view_path('path/of/partial_view')

BUSINESS LOGIC

What do we do in the case when a tenant necessarily requires some ad-hoc logic? Well, the first approach should be to integrate the logic so as to make it valid for all tenants. If this proves impossible, all that’s left to do is to create an override of the above-mentioned logic. I thought of enclosing this logic within lib/extensions, as I don’t believe these small overrides really belong to the application – they are more of exceptional cases. The Ruby code in lib is not automatically loaded by the framework’s auto-loader, rhis give us the advantage of simply and effectively managing the loading of the logic that exclusively concerns the current tenant. So, in config/initializers/, we selectively load our extensions:

1
2
3
Dir[
  Rails.root.join('lib', 'extensions', 'tenants', ENV['TENANT'], '**', '*.rb')
].each { |l| require l }

The approach is the same we used for the assets: if I want to override a method of the Chart model in app/models/chart.rb for the tenant shop05, I can include the override module in lib/extensions/tenants/shop05/models/chart.rb .

1
2
3
4
5
6
7
module TenantAwesomeModule
  module Chart
    def my_method_override
    [...]
    end
  end
end

I shall then apply the override to the Chart model:

1
2
3
4
5
class Chart
  prepend TenantAwesomeModule::Chart if defined? TenantAwesomeModule::Chart

  [...]
end

Very simple, but the best thing to do is to avoid this kind of thing as much as possible: the tenants should differ from each other in the assets only, not the Ruby logic.

TESTS

Now that you have your custom logic for the shop05 tenant, you must tell the application not to test the shop05 tenant with the generic tests, but instead the the dedicated tests (if you wrote any.) Of course, this only and exclusively applies to the tests that cannot be shared with the tenant.

I shall add this row to your rails_helper.rb to force the execution of tests from your tenant:

1
2
3
if ENV['TENANT'].present?
  Dir["./tenants_test/#{ENV['TENANT']}/**/*.rb"].sort.each { |f| require f }
end

When using RSpec, this filter becomes fundamental:

1
config.filter_run_excluding  tenant_test: true

At this point, RSpec will execute both the generic tests and those specific to the tenant. Let’s then prevent RSpec from executing the generic test in favour of the tenant’s:

1
2
3
RSpec.describe 'Chart', type: :model, tenant_test: false do
  [...]
end

The filter declared earlier allows us to selectively exclude a test’s execution.

CONCLUSION

At this point, your application is ready to deploy. I will say nothing in this respect, you shall find the best way for your stack. In my specific case I have used a different environment for every tenant.

When you will have to deal with many tenants, you will notice that the pre-compiled asset will become very slow (this is because everything is compiled, in each and every case.) We can update our configurations in assets.rb to make compiling smarter, that is, getting the compiler to only compile the generic assets and those specific to the relevant tenant.

Let’s begin with a drastic override to prevent Sprockets from compiling everything:

1
2
3
4
5
6
7
8
9
10
MY_ASSETS = lambda do |logical_path, filename|
  filename.start_with?(::Rails.root.join('app/assets').to_s) && !(
  filename.start_with?(::Rails.root.join('app/assets/stylesheets/tenants').to_s) ||
  filename.start_with?(::Rails.root.join('app/assets/javascripts/tenants').to_s) ||
  filename.start_with?(::Rails.root.join('app/assets/images/tenants').to_s)
  ) && !['.js', '.css', ''].include?(File.extname(logical_path))
end

Rails.application.config.assets.precompile = [APP_ASSETS]

At this point we shall only load the relevant assets:

1
2
3
4
5
6
7
8
tenant_prefix = "tenants/#{ENV['TENANT']}"
prefix = ENV['TENANT'].present? && File.directory?(Rails.root.join("app/assets/stylesheets/#{tenant_prefix}")) ? tenant_prefix : ''

Rails.application.config.assets.precompile += %w[application.js]
asset_check = File.exist?(::Rails.root.join("app/assets/javascripts/tenants/#{ENV['TENANT']}/application.js"))
Rails.application.config.assets.precompile += ["#{prefix}application.js"] if asset_check
Rails.application.config.assets.precompile += ["#{prefix}application.css"]
Rails.application.config.assets.precompile += ["#{prefix}*.jpg", "#{prefix}*.png", "#{prefix}*.gif", "#{prefix}*.svg"]

This intervention has dramatically reduced the compiling time of assets, that had become forbidding.

This approach only represents my personal experience and all of this was born from the inalienable need not to intervene on the database in any way.