Multi-tenancy — Database

Apa itu Multi-tenancy? Multi-tenancy adalah arsitektur di mana satu aplikasi melayani banyak tenant (klien/organisasi) dengan data yang terisolasi. Setiap…

Multi-tenancy artinya satu aplikasi melayani banyak organisasi sekaligus, dan data tiap organisasi terpisah: yang satu gak boleh melihat data yang lain. Tiap organisasi itu disebut tenant, artinya penyewa. Bayangin gedung apartemen. Gedungnya satu, lift dan pipa airnya dipakai bersama, tapi tiap unit punya kunci sendiri, dan penghuni unit 3 gak bisa masuk ke unit 7.

🧪 Tebakan cepat: aplikasi kasir Kedai Senja sekarang juga dipakai Kopi Pagi. Admin Kedai Senja membuka widget "Pelanggan terbaru", yang kodenya Customer::latest('id')->take(5)->get(), tanpa filter tenant. Yang muncul cuma pelanggan Kedai Senja, atau pelanggan Kopi Pagi juga? Tebak dulu, jawabannya ada di bagian "Satu query lupa scope".

Tabel yang kita pakai

Aplikasi kasir Kedai Senja laku, dan sekarang disewakan ke kedai lain. Kedai Senja jadi tenant 1, Kopi Pagi tenant 2. Cara paling sederhana: semua tenant memakai tabel yang sama, dan setiap baris diberi kolom tenant_id:

CREATE TABLE tenants (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE customers (
  id INTEGER PRIMARY KEY,
  tenant_id INTEGER NOT NULL REFERENCES tenants(id),
  name TEXT NOT NULL,
  phone TEXT
);
CREATE INDEX customers_tenant_id_index ON customers (tenant_id);

INSERT INTO tenants VALUES (1, 'Kedai Senja'), (2, 'Kopi Pagi');
INSERT INTO customers VALUES
  (1, 1, 'Budi', '0812-1111-2222'),
  (2, 1, 'Siti', '0813-5555-6666'),
  (3, 2, 'Dewi', '0821-3333-4444'),
  (4, 1, 'Andi', '0819-7777-8888'),
  (5, 2, 'Rina', '0822-6666-7777');

Setiap query wajib memfilter tenant-nya:

SELECT * FROM customers WHERE tenant_id = 1;  -- Budi, Siti, Andi

Index di tenant_id wajib juga, karena kolom itu ikut di hampir setiap query.

Satu query lupa scope

Halaman Pelanggan ditulis dengan benar. Widget dashboard ditulis buru-buru:

// halaman Pelanggan: benar
Customer::where('tenant_id', $tenantId)->get();

// widget "Pelanggan terbaru": lupa filter tenant
Customer::latest('id')->take(5)->get();
SELECT * FROM customers ORDER BY id DESC LIMIT 5;
id tenant_id name phone
5 2 Rina 0822-6666-7777
4 1 Andi 0819-7777-8888
3 2 Dewi 0821-3333-4444
2 1 Siti 0813-5555-6666
1 1 Budi 0812-1111-2222

Rina dan Dewi, pelanggan Kopi Pagi, muncul di dashboard Kedai Senja, lengkap dengan nomor HP-nya. Itu jawaban tebakannya. Gak ada error sama sekali, query-nya "berhasil". Ini bug paling berbahaya di aplikasi multi-tenant, karena hasilnya kebocoran data.

Global scope: filter yang gak bisa lupa

Daripada mengandalkan semua developer ingat menulis where('tenant_id', ...), pasang filternya sekali di model lewat global scope, filter yang otomatis ditambahkan Eloquent ke setiap query model itu:

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $query) {
            $query->where('tenant_id', auth()->user()->tenant_id);
        });

        static::creating(function (Model $model) {
            $model->tenant_id ??= auth()->user()->tenant_id;
        });
    }
}

class Customer extends Model
{
    use BelongsToTenant;
}

