Skip to main content

Multi-Tenancy

Multi-tenancy is a feature of a software application that allows multiple tenants to use the same application.

In JodApp's context, our tenants are the business users (or Org::Company) who use our applications. They manage their own:

  • Org::UserProfiles
  • Careers::Job
  • Careers::JobApplication
  • Gig::Job
  • etc.

It is very important that Company A can never access Company B's data.

Likewise, a Career User A cannot access Career User B's Career::JobApplication.

Multi-Tenancy Strategies

There are namely two strategies for multi-tenancy:

  • Soft Multi Tenancy
  • Hard Multi Tenancy

Soft Multi-Tenancy (Simple)

All tenants share the same database (and schema), but every row is tagged with a tenant identifier, and the application ensures queries are scoped to the current tenant.

This is simpler to manage (one set of tables, one migration to run) and is quite common for SaaS apps

info

Tenant Identifier refers to adding a foreign key to a database table.

In our case, this would be org_company_id column.

Hard Multi-Tenancy (Complex)

Each tenant gets a dedicated database or schema. This guarantees strong isolation (one tenant’s data can never appear in another’s), but increases operational complexity.

Every new tenant means provisioning a new DB/schema, managing multiple connection pools, and running migrations per tenant. It’s robust but can complicate deploys and scaling (migrations must be run on each database, etc.)

What tenancy strategy does Jod use?

Jod uses Soft Multi-Tenancy.

We also want to design our application using Bounded Context in a Majestic Monolith.

info

Bounded Context is a Domain Driven Design concept. You can got through a Rails example of Bounded Context here.

How do we implement Soft Multi-Tenancy?

We will use Strategic Denormalization and Application Layer Scoping.

Database Layer

  • Strategic Denormalization

Application Layer

  • Tenant Scoping

Strategic Denormalization

Example Use Cases

As an Org User, I can view careers_job_applications that only belong to my company. Other Org Users in other companies cannot view my company's job applications.

  • Anytime we query for careers_job_applications, we would need to filter Careers::JobApplication with the Org User Company's ID.
Careers::JobApplication.where(org_company_id: org_company.id)

As a Careers User, I can view my own careers_job_applications. Other Career Users cannot view my job applications.

  • Anytime we query for careers_job_applications, we would need to filter using the Career User's Identifier
Careers::JobApplication.where(careers_user_id: current_careers_user.id)

As a Careers User, I can view all careers_jobs in Jod, regardless of the company.

We just return all the careers_jobs that can be shown to Career Users without any filtering with org_company_id.

Use Case Generalisation

For any database table that contains data that only a specific company can access, we will add the org_company_id column as a FK to org_companies to that table.

Wouldn't that make org_companies table become a really large "God Object" in the database?

  • No, it would not.
  • Besides, that's not the meaning of "God Object".

Think of org_company_id as a scoping key, not a domain relationship. While tables like careers_jobs will have org_company_id as a foreign key, we would not need to define the relationship in the application layer (i.e. Rails).

Like wise, for a single Careers::JobApplication, we need to know which Careers::UserProfile made the application.

  • So we have careers_job_applications.careers_user_profile to scope it down to the user.
  • We do this subconsciously do in the overused e-commerce domain.
    • orders.customer_id would tell us which customer made the order.

What happens without scoping?

Imagine the scenario where we do not have org_company_id. We want to list all the Career::JobApplication for a single company.

We would need to join two tables to scope Career::JobApplication to that company:

  • career_job_applications joins career_jobs
Careers::JobApplication.joins(:job).where(jobs: { org_company_id: org_company_id })

This becomes worse if we need to show models that are "further away" from Org::Company, and it will get worse as we extend the application with new features.

With the org_company_id we can simply pass the org_company_id, without having to load the Org::Company model.

Career::JobApplication.where(org_company_id: org_company_id)

If every request every user makes makes the database join multiple tables just to get the "scoped" results, then database load would increase and we would have scaling issues in the future.

Application Layer Scoping

Previously in API Design, we went through how we group our endpoints by the "user type".

For each request that comes in, we need to get hold of the instances of the:

  • Identities::User, so we know if they have a "JodApp Account" (refer to )

In Rails, we can implement Application Layer Scoping by using ActiveSupport::CurrentAttributes and ActiveSupport::Concerns

