On 26 August 2025, we implemented the most architecturally significant feature yet: multi-tenancy. This transformation enabled a single application deployment to serve multiple letting agencies, each with completely isolated data, independent branding, and zero risk of data leakage between tenants. It's the difference between hosting software and operating a platform.
For letting agencies—particularly franchise networks, agencies with multiple trading brands, or software vendors serving the lettings market—multi-tenancy is transformational. Instead of deploying separate application instances for each agency (expensive, operationally complex, and slow to update), one deployment serves dozens or hundreds of agencies. Each agency gets their own subdomain (northside.let-admin.com, southside.let-admin.com), their data remains completely isolated, and they're unaware other agencies exist on the same platform.
This article explores how we built it, why the architectural choices matter, and what it means for agencies considering purpose-built software.
What Your Team Will Notice
From an agency's perspective, multi-tenancy is invisible—which is exactly right. Staff log in at their agency's subdomain, see only their properties and tenants, and operate as if the software were built exclusively for them. There's no dropdown to "switch agencies," no accidentally viewing competitors' data, and no performance degradation from serving thousands of properties across all agencies—because queries are scoped to return only the current agency's data.
For franchise networks, this is transformative. Headquarters can provision new franchisees in minutes: create an agency record, assign a subdomain, provision admin credentials, done. Each franchisee operates independently but can optionally share resources (templates, compliance checklists, vendor contacts) maintained centrally. Franchise fees can be calculated programmatically based on property counts or revenue metrics aggregated across the network.
For software vendors serving multiple agencies, multi-tenancy enables efficient operations. One deployment to monitor, one database to back up, one set of security patches to apply. Updates roll out to all agencies simultaneously. Economies of scale emerge: hosting costs grow sub-linearly with customer count, and features built for one agency benefit all agencies.
Under the Bonnet: The Agency Model
Multi-tenancy begins with the Agency model—the central entity representing a letting agency business:
# app/models/agency.rb
class Agency < ApplicationRecord
has_many :users, dependent: :destroy
has_many :properties, dependent: :destroy
has_many :property_photos, through: :properties
validates :name, presence: true
validates :subdomain, presence: true,
uniqueness: { case_sensitive: false },
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/,
message: "must be lowercase alphanumeric with hyphens" }
before_validation :normalize_subdomain
private
def normalize_subdomain
self.subdomain = subdomain.to_s.downcase.strip
end
end
The subdomain field is critical—it determines how agencies are identified. Subdomains must be:
- Unique: No two agencies can share a subdomain
- URL-safe: Lowercase alphanumeric with hyphens only
- Normalized: Automatically converted to lowercase to prevent case-sensitivity issues
The has_many associations establish ownership. An agency has many users (staff members), many properties (rental listings), and transitively many property photos. The dependent: :destroy ensures deleting an agency cascades to all associated records, maintaining database referential integrity.
acts_as_tenant: Database-Level Isolation
We use the acts_as_tenant gem, which provides automatic query scoping. Every model belonging to an agency includes:
# app/models/property.rb
class Property < ApplicationRecord
acts_as_tenant :agency
has_many :property_photos, dependent: :destroy
# ... validations, scopes, etc.
end
# app/models/user.rb
class User < ApplicationRecord
acts_as_tenant :agency
devise :database_authenticatable, :recoverable,
:rememberable, :validatable
end
The acts_as_tenant :agency declaration does remarkable work. It modifies every query on that model to include WHERE agency_id = [current agency ID]. Consider:
# Without acts_as_tenant (unsafe!)
Property.all
# => SELECT * FROM properties
# With acts_as_tenant (automatically scoped)
Property.all
# => SELECT * FROM properties WHERE agency_id = 42
This happens transparently. Developers write Property.all but get only the current agency's properties. Accidentally querying across agencies becomes architecturally impossible—the gem prevents it at the database query level.
The database migration added agency_id foreign keys:
# db/migrate/20250826140728_add_agency_id_to_properties.rb
class AddAgencyIdToProperties < ActiveRecord::Migration[8.0]
def change
add_reference :properties, :agency, null: false, foreign_key: true
# Backfill existing records (for migration only)
reversible do |dir|
dir.up do
default_agency = Agency.first || Agency.create!(
name: "Default Agency",
subdomain: "default"
)
Property.update_all(agency_id: default_agency.id)
end
end
end
end
The migration is non-trivial because existing properties have no agency association. We create or select a default agency and backfill all properties to belong to it. In production, this would be coordinated with data migration scripts specific to the deployment scenario.
Subdomain Middleware: Request Routing
The AgencySubdomainMiddleware intercepts every HTTP request and sets the current tenant:
# lib/agency_subdomain_middleware.rb
class AgencySubdomainMiddleware
# Intercepts every HTTP request early in the Rails stack
# Extracts subdomain from Host header
# Looks up agency by subdomain
# Sets current tenant context for the request
# Returns 404 for unrecognized subdomains
end
This middleware runs early in the Rails request cycle:
- Extract subdomain: Parse the
Hostheader (northside.let-admin.com→northside) - Lookup agency: Query the database for
Agency.find_by(subdomain: 'northside') - Set current tenant:
ActsAsTenant.current_tenant = agency - Continue request: Pass control to Rails routing and controllers
Once current_tenant is set, all queries on tenanted models automatically scope to that agency. Controllers don't need special filtering logic—it's implicit.
The middleware is registered in config/application.rb:
# config/application.rb
require_relative '../lib/agency_subdomain_middleware'
module LetAdmin
class Application < Rails::Application
# ...
config.middleware.insert_before 0, AgencySubdomainMiddleware
end
end
insert_before 0 ensures the middleware runs before Rails routing, session management, or authentication. The tenant must be set before any other application logic executes.
Application Controller: Verification and Security
The ApplicationController enforces additional security:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# Verifies authenticated users belong to the current tenant
# Prevents cross-agency access attempts
# Signs out users attempting to access wrong subdomain
end
This catches a subtle attack vector: what if a user from Agency A manually navigates to Agency B's subdomain while still logged in? The subdomain middleware would set Agency B as the current tenant, but the user's session cookie (from Agency A) would remain valid.
The controller-level security verification detects this mismatch between the authenticated user's agency and the current subdomain's tenant. When detected, the user is signed out and redirected to login.
This defence-in-depth approach prevents session hijacking, subdomain manipulation, and accidental cross-agency access.
Testing Multi-Tenancy Security
The test suite includes comprehensive multi-tenancy security specs:
# spec/security/multi_tenancy_security_spec.rb
RSpec.describe "Multi-Tenancy Security", type: :request do
let(:agency_a) { create(:agency, subdomain: 'agency-a') }
let(:agency_b) { create(:agency, subdomain: 'agency-b') }
let(:user_a) { create(:user, agency: agency_a) }
let(:property_a) { create(:property, agency: agency_a) }
let(:property_b) { create(:property, agency: agency_b) }
before do
sign_in user_a
end
it "prevents users from accessing properties from other agencies" do
ActsAsTenant.with_tenant(agency_a) do
get property_path(property_b), headers: { 'Host' => 'agency-a.example.com' }
expect(response).to have_http_status(:not_found)
end
end
it "scopes property queries to current agency" do
ActsAsTenant.with_tenant(agency_a) do
properties = Property.all
expect(properties).to include(property_a)
expect(properties).not_to include(property_b)
end
end
it "signs out users accessing wrong subdomain" do
get dashboard_path, headers: { 'Host' => 'agency-b.example.com' }
expect(response).to redirect_to(new_user_session_path)
expect(flash[:alert]).to include("Access denied")
end
end
These tests verify:
- Users cannot access records from other agencies (404 errors)
- Database queries automatically scope to the current agency
- Cross-subdomain access attempts are caught and rejected
Running these tests on every commit (via CI/CD pipeline) ensures tenant isolation remains intact as the codebase evolves.
Database Indexes: Performance at Scale
Multi-tenancy introduces a performance concern: every query includes WHERE agency_id = X. Without proper indexes, these queries perform table scans—catastrophically slow as data grows. The migrations added composite indexes:
# In migration files
add_index :properties, [:agency_id, :updated_at]
add_index :property_photos, [:agency_id, :property_id]
add_index :users, [:agency_id, :email], unique: true
These indexes enable:
- Fast lookups: "Get all properties for agency 42 ordered by updated_at"
- Uniqueness constraints: "Ensure email addresses are unique within each agency" (different agencies can have users with the same email)
PostgreSQL's query planner uses these indexes automatically, keeping queries fast even with millions of records across hundreds of agencies.
Shared vs. Separate Databases
Multi-tenancy architectures generally fall into three categories:
- Shared database, shared schema (our approach): All agencies' data in one database, filtered by
agency_id - Shared database, separate schemas: Each agency gets a PostgreSQL schema, isolated within one database
- Separate databases: Each agency gets a dedicated database
We chose shared database/shared schema because:
- Simplicity: One connection pool, one migration workflow, one backup job
- Cost efficiency: PostgreSQL connection limits favour shared databases
- Feature parity: Schema changes deploy to all agencies simultaneously
- Analytics: Cross-agency reporting (aggregated, anonymized) is straightforward
Separate schemas or databases offer stronger isolation but add operational complexity. For agencies subject to regulatory data residency requirements (e.g., EU data must stay in EU), separate databases per region is feasible—but overkill for most UK lettings agencies.
What About Performance?
A common concern: "Won't one agency's heavy usage slow down others?" In practice, no—if the architecture is sound:
- Database connection pooling: Heavy queries from Agency A don't block Agency B's requests (separate connections)
- Query indexes:
agency_idindexes ensure queries remain fast regardless of total row count - Application server scaling: Heroku can run multiple dynos, distributing load across agencies
- Caching: Redis or Rails cache stores can scope cached data by agency_id, preventing cache contamination
If one agency genuinely overwhelms the system (e.g., bulk-importing 50,000 properties), rate limiting and background job queuing (using SolidQueue, configured earlier in the week) prevent them from monopolizing resources.
Onboarding New Agencies
With multi-tenancy in place, onboarding becomes programmatic:
# In a Rails console or admin interface
agency = Agency.create!(
name: "Northside Lettings Ltd",
subdomain: "northside",
contact_email: "info@northside-lettings.co.uk"
)
admin_user = User.create!(
agency: agency,
email: "admin@northside-lettings.co.uk",
password: SecureRandom.hex(16),
first_name: "Admin",
last_name: "User"
)
# Send welcome email with credentials...
Minutes later, northside.let-admin.com is live. The admin logs in, configures branding, imports properties, and invites staff. No deployment, no server provisioning, no infrastructure setup.
For franchise networks, this enables self-service provisioning. Franchisees complete an online form, payment is processed, and their agency subdomain activates automatically. This operational efficiency dramatically changes the economics of serving letting agencies.
What's Next
Multi-tenancy unlocked features we'd build in subsequent weeks:
- Agency-specific branding: Custom logos, colour schemes, and email templates
- Franchise analytics: Aggregated reporting across agency networks
- White-label deployments: Partners resell the platform under their own branding
- Tiered pricing: Different feature sets for different subscription tiers
But the foundation—secure, performant, database-level tenant isolation with subdomain routing—was established on 26 August. Every feature built afterward would benefit from this solid architectural decision.
Related articles:
