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.