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





