When we built the first version of LoyalAura, we made a decision that shaped every technical choice that followed: we would build it multi-tenant from day one. Not as a retrofit, but as a foundational design principle. That decision has proven correct across every project we've delivered — but the journey taught us lessons we wished we'd had written down at the start.
The Three Multi-Tenancy Models
Multi-tenancy isn't one thing — it's a spectrum. Understanding where your product sits on that spectrum before you write a line of code will save you months of refactoring.
- Shared database, shared schema — all tenants in the same tables, differentiated by a tenant_id column. Lowest cost, highest engineering discipline required.
- Shared database, separate schemas — each tenant gets their own PostgreSQL schema within one database. Good middle ground for medium-scale deployments.
- Separate databases per tenant — maximum isolation, highest operational overhead. Only warranted for enterprise clients with strict compliance requirements.
We chose the shared database, shared schema model for LoyalAura's core platform. The reasoning: at the scale we operate (thousands of SMB tenants), the cost and operational complexity of per-tenant databases would be prohibitive. The trade-off is that every query must be tenant-aware — an application-level responsibility that requires rigorous enforcement.
The tenant_id Problem
In a shared schema model, every data access path must filter by tenant_id. Miss one, and you have a data leak. This is the most common failure mode in naive multi-tenant implementations. We solve it with a pattern we call 'tenant middleware injection' — a Django middleware that attaches the current tenant to every database query via a thread-local context, making it impossible to accidentally query across tenant boundaries.
Never rely on developers remembering to add tenant filters manually. Enforce tenant isolation at the ORM layer, not at the view layer.
Database Schema Design
The schema decisions you make early are the hardest to change later. Three principles guide our schema design:
- Every tenant-owned table has a tenant_id UUID foreign key with a database-level NOT NULL constraint and index
- Shared/global tables (e.g. currency definitions, country codes) are clearly separated from tenant data
- Soft deletes (is_active flag + deleted_at timestamp) are preferred over hard deletes to support audit trails
Performance at Scale
The shared schema model requires careful attention to database performance as tenant count grows. Composite indexes on (tenant_id, <query_column>) are non-negotiable. We also implement query result caching per-tenant using Redis with cache keys prefixed by tenant UUID, ensuring cache isolation while achieving the performance benefits.
“A multi-tenant system that isn't designed for isolation from day one will eventually leak data. There is no shortcut.”
— Abdur Razzak, CTO
Authentication & Tenant Resolution
We resolve the current tenant through the subdomain (business.loyalaura.com) on the API side, extracted from the request host header and cached aggressively. JWT tokens carry the tenant_id claim, validated on every authenticated request. This dual validation — subdomain + JWT claim — means a token issued for one tenant cannot be used against another tenant's subdomain even if the token is otherwise valid.
Lessons We'd Tell Our Past Selves
- Design your tenant onboarding flow before your core feature — it touches everything
- Invest in tenant-aware logging and monitoring from day one; debugging production issues across thousands of tenants without it is brutal
- Plan for tenant data export and deletion before you have enterprise clients asking for it
- Rate limiting must be per-tenant, not global — one busy tenant should never degrade others
- Your test suite needs tenant isolation tests. Write them early.

Written by
Abdur Razzak
Chief Technology Officer · ZafSoft Solution
Part of the core team at ZafSoft Solution, building enterprise software trusted by 500+ businesses worldwide.
