Skip to content
··5 min basahin

Gumawa Ako ng Multi-Tenancy sa Araw 2. Sa Araw 67, Binuo Kong Muli

Inakala kong ang pagdagdag ng org_id sa bawat table ay nangangahulugan ng bulletproof multi-tenancy. Tapos ibinunyag ng security audit ko na ang mga agency ay sumusulat sa SME tables—hindi dahil sa bug, kundi sa disenyo.

Oktubre 27, 2025, 11:47 PM. Nagpapatakbo ako ng akala ko ay routine security audit sa STRATUM. Lahat ay gumagana nang maayos sa loob ng ilang linggo — may sariling data ang SMEs, may sarili ang agencies, solid ang multi-tenancy.

Malamig na ang kape. Umiikot ang audit script sa mga logs. Tapos nakita ko: Ang mga Agency ay sumusulat sa SME tables.

Hindi dahil sa bug. Hindi dahil sa security hole. Dahil sa mismong architecture.

Dalawang buwan na ang nakaraan, ginawa ko ang desisyon na mag-build ng multi-tenant architecture mula Araw 2. Bold na hakbang para sa isang solo founder na may isang gumaganang AI agent. Nagdagdag ako ng `org_id` sa bawat table, sumulat ng RLS policies, gumawa ng hiwalay na routing para sa SMEs at Agencies. Gumagana ito — may sariling campaigns ang SMEs, may sariling kliyente ang agencies, dumadaloy ang data sa tamang lugar.

O akala ko.

Nakaupo siguro ako ng mga 20 minuto na nakatingin lang sa schema. Paano ko na-miss ito?

Ito ang kwento ng pagtuklas na ang tunay na multi-tenant isolation ay nangangailangan ng higit pa sa pagdagdag ng `org_id` sa bawat table — at ang 33 migrations sa loob ng 48 oras na sa wakas ay lumutas nito.

---

Ang Problema: Hindi Pantay ang Lahat ng Tenants

Ang una kong ginawa:

```sql
-- Brand guidelines table (shared by SMEs and Agencies)
CREATE TABLE brand_guidelines (
  id UUID PRIMARY KEY,
  org_id UUID REFERENCES organizations(id),
  name TEXT,
  guidelines JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
```

Ito ay perpektong gumagana para sa SMEs. Bawat organization ay may sariling brand guidelines. Tinitiyak ng Row-Level Security na hindi nila makikita ang data ng isa't isa.

Pero iba ang mga Agency.

Ang mga agency ay hindi lang may isang set ng brand guidelines. Mayroon silang isa bawat kliyente.

Ang naive na solusyon (ang una kong ginawa):

Nagdagdag ng `entity_id` sa shared table. Naging magulo ang mga queries na may complex na `NULL` handling, at bawat feature ay nangangailangan ng "if SME, else Agency" logic sa application code.

Mas masama pa, gumawa ang architecture ng maling mga assumptions:

- Ang mga Agency na sumusulat ng `entity_id=NULL` ay makakapag-pollute ng SME data

- Hindi magkakaroon ang SMEs ng sub-entities kahit gusto nila ng sub-accounts

Hindi ito multi-tenant architecture. Ito ay isang table na sinusubukang pagsilbihan ang dalawang magkaibang data models.

---

Ang Rebelasyon: Kailangan ng Iba't Ibang Tenants ng Iba't Ibang Schemas

Sa late October, narealize ko ang katotohanan: Hindi nagbabahagi ang SMEs at Agencies ng parehong data model.

Ang solusyon: Hiwalay na database schemas.

```sql
-- SME tables (public schema)
public.brand_guidelines
public.campaigns
public.outputs

-- Agency tables (tenant_b schema)
tenant_b.workspace_entities
tenant_b.entity_data
tenant_b.campaigns
tenant_b.outputs
```

Ngayon ganap na magkaibang tables ang SMEs at Agencies. Walang shared schema. Walang nullable na `entity_id` pollution. Walang "if SME, else Agency" logic.

---

Ang Architecture: Schema Routing

Pattern 1: Database Router Functions

Paano ka sumusulat sa tamang schema? Router functions.

```sql
CREATE FUNCTION save_resource_routed(params)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  SELECT type INTO org_type FROM organizations WHERE id = p_org_id;

  IF org_type = 'TENANT_B' THEN
    INSERT INTO tenant_b.entity_data (...) VALUES (...);
  ELSE
    INSERT INTO public.brand_guidelines (...) VALUES (...);
  END IF;

  RETURN result;
END;
$$;
```

