Inara — single-tenant healthcare app turned into a B2B SaaS platform.
Configuration-driven multi-tenancy, per-organisation feature toggles, on-demand onboarding, full white-label, and Arabic RTL i18n — all on a single deployment, single codebase.
multi-organisation B2B platform
configurable on the fly
same codebase as English
The situation (why they called)
Inara had product-market fit as a B2C healthcare and caregiving app. Enterprise interest started landing — hospital networks, employer benefits programs, and a Middle East partner — all asking for a version of the platform their own brand could live on top of, with their own users, their own features turned on, and their own language.
Internally the response had been "we'll fork the code per client" — which would have meant 5 codebases to maintain in 6 months, a hire-or-die situation, and zero benefit from any future product work being shared across customers. The team needed a different approach.
The diagnosis
Three constraints set the architecture:
- Data isolation is non-negotiable — healthcare context means one tenant's data must never appear in another tenant's response. Application-layer enforcement isn't enough; we need defence in depth.
- The product is still evolving fast. Whatever multi-tenancy we add must NOT slow down feature delivery — otherwise the platform shift kills the product.
- The Middle East partner needed Arabic, including layout direction. Half-baked i18n would fail acceptance testing, which would push back the entire go-to-market.
The decision
The architecture, in five layers
1. Tenant identity and request context
- Every request carries an organisation context resolved from the JWT (for users) or API key (for integrations)
- Context is propagated through the FastAPI dependency graph — no service can accidentally query "all data"
- Background jobs explicitly receive a tenant context; no global cron without an org_id
2. Data isolation — application + database
- Every tenant-scoped table has an
organisation_idcolumn with a NOT NULL constraint - PostgreSQL Row-Level Security (RLS) policy on each tenant table: a query can only see rows where
organisation_id = current_setting('app.current_org') - The current org is set per-connection at session start by middleware — even a buggy query can't leak across tenants
- Audit log is partitioned by organisation_id for fast tenant-scoped exports
3. Per-organisation feature toggles (45+ flags)
- Every non-trivial feature gated by a flag stored in
organisation_config - Flags are queryable on the request hot path via a per-process cache (10-second TTL; instant invalidation via PG
LISTEN/NOTIFYon flag updates) - Admins toggle features in a UI — no deploy needed to enable or disable
- Feature flags double as a kill switch for buggy launches and for client-specific rollback
4. On-demand onboarding
- "Add organisation" is a single workflow: name, primary admin, branding, default features, default RBAC
- Onboarding finishes in minutes, not days — no engineering touch needed for new clients
- Branding (logos, colours, copy strings) and theme tokens stored as JSON per org; rendered server-side at request time so the page returns already-themed
5. Internationalisation, including RTL
- Translation files per locale (en, ar, …), keyed by string ID across the codebase
- Per-organisation default language; per-user override
- Arabic RTL handled at the layout level — Tailwind's
dir="rtl"mode, mirrored components, mirrored padding/margin via logical properties (padding-inline-startinstead ofpadding-left) - Date/number formatting via
Intl— no manual locale handling code
The delivery
- Phase 1 (foundation): schema additions for org_id, RLS policies, dependency-injection plumbing for org context, integration tests that assert cross-tenant isolation
- Phase 2 (feature flags): 45+ existing features gated; admin UI to toggle them; cache + invalidation
- Phase 3 (theming + onboarding): per-org branding, white-label theme tokens, the on-demand onboarding flow
- Phase 4 (RBAC): 7-role / 58-permission permission system, again per-organisation
- Phase 5 (i18n): string extraction, translation pipeline, Arabic translation, RTL layout handling, partner UAT
Key decisions (and what we said no to)
- Yes: Single deployment, RLS, feature flags, JSON config columns for branding/theme
- No: Per-client forks of the codebase
- No: Schema-per-tenant in PostgreSQL — operationally expensive, hard to reason about, and not needed when RLS + organisation_id give us defence in depth
- No: A separate "enterprise edition" of the product — extensibility is built in, not a separate SKU
The outcome
45+ per-organisation feature toggles live in production; admins can enable/disable without an engineering ticket.
7-role / 58-permission RBAC per organisation.
Onboarding a new organisation: minutes, not days.
Customisation effort per client: dropped significantly — config rows replace code branches.
Full Arabic (RTL) localisation live, ready for Middle East partner go-live.
What I'd do differently
I'd add the per-tenant audit log during Phase 1, not Phase 4. We initially treated audit as a compliance feature for later — but as soon as the second organisation onboarded, "show me what user X did between dates Y and Z, scoped to MY org" became the most-requested feature. Building it after the fact meant we had to backfill some events from log files. Lesson: tenant-scoped audit is multi-tenancy infrastructure, not a separate compliance feature.
Companion case study: Inara — collapsing 40 microservices into 1 modular monolith ($5K → $500/mo) — same platform, different problem.