Lubangnya sama seperti soft delete: global scope cuma milik Eloquent. DB::table('customers'), SQL mentah, dan withoutGlobalScope('tenant') melewatinya.

Tiga strategi multi-tenancy

Kolom tenant_id di tabel bersama baru satu dari tiga cara. Bedanya ada di seberapa terpisah data antar tenant:

  1. Shared tables (satu database, tabel bersama, kolom tenant_id): seperti satu lemari besar, tiap kotak diberi label nama pemiliknya. Paling sederhana dan paling murah. Satu migration untuk semua tenant, tambah tenant cukup satu baris di tenants. Risikonya: satu query lupa filter, data bocor.
  2. Separate schema (schema per tenant): satu database, tapi tiap tenant punya schema sendiri. Di PostgreSQL, schema adalah folder tabel di dalam satu database: kedai_senja.customers dan kopi_pagi.customers adalah dua tabel berbeda. Per request, aplikasi menjalankan SET search_path TO kopi_pagi;, dan query-nya gak butuh tenant_id lagi. Isolasi lebih baik, tapi setiap migration dijalankan sekali per schema.
  3. Separate database (database per tenant): tiap tenant punya database sendiri, dan per request aplikasi memilih koneksi ke database tenant itu. Isolasinya paling kuat: query yang lupa filter pun cuma bisa melihat data tenant itu, karena data tenant lain gak ada di database tersebut. Backup dan restore satu tenant tinggal database-nya. Harganya paling mahal: ratusan database untuk dirawat, migration per database, dan koneksi per database.
Shared tables Separate schema Separate database
Isolasi Rendah Sedang Tinggi
Tambah tenant 1 baris di tenants CREATE SCHEMA + migration CREATE DATABASE + migration
Migration Sekali untuk semua Sekali per schema Sekali per database
Backup satu tenant Susah, harus difilter per tabel Per schema Tinggal database-nya
Biaya Paling murah Sedang Paling mahal

Aturan di latihan mengikuti baris "Isolasi" itu: kebutuhan isolasi "rendah" jadi "shared tables", "sedang" jadi "separate schema", "tinggi" jadi "separate database", dan yang lain "shared tables". Contoh isolasi tinggi: klinik atau bank yang kontraknya mewajibkan datanya disimpan terpisah.

Di Lab, database per tenant ditiru dengan file database SQLite per tenant. Request Kopi Pagi cuma membuka file kopi_pagi.db (lewat ATTACH), jadi SELECT * FROM customers tanpa filter pun gak bisa menyentuh pelanggan Kedai Senja.

📌 Row Level Security di PostgreSQL

PostgreSQL bisa memaksa filter tenant di level database, jadi query yang lupa WHERE pun tetap aman:

ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON customers
  USING (tenant_id = current_setting('app.tenant_id')::int);

Per request, aplikasi menjalankan SET app.tenant_id = '1';, dan database sendiri yang menyaring barisnya. Perhatikan: pemilik tabel melewati policy ini, kecuali ditambah ALTER TABLE customers FORCE ROW LEVEL SECURITY;. Package Laravel seperti stancl/tenancy dan spatie/laravel-multitenancy mengurus pemilihan tenant per request untuk ketiga strategi.

⚠️ Jebakan yang sering ketemu

TL;DR: Multi-tenancy: satu aplikasi melayani banyak organisasi (tenant) dengan data terpisah. Cara paling sederhana adalah shared tables: satu database, kolom tenant_id di setiap tabel, dan setiap query wajib WHERE tenant_id = ?. Satu query yang lupa filter membocorkan data tenant lain tanpa error, jadi pasang global scope di model. Separate schema memberi tiap tenant schema sendiri (isolasi sedang). Separate database memberi tiap tenant database sendiri: isolasi paling kuat dan backup per tenant gampang, tapi paling mahal dirawat.

Yang akan kamu pelajari