Walang if/else sa application code. Ang database ang gumagawa ng routing.

Pattern 2: Security-Invoker Views para sa Reads

```sql
CREATE VIEW resources_unified
WITH (security_invoker = on)
AS
  SELECT id, organization_id, NULL AS entity_id, data, 'public' AS source
  FROM public.brand_guidelines
UNION ALL
  SELECT id, organization_id, entity_id, content AS data, 'tenant_b' AS source
  FROM tenant_b.entity_data
  WHERE data_type = 'brand_guidelines';
```

Key detail: Tinitiyak ng `WITH (security_invoker = on)` na nai-enforce ang RLS policies. Kung wala ito, bina-bypass ng views ang RLS (security disaster).

---

Ang Migration: 33 Migrations sa 48 Oras

Alam mo ba kung ano ang masaya? Sumulat ng 33 database migrations nang sunud-sunod habang alam mong kung kahit ISA ay may typo, masisira ang production data. Actually, "masaya" ay hindi ang tamang salita. "Nakakatakot" ang mas tama. Pinatakbo ko ang bawat migration sa staging nang tatlong beses bago hawakan ang production.

Oktubre 27-29, 2025: 33 sequential migrations para sa kumpletong schema routing.

Kabuuang effort: 33 migrations, 2 araw kasama si Claude Code, 100% sulit.

---

Ang mga Resulta: Tunay na Multi-Tenant Isolation

Dati (Shared Tables na may org_id)

- Nullable na `entity_id` (data model confusion)

- Complex queries na may `NULL` handling

- Application logic: `if (tenantTypeA) { ... } else { ... }`

- Panganib ng cross-contamination

Pagkatapos (Schema Routing)

- Malinis na data models (walang nullable foreign keys)

- Simpleng queries nang walang complex `NULL` handling

- Walang application if/else (database ang humahawak ng routing)

- Imposibleng magkaroon ng cross-schema contamination (pisikal na hiwalay)

---

Mga Aral na Natutunan

1. Kailangan ang org_id, Pero Hindi Sapat

Ang pagdagdag ng `org_id` sa bawat table ay nagbibigay sa iyo ng row-level filtering. Pero kung kailangan ng iba't ibang tenant types ng iba't ibang data models, kailangan mo ng schema routing.

2. Application Logic → Database Logic

Bawat `if (tenantType === 'TYPE_B')` sa iyong application ay isang code smell. Ilipat ang tenant-aware logic sa database gamit ang router functions.

3. Views + RLS = Unified Reads

Ang pagbasa mula sa maraming schemas ay complex. Ang Views + `security_invoker = on` ay nagbibigay sa iyo ng unified reads na may tamang isolation.

4. Nakakatakot ang mga Architectural Flaws Kaysa sa Code Bugs

Ang paghahanap ng null pointer exception sa 11:47 PM? Nakaiinis. Ang pagdiskubre na fundamentally broken ang buong multi-tenant architecture mo? Iyon ang uri ng pagtuklas na nagpapatulog ng gising sa iyo sa gabi.

Pero natutunan ko ito: naaayos ang mga architectural mistakes. Mahal, oo. Matagal, absolutely. Pero nagpunta ako mula "maaaring aksidenteng mag-contaminate ng data ang isang tenant type sa isa pa" patungo sa "database-enforced isolation na imposibleng i-bypass."

Ayusin itong maaga, ayusin itong tama, at mas magiging maayos ang tulog mo.

May natuklasan ka na bang architectural flaw na hindi bug kundi design mistake? Paano mo hinawakan ang rebuild?

Maraming salamat,

Chandler

Ang STRATUM architecture series: Ito ang part 2 ng multi-tenancy journey. Nagsimula ito sa pagbuo ng multi-tenancy sa Araw 2. Pagkatapos ng schema rebuild, natuklasan ko ang 31 blank screens mula sa nawawalang navigation context at ang database ko ay tama pero 296x masyadong mabagal.

---

Gumagawa ng multi-tenant SaaS na may complex isolation needs? Gumagamit ang STRATUM ng schema routing para pagsilbihan ang iba't ibang tenant types na may tunay na data isolation. Mag-request ng alpha access sa https://stratum.chandlernguyen.com/request-invitation

Ipagpatuloy ang Pagbasa

Ang Journey Ko
Kumonekta
Wika
Mga Preference