So instead of having an instance variables in the controllers that are extended from:

controllerinstance variable
IdentitiesController@current_identities_user
CareersController@current_careers_user_profile
OrgController@current_org_company_profile

We have a single model called Current that contains all the context of the current request during runtime.

Controllers would set the Current model with the context of the current request.

active-support-current-attributes-comparison

class Current < ActiveSupport::CurrentAttributes
# Domain context
attribute :identities_user
attribute :org_company_profile
attribute :careers_user_profile
attribute :org_company

# HTTP request context
attribute :request_id, :user_agent, :ip_address
end

Analogy of ActiveSupport::CurrentAttributes

Analogy generated by Gemini Pro 2.5

We can use the analogy of "A Waiter's Tray at a Busy Restaurant" to understand how ActiveSupport::CurrentAttributes work.

Imagine a busy restaurant (your Rails server running Puma).

analogytechnical equivalent
Customers arrivingThese are incoming web requests.
WaitersThese are the server threads. Each waiter can only handle one customer's order at a time.
An OrderThis is a single request-response cycle.

Now, when a waiter takes an order, they put everything for that specific customer on their tray.

  • the drink order
  • the food order
  • the table number
  • any special allergy notes

This tray is unique to that waiter and that specific customer's order.

  • The waiter carries this tray everywhere they go—to the bar, to the kitchen, back to the table.
  • They don't have to constantly ask, "Wait, which table was this for again?" or "What was the drink order?"
  • It's right there on their tray.

When the meal is over and the customer leaves the waiter clears the tray completely, ready for the next customer.

  • They would never accidentally give a new customer a leftover item from the previous one.

ActiveSupport::CurrentAttributes is the waiter's tray.

  • It's a global object that is scoped to a single request (a single "order").
  • It's automatically isolated to the current thread (the "waiter").
  • It's automatically wiped clean after the request is finished.

ActiveSupport::CurrentAttributes simplifies multi-tenant context management for us by:

  • Reduces parameter passing in your service layer
  • Provides thread-safe, request-isolated storage
  • Makes it easier to access current user/company from anywhere
  • Follows Rails conventions and best practices

Database Layer

Table org_companies {
id bigint [pk, increment]
}

Table careers_jobs {
id bigint [pk, increment]
org_company_id bigint [ref: > org_companies.id]
}

Table careers_job_applications {
id bigint [pk, increment]
org_company_id bigint [ref: > org_companies.id]
}

careers_jobs and careers_job_applications have org_company_id as a foreign key.

Application Layer

module Org
class Company < ApplicationRecord
end
end

module Careers
class Job < ApplicationRecord
# When getting a job, we would want to know the company that posted it, especially for
belongs_to(
:org_company,
class_name: "Org::Company"
)
end
end

module Careers
class JobApplication < ApplicationRecord

belongs_to(
:careers_user_profile,
class_name: "Careers::UserProfile"
foreign_key: "careers_user_profile_id"
)

belongs_to(
:org_company,
class_name: "Org::Company",
foreign_key: "org_company_id"
)
end
end

Notice that we are not defining any relationships in Org::Company even thought tables like careers_jobs and careers_job_applications have the FK org_company_id?

That's because Org::Company does not, and should not manage the Careers::JobApplication model.

  • Adding a Careers::JobApplication should just be a simple Careers::JobApplication.create(..) in the CreateManager.
  • Listing Careers::JobApplication should just be a simple Careers::JobApplication.where(..) in the IndexManager

Summary

The model Org::Company is NOT an aggregate root.

The database table org_companies is NOT an aggregate root just cause it has many tables referencing it.

warning

Incomplete. notes for ali:

  • what is ActiveSupport::Concerns?
  • what class should be determine what scope to use on models?
    • controller because the controller knows which user-type is making the call
  • should we even bother using ActiveSupport::Concerns since its meta-programming?
    • will there be issues with sorbet?
    • will bin/tapioca dsl provide wrong types?
    • will engineers find it difficult to understand?
    • what is the simpler alternative?
      • would it be writing the scope directly onto the models?
        • would the drawback be engineers having to use the same symbol names in each model?
        • how human-error prone would this be vs doing a simple include OrgCompanyScope in each model?