Multitenancy with Hanami and Sequel

Denis Kondratenko - 09.06.2017
multitenancy, sharding, hanami, sequel


Hi folks! Today I’d like to share the story how we built a multi-tenant app with Hanami framework. What we were trying to achieve is to have a single application instance, but to separate the data per each country we were hosted in. For example, to run a single server with an app that would serve requests from https://app.uk, https://app.fr & https://app.nl. Let me say in advance that we made it working :)

How it all began

You may be wondering why we chose Sequel if Hanami has Hanami::Model? I’d like to share our consideration behind this decision, so I’ll begin with a little history retrospective.

Hanami::Model story

When we decided to migrate from our Rails app, Hanami was at the 0.8.0 version and hanami-model at 0.6.1. We were excited about repository pattern and wanted to use it, but it turned out that hanami-model had a global config, including DB URI, preventing us to switch DBs at the run-time. And that was our goal - to have a single instance of our app that would talk to different DBs depending on the runtime context. However, we have a decent alternative - ROM, or the Ruby Object Mapper (version 2.0.1 at that time).

ROM story

Thanks to the design of ROM::Repository, we could pass configuration on its initialization. First, we registered all countries-specific configuration within the app container. Then, we created our wrapper around ROM::Repository and redefined its initialize method so whenever we instantiate our repo we get the right config. It worked pretty well and we switched to ROM. Yay! We were even more excited because ROM seemed much more advanced than what Hanami::Model could provide. We started to play with ROM and the more things we tried to implement, the more often we faced discrepancy with its documentation. Once we literally copy-pasted the code from the documentation and it didn’t work. We had to open its source and figure out what was going on pretty often. That slowed down the whole team significantly - enough to search for other alternatives…

Sequel story

Sadly, but we haven’t found other decent ORMs designed with repository pattern in mind. We weren’t happy about that but had to switch back to Active Record. Hold on, it’s not what you might think! I am talking about the pattern here. Of course, we didn’t want to use ActiveRecord with Hanami. I would not forgive myself, at least. So we chose the Sequel and I am quite happy about that! ヽ༼ຈل͜ຈ༽ノ

Sequel is a mature ORM with lots of useful plugins and extensions. It is powerful, flexible, fast and has an awesome maintainer! BTW, rom-sql (adapter of ROM to work with relational databases) is using Sequel under the hood. It is flexible enough to allow us to define our own repositories and entities on top of it (and we already have a prototype of it, so stay tuned).

Enough talking, gimme the code!

Among many extensions, there are two related to multitenancy: server_block and sharding. We have to enable them first. Then we establish Sequel connection with the additional server configurations on application load. It looks like:

DB = Sequel.connect("postgres://default_db_url", servers: @servers)

The first argument is a URL to the default database. Sequel needs it to map tables into models (and probably some other stuff). It may be with some data, but our is empty.

@servers holds configuration for our databases and looks like:

@servers = { fr: { database: "postgres://fr_url" }, uk: { database: "postgres://uk_url" } }

In order to perform an operation on other than default database, we have to use server_block extension:

DB.with_server(:uk) do
  Message.create(body: 'Such wow', author_id: 1, topic_id: 1)
end

OK, that’s pretty awesome, but how do we know when should we write to fr or uk servers? Here comes the rack middleware! We simply have to fetch the domain from the incoming request and call next middleware within with_server block, like this:

module Middleware
  class SwitchDatabase
    class CountryNotDefinedError < StandardError; end

    def initialize(app)
      @app            = app
      @db             = App::Container['config.db_connection'].db
      @country_switch = App::Container['config.country']
    end

    def call(env)
      request = Rack::Request.new(env)
      tld = request.host.split('.').last

      tld = ENV.fetch('FORCE_DB') if ENV.fetch('FORCE_DB', nil)

      raise CountryNotDefinedError if tld.nil? || tld.empty?

      @country_switch.switch(tld)
      @db.with_server(tld.to_sym) do
        @app.call(env)
      end
    ensure
      @country_switch.reset!
    end
  end
end

And include our middleware before hanami in config.ru file:

use Middleware::SwitchDatabase
run Hanami.app

Our application now switches to correct databases depending on the top-level domain. It is thread-safe, easy to adjust and everything is pretty explicit.

Voila (ノ◕ヮ◕)ノ*:・゚✧

Instead of the summary

I’ll add few more notes. @country_switch from the code above is an instance of a special class that represents the current country. It is a thread-safe and allows us to use sharding with background job processors like Sidekiq. It is also easy to simulate specific country in specs and test bits of country-specific logic. I’ll probably write about those things in the next articles but now I have to end.

Thanks and see ya!


comments powered by Disqus