Design a SaaS application on Rails 6.1 with horizontal sharing

0


Profile picture of Ritikesh G Hacker Noon

Rails, the framework built on Ruby, has just released its latest version (6.1). Many features and improvements have been made to the latest version of Rails. You can read the official announcement for more details.

I will focus particularly on the Multi-database enhancements section, what’s changed, and how we can leverage Rails native multi-database management techniques to build scalable multi-tenant applications.

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

New support for multiple databases makes it easy for a single application to connect to multiple databases at the same time! You can do this either because you want to segment certain records in their own databases for scaling or isolation purposes, or because you are doing a read / write split with replicated databases for performances. Either way, there’s a simple new API to make this happen without getting to the guts of Active Record. The groundwork for supporting multiple databases was done by Eileen Uchitelle and Aaron Patterson.

This allowed application developers to be able to define multiple database connections for a single application. Prior to that, 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 a maintenance overhead when it comes to upgrades, breaking changes, bugs, performance issues, and more.

With Rails 6.0 you can define your

database.yml

like:

# 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

Then set

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

Both abstract classes and models that inherit from them would now both have access to the

connected_to

method that can be used to establish connection to configured database connections.

# some_controller.rb

# frozen_string_literal: true
ApplicationRecord.connected_to(role: :reading) do
  User.do_something_thats_slow
end

This approach worked great for the main replica setup or setups where the models had a clear separation. that is, a model always queried from a single database. However, with modern multi-tenant SaaS applications, horizontal partitioning is almost a basic necessity. Depending on which tenant is accessing the app, the app should be able to select the database from which it wants to query data. While the way the app splits horizontally is DSL and may vary from case to case, how it is able to connect to the underlying databases should be something the framework should be. able to handle. And that’s what they did.

With Multi-database improvements released in version 6.1, you can now define partition connections for your abstract classes. The above example changes like:

# 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
# 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

Similar to 6.0, then we can take advantage of the

connected_to

method of switching (/ establishing) connections to 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

One of the most common design patterns for multi-tenant architectures is to associate each tenant with a unique subdomain on your root domain. For example. if your app is running on example.com, marvel because a tenant is accessing the system using marvel.example.com and so on.

This model has its own advantages (easier / faster DNS resolution when running on a multi-pod setup) and disadvantages (DNS updates for each tenant creation). Instead of discussing this, we’ll explore how to implement this architecture in a Rails application using the new multi and horizontal database configuration provided by Rails 6.0 / 6.1.

To begin with, we’ll need a

Tenant

model. Since your tenants will be identified by subdomains, it makes sense to have a subdomain column in the table with other attributes required by the application. Each tenant belongs to a

Shard

and all of that tenant’s data would reside on that partition. So we will also need a partition template.

We can start by configuring the required database configurations first:

# config/database.yml

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

development:
  default:
    <<: *default
    database: primary_db
  default_replica:
    <<: *default
    database: primary_db_replica
    replica: true
  shard1:
    <<: *default
    database: shard1_db
  shard1_replica:
    <<: *default
    database: shard1_db_replica
    replica: true

We will also define the required models accordingly.

# app/models/application_record.rb

# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  db_configs = Rails.application.config.database_configuration[Rails.env].keys

  db_configs = db_file.each_with_object({}) do |key, configs|
    # key = default, db_key = default
    # key = default_replica, db_key = default
    db_key = key.gsub('_replica', '')
    role = key.eql?(db_key) ? :writing : :reading

    db_key = db_key.to_sym
    configs[db_key] ||= {}

    configs[db_key][role] = key.to_sym
  end

  # connects_to shards: {
  #   default: { writing: :default, reading: :default_replica },
  #   shard1: { writing: :shard1, reading: :shard1_replica }
  # }
  connects_to shards: db_configs
end

# app/models/global_record.rb

# frozen_string_literal: true
class GlobalRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: { writing: :default, reading: :default_replica }
end

# app/models/tenant.rb

# frozen_string_literal: true
class Tenant < ApplicationRecord
  include ActsAsCurrent

  validates :subdomain, format: { with: DOMAIN_REGEX }
  # other DSL

  after_commit :set_shard, on: :create

  private

  def set_shard
    Shard.create!(tenant_id: self.id, domain: subdomain)
  end
end

# app/models/shard.rb

# frozen_string_literal: true
class Shard < GlobalRecord
  include ActsAsCurrent

  validates :domain, format: { with: DOMAIN_REGEX }
  validates :tenant_id

  before_create :set_current_shard

  private

  def set_current_shard
    self.shard = APP_CONFIGS[:current_shard] #shard1
  end
end

With multi-tenant architectures, there will always be a global context and a tenant-specific context. We isolate these models through abstract classes

ApplicationRecord

and

GlobalRecord

. They also take care of analyzing database connections and configuring the required isolations.

We can also take advantage of the BelongsToTenant model for all models that are owned by a tenant and inherit from

ApplicationRecord

.

All models inherited from ActiveRecord connect by default to a default partition and write role unless

connected_to

another connection. Therefore, when connecting to

GlobalRecord

legacy models, we will not require any explicit connection management.

We can also define a proxy class to extract any application-specific connection management logic:

# app/proxies/database_proxy.rb

# frozen_string_literal: true
class DatabaseProxy
  class << self
    def on_shard(shard: , &block)
      _connect_to_(role: :writing, shard: shard, &block)
    end

    def on_replica(shard: , &block)
      _connect_to_(role: :reading, shard: shard, &block)
    end

    def on_global_replica(&block)
      _connect_to_(klass: GlobalRecord, role: :reading, &block)
    end

    # for regular executions, since Global only connects to default shard,
    # no explicit connection switching is required.
    # def on_global(&block)
    #   _connect_to_(klass: GlobalRecord, role: :writing, &block)
    # end

    private

    def _connect_to_(klass: ApplicationRecord, role: :writing, shard: :default, &block)
      klass.connected_to(role: role, shard: shard) do
        block.call
      end
    end
  end
end

With this setup in place, we can now write both application and background middleware that handles partition selection and tenant isolation on a demand or work basis.

# lib/middlewares/multitenancy.rb

# frozen_string_literal: true

require 'database_proxy'

module Middlewares
  # selecting account based on subdomain
  class Multitenancy
    def initialize(app)
      @app = app
    end

    def call(env)
      domain = env['HTTP_HOST']

      shard = Shard.find_by(domain: domain)
      return @app.call(env) unless shard

      shard.make_current
      DatabaseProxy.on_shard(shard: shard.shard) do
        account = Account.find_by(subdomain: domain)

        account&.make_current
        @app.call(env)
      end
    end
  end
end

# config/application.rb
require 'lib/middlewares/multitenancy'

config.middleware.insert_after Rails::Rack::Logger, Middlewares::Multitenancy

With more widespread adoption, the underlying framework is only expected to improve from here. Native implementations also allow us greater flexibility and control over code flow and application design.

Also published on: https://dev.to/ritikesh/multitenant-architecture-on-rails-6-1-27c7

Profile picture of Ritikesh G Hacker Noon

Keywords

Join Hacker Noon

Create your free account to unlock your personalized reading experience.



Source link

Leave A Reply

Your email address will not be published.