Serving multiple customers from a single monolithic application might seem like a relic of the past, but it’s a surprisingly effective strategy for many SaaS providers, especially in the early stages. The real magic isn’t in the monolithic architecture itself, but in how you isolate customer data and behavior within it.

Imagine a single web server running your application code. When a request comes in, it’s for customerA.yourdomain.com or customerB.yourdomain.com. Your application needs to know which customer this request belongs to and then only operate on that customer’s data.

Here’s a simplified example of how you might handle this in a Ruby on Rails application.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_tenant

  private

  def set_current_tenant
    # Extract tenant identifier from subdomain
    tenant_id = request.subdomain.presence || 'default' # Handle cases without subdomains

    # Load the tenant and set it for the current request
    Current.tenant = Tenant.find_by(subdomain: tenant_id)

    unless Current.tenant
      render file: "#{Rails.root}/public/404.html", status: :not_found
    end
  end
end

# app/models/concerns/tenant_scoped.rb
module TenantScoped
  extend ActiveSupport::Concern

  included do
    # Automatically scope queries to the current tenant
    default_scope { where(tenant_id: Current.tenant.id) }

    # Ensure new records are associated with the current tenant
    before_create :assign_tenant_id
  end

  private

  def assign_tenant_id
    self.tenant_id = Current.tenant.id
  end
end

# app/models/user.rb
class User < ApplicationRecord
  include TenantScoped
  # ... other user model logic
end

# app/models/product.rb
class Product < ApplicationRecord
  include TenantScoped
  # ... other product model logic
end

# app/services/order_processing_service.rb
class OrderProcessingService
  def process(order_params)
    # Current.tenant is automatically available here thanks to set_current_tenant
    order = Current.tenant.orders.build(order_params)
    if order.save
      # ... process the order ...
      true
    else
      false
    end
  end
end

# config/routes.rb (example with subdomains)
Rails.application.routes.draw do
  # Constraints to handle subdomains
  # This is a simplified example; a robust solution might use a gem
  scope constraints: lambda { |req| req.subdomain.present? && req.subdomain != 'www' } do
    resources :products
    resources :orders
    # ... other customer-facing resources
  end

  # Root path for the main domain or specific tenant
  root to: 'dashboard#index'
end

This example uses a Current thread-local object to hold the current tenant. The TenantScoped concern automatically adds WHERE tenant_id = ? to every query and ensures new records get the correct tenant_id.

The core problem this solves is data isolation. Without proper multi-tenancy, one customer’s data would be visible to or even modifiable by another. This is a critical security and privacy requirement for any SaaS. The "how it works internally" part is all about context: at any given moment, your application code needs to know which customer it’s acting on behalf of. This context is typically derived from the incoming request (e.g., subdomain, API key, JWT claim) and then propagated throughout the request lifecycle.

The exact levers you control are primarily your data model and your request routing. For the data model, you’ll likely add a tenant_id column to every table that needs to be customer-specific. For request routing, you’ll need a mechanism to identify the tenant from the incoming request and set that context.

The most surprising thing about this approach is how much you can achieve with just a tenant_id column. Many developers imagine complex sharding or separate database instances for each customer. While those are valid scaling strategies, they often introduce immense operational overhead. A well-implemented single-database, single-application multi-tenancy system can scale to thousands of tenants by simply optimizing the queries using the tenant_id index and ensuring your application logic consistently respects that scope. The performance bottleneck often shifts from database architecture to application-level processing, making it a more manageable problem to solve for a long time.

The next hurdle you’ll likely face is managing shared resources and background jobs across tenants.

Want structured learning?

Take the full Monolith course →