Back to all insights
Build Log 2026-04-29 8 min read

Multi-Tenant SaaS Database Design: Lessons From Building MealCircle

How I designed MealCircle's multi-tenant PostgreSQL database — row-level security, tenant isolation testing, schema migration strategy, and what I'd change in hindsight.

The Multi-Tenant Problem in Healthcare SaaS

When I built MealCircle — a retention-first clinical nutrition SaaS for dietitians — I had to make a foundational architectural decision early: how to isolate data between nutrition practices sharing the same database infrastructure.

In healthcare SaaS this isn't just a product decision. Each practice is a separate covered entity under HIPAA. A data leak between tenants is a HIPAA breach regardless of whether it was caused by a bug or by design. The isolation strategy needs to be structurally enforced, not just assumed from application code.

The Three Approaches I Evaluated

1. Separate database per tenant Strongest isolation. Every practice gets their own RDS instance. Simple to reason about, easy to audit. The problem: at small scale, 50 practices means 50 databases with 50x the infrastructure cost and 50x the migration complexity.

2. Shared database, separate schemas Each tenant gets their own PostgreSQL schema (search_path determines which schema is active). Better cost profile than separate databases, still reasonable isolation. The problem: schema migrations become exponentially more complex with tenant count.

3. Shared database, shared tables, row-level security All tenants share the same tables. Isolation enforced at the PostgreSQL RLS layer. Best cost profile, simplest migrations, and — crucially — isolation enforced at the database layer regardless of application code. This is what I chose.

The RLS Implementation

-- All patient-adjacent tables carry practice_id
CREATE TABLE meal_logs (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    practice_id UUID NOT NULL REFERENCES practices(id),
    patient_id  UUID NOT NULL REFERENCES patients(id),
    logged_at   TIMESTAMPTZ NOT NULL,
    meal_data   JSONB NOT NULL,
    created_at  TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE meal_logs ENABLE ROW LEVEL SECURITY;

-- RLS policy: only rows matching the current practice session
CREATE POLICY practice_isolation ON meal_logs
    USING (practice_id = current_setting('app.current_practice_id')::UUID);

-- Application middleware sets this at the start of every request
-- using a transaction-scoped SET LOCAL

The key property: even if application code accidentally omits a `WHERE practice_id = ?" filter, PostgreSQL silently returns only the current practice's rows. This is the same pattern I document in the CCM/PCM software requirements article and recommend for all healthcare multi-tenant SaaS.

Tenant Isolation Testing: The Part Most Teams Skip

After implementing RLS, I wrote a dedicated cross-tenant test suite:

def test_cross_tenant_isolation():
    """Verify that querying as practice A cannot return practice B's data."""
    practice_a_id = create_test_practice()
    practice_b_id = create_test_practice()
    patient_b = create_test_patient(practice_id=practice_b_id)

    # Set session context to practice A
    db.execute("SET LOCAL app.current_practice_id = %s", [str(practice_a_id)])

    # Query that would return all patients if RLS were disabled
    results = db.execute("SELECT * FROM patients").fetchall()

    # Should return zero rows — practice A has no patients
    assert len(results) == 0
    # Explicitly: practice B's patient must not appear
    assert not any(str(r.id) == str(patient_b.id) for r in results)

This test runs on every deployment. If RLS is ever accidentally disabled on a table, it catches it immediately.

Schema Migration Strategy With RLS

One non-obvious complication: PostgreSQL's ALTER TABLE commands bypass RLS. You need to ensure migration scripts don't inadvertently touch cross-tenant data in unexpected ways. I run all migrations as a dedicated migration role with tightly scoped permissions — not as the application role.

What I'd Change

I'd add RLS from migration 1 rather than retrofitting it after the schema was designed. Retrofitting required updating ~20 tables and writing compensatory tests. Starting with RLS as a constraint from day one shapes the schema design in useful ways — you think about tenant context at every table, not as an afterthought.

See MealCircle's features to understand the product context, or the how it works page for the clinical workflow.

The HIPAA & SOC 2 Cloud Architecture service applies this multi-tenant isolation pattern to custom healthcare SaaS builds.

Related Service

HIPAA & SOC 2 Cloud Architecture

Deep-dive into our engineering approach, capabilities, and technical specifications.

View Engineering Specs →
SA

Written by Sheharyar Amin

Founder & Lead Engineer, Opexia