DEV Community

loading...
Cover image for Multitenant Architecture on Rails 6.1

Multitenant Architecture on Rails 6.1

Ritikesh
Read, code, repeat.
Updated on ・3 min read

Rails, the framework built on top of Ruby, just got its latest version(6.1) released. A lot of features and enhancements have gone into the latest version of Rails. You can read the official announcement for more details.

I will be focusing particularly on the Multi-DB improvements section, what changed and how we can leverage Rails' native multi DB handling techniques for building scalable multitenant applications.

Rails 6.0 was the first official rails version to support multiple databases. From the release notes:

The new multiple database support makes it easy for a single application to connect to, well, multiple databases at the same time! You can either do this because you want to segment certain records into their own databases for scaling or isolation, or because you’re doing read/write splitting with replica databases for performance. Either way, there’s a new, simple API for making that happen without reaching inside the bowels of Active Record. The foundational work for multiple-database support was done by Eileen Uchitelle and Aaron Patterson.

This allowed application developers to be able to define multiple database connections for a single application. Before this, developers had to use one of the many third party gems for any kind of multi DB support in Rails. Even though the ruby/rails community is very vibrant, third party gems often come with maintenance overheads with respect to upgrades, breaking changes, bugs, performance issues, etc.

With Rails 6.0, you could define your database.yml in such a way:

# config/database.yml

default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  primary:
    <<: *default
    database: primary_db
  primary_replica:
    <<: *default
    database: primary_db_replica
    replica: true
  animals:
    <<: *default
    database: animals_db
  animals_replica:
    <<: *default
    database: animals_db_replica
    replica: true
Enter fullscreen mode Exit fullscreen mode

Then define ActiveRecord Abstract classes that could connect to these databases.

# app/models/application_record.rb

# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
  connects_to database: { writing: :primary, reading: :primary_replica }
end

# app/models/animals_base.rb

# frozen_string_literal: true
class AnimalsBase < ApplicationRecord
  connects_to database: { writing: :animals, reading: :animals_replica }
end

# app/models/user.rb

# frozen_string_literal: true
class User < ApplicationRecord
end
Enter fullscreen mode Exit fullscreen mode

The abstract classes and models inheriting from them would both now have access to the connected_to method which can be used to establish connection to the configured database connections.

# some_controller.rb

# frozen_string_literal: true
ApplicationRecord.connected_to(role: :reading) do
  User.do_something_thats_slow
end
Enter fullscreen mode Exit fullscreen mode

This approach worked great for primary-replica setup or setups where models had clear separation. i.e. a model always queried from a single database. However, with modern multi-tenant SaaS applications, horizontal sharding is almost a basic necessity. Depending on the tenant that's accessing the application, the application should be able to select which database it wants to query the data from. While how the application shards horizontally is DSL and can vary from a case to case basis, how it is able to connect to the underlying databases should be something that the framework should be able to handle. And so they did.

With Multi-DB improvements released in 6.1, you can now define shard connections for your abstract classes as well. The example from above changes as:

# config/database.yml

default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  primary:
    <<: *default
    database: primary_db
  primary_replica:
    <<: *default
    database: primary_db_replica
    replica: true
  animals:
    <<: *default
    database: animals_db
  animals_replica:
    <<: *default
    database: animals_db_replica
    replica: true
  animals_shard1:
    <<: *default
    database: animals_db1
  animals_shard1_replica:
    <<: *default
    database: animals_db1_replica
    replica: true
Enter fullscreen mode Exit fullscreen mode
# app/models/application_record.rb

# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
  connects_to database: { writing: :primary, reading: :primary_replica}
end

# app/models/animals_base.rb

# frozen_string_literal: true
class AnimalsBase < ApplicationRecord
  connects_to shards: { 
    default: { writing: :animals, reading: :animals_replica },
    shard1: { writing: :animals_shard1, reading: :animals_shard1_replica }
  }
end

# app/models/cat.rb

# frozen_string_literal: true
class Cat < AnimalsBase 
end
Enter fullscreen mode Exit fullscreen mode

Similar to 6.0, we can then leverage the connected_to method for switching(/establishing) connections to the configured databases.

# some_controller.rb

# frozen_string_literal: true
AnimalsBase.connected_to(shard: :shard1, role: :reading) do
  Cat.all # reads all cats from animals_shard1_replica
end
Enter fullscreen mode Exit fullscreen mode

Native multi DB connection switching and handling would go a long way in helping developers move away from a lot of complex 3rd party gems in favor of out-of-the-box tools. I have already started leveraging this in one of my applications. I will be sharing more on how to build an effective sharding / connection switching strategy on top of what's natively available with Rails in my next post. Thanks for reading and happy holidays!

Discussion (0)