Multi-tenancy
Multi-tenancy adalah keputusan arsitektur yang sangat mahal untuk diubah belakangan. Yang menentukan bukan kerapian kode, melainkan seberapa kuat isolasi data yang benar-benar kamu butuhkan.
Intisari
- Tiga pola: satu database dengan kolom tenant, satu database per tenant, dan skema terpisah.
- Pola kolom paling murah dan paling umum โ tapi seluruh isolasinya bergantung pada kode yang benar.
- Global scope adalah pertahanan utamanya, dan ia harus punya jalan keluar yang jelas.
- Yang paling sering bocor: job antrean, perintah Artisan, dan kunci cache.
- Uji kebocoran sebagai bagian dari suite tes โ bukan sebagai review manual.
Tiga pola
| Kolom tenant | Database per tenant | Skema per tenant | |
|---|---|---|---|
| Isolasi | Logis (bergantung kode) | Fisik | Kuat |
| Biaya per tenant | Nyaris nol | Tinggi | Sedang |
| Migrasi | Sekali | Per tenant | Per tenant |
| Query lintas tenant | Mudah | Sulit | Sulit |
| Backup per tenant | Sulit | Mudah | Sedang |
| Skala ribuan tenant | Baik | Buruk | Sedang |
| Cocok untuk | SaaS umum | Enterprise, kepatuhan ketat | Jalan tengah |
Kalau ragu, mulai dari kolom tenant. Ia mendukung ribuan pelanggan dengan satu set migrasi dan satu klaster database. Pindah ke database terpisah nanti memang berat, tapi biasanya hanya perlu dilakukan untuk segelintir pelanggan besar yang menuntutnya โ dan pola hibrida seperti itu lazim.
Pola kolom tenant
// Trait yang dipakai semua model milik tenant
trait MilikTenant
{
protected static function bootMilikTenant(): void
{
static::addGlobalScope('tenant', function (Builder $q) {
if ($id = Tenant::aktif()?->id) {
$q->where($q->getModel()->getTable().'.tenant_id', $id);
}
});
static::creating(function (Model $model) {
$model->tenant_id ??= Tenant::aktif()?->id
?? throw new RuntimeException('Tidak ada tenant aktif.');
});
}
}
Perhatikan throw di baris terakhir. Membuat baris tanpa tenant aktif harus menjadi
error, bukan menghasilkan baris dengan tenant_id kosong yang kemudian terlihat oleh
semua orang. Gagal keras di sini jauh lebih baik daripada kebocoran diam-diam.
// Konteks tenant โ scoped, BUKAN singleton (lihat Fase 8)
$this->app->scoped(KonteksTenant::class);
// Middleware yang menentukannya
class TentukanTenant
{
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::where('domain', $request->getHost())->firstOrFail();
app(KonteksTenant::class)->set($tenant);
return $next($request);
}
}
Tiga tempat kebocoran paling sering terjadi
| Tempat | Kenapa bocor | Perbaikan |
|---|---|---|
| Job antrean | Worker tidak punya permintaan HTTP, jadi tidak ada tenant aktif | Simpan tenant_id di job, pulihkan di awal handle() |
| Kunci cache | Kunci tanpa ID tenant dipakai bersama semua tenant | Sertakan tenant di setiap kunci |
| Perintah Artisan | Tidak ada tenant; global scope tidak aktif | Iterasi tenant secara eksplisit |
| Query builder mentah | DB::table() melewati global scope | Hindari, atau filter manual |
| Berkas di S3 | Path tanpa tenant | Prefiks path dengan ID tenant |
// Job yang membawa tenant-nya sendiri
class BuatLaporanBulanan implements ShouldQueue
{
public function __construct(
public readonly int $tenantId,
public readonly string $bulan,
) {}
public function handle(): void
{
Tenant::jalankanSebagai($this->tenantId, function () {
// Di dalam sini, global scope kembali aktif
$artikel = Artikel::whereMonth('terbit_pada', $this->bulan)->get();
// ...
});
}
}
// Kunci cache SELALU mengandung tenant
Cache::remember("t{$tenantId}:dasbor:harian", 300, fn () => $this->hitung());
// Berkas juga
Storage::disk('s3')->put("tenant/{$tenantId}/laporan/{$nama}.pdf", $isi);
Kunci cache adalah kebocoran yang paling sulit dilihat. Ia tidak muncul di tes yang menjalankan satu tenant, tidak muncul di review kode yang tidak memikirkan cache, dan gejalanya berupa "kadang datanya aneh" yang sangat sulit direproduksi. Buat aturan tim yang absolut: setiap kunci cache diawali ID tenant, tanpa kecuali โ dan tegakkan lewat wrapper, bukan lewat ingatan.
Menguji isolasi
it('tidak membocorkan artikel antar tenant', function () {
$a = Tenant::factory()->create();
$b = Tenant::factory()->create();
$artikelA = Tenant::jalankanSebagai($a->id, fn () => Artikel::factory()->create());
$artikelB = Tenant::jalankanSebagai($b->id, fn () => Artikel::factory()->create());
Tenant::jalankanSebagai($b->id, function () use ($artikelA, $artikelB) {
expect(Artikel::count())->toBe(1);
expect(Artikel::find($artikelA->id))->toBeNull(); // tidak terlihat
expect(Artikel::find($artikelB->id))->not->toBeNull();
});
});
it('menolak akses lintas tenant lewat URL', function () {
$artikelLain = Tenant::jalankanSebagai($b->id, fn () => Artikel::factory()->create());
actingAsTenant($a)
->get("/artikel/{$artikelLain->id}")
->assertNotFound(); // 404, bukan 403
});
Tes seperti ini wajib ada untuk setiap model milik tenant. Bukan satu tes untuk seluruh aplikasi โ
satu untuk setiap model. Model baru yang lupa memakai trait MilikTenant adalah kebocoran yang
menunggu terjadi, dan satu-satunya cara menangkapnya sebelum produksi adalah tes yang memang mencarinya.
Sebuah arch() yang mewajibkan setiap model di folder tertentu memakai trait itu juga layak
ditambahkan.
Yang perlu diputuskan di awal
- Cara mengenali tenant: subdomain, domain sendiri, atau path. Domain sendiri butuh sertifikat per pelanggan.
- Pengguna lintas tenant: bolehkah satu orang masuk ke dua tenant? Ini mengubah bentuk tabel.
- Data bersama: tabel apa yang memang global โ negara, mata uang, paket langganan.
- Kuota per tenant: batas penyimpanan, jumlah pengguna, dan laju permintaan.
- Menghapus tenant: apa yang benar-benar dihapus, dan berapa lama disimpan.
Latihan: tambahkan tenant_id dan trait MilikTenant pada satu model, lalu
tulis kedua tes isolasi di atas. Setelah hijau, buat satu job yang lupa membawa tenant dan buktikan ia
mengambil data yang salah โ lalu perbaiki.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.