Kurumsal

SaaS Substrate

Üzerinde zaten çalışan bir üründen çıkarılmış çok kiracılı SaaS altyapısı. Bugün üç ürün daha taşıyor.

Çalışan bir şeyin içinden çıktı

Altyapı, zaten yayına girmiş bir araştırma ürününün içinden çıktı. O kod tabanının hangi parçalarının araştırmayla ilgisi olmadığı netleşince, o parçalar kendi reposuna taşındı: iki katmanlı kimlik doğrulama, çok kiracılılık, denetim, rıza ve veri hakları, gözlemlenebilirlik, feature flag ve mesajlaşma.

Çıkarma işini 53 karar kaydı anlatıyor. Reddettiğim yol bir scaffold’dan başlamaktı; öyle yapınca elinizde hiçbir ürünün henüz itiraz etmediği bir şekil kalıyor.

Kiracı sınırı nerede duruyor

Kural şu: tenant id bir kolon, bir middleware kontrolü değil. Her domain tablosu indeksli ve boş geçilemeyen bir tane taşıyor, böyle bir tablodaki her sorgu da onu daraltan koşulu taşıyor.

Middleware yine de bir iş yapıyor ve o iş bilerek küçük tutuluyor: istek başına tek bir AsyncLocalStorage deposu açıyor, tenant’ı da token doğrulaması dolduruyor. Repository o depoyu doğrudan okuyor. Aradaki hiçbir katman tenant id’yi elden ele aktarmıyor, dolayısıyla hiçbir use case ihtiyacı olmayan bir parametreye kavuşmuyor ve kimse aktarmayı unutamıyor.

Bunu ayakta tutan şey bir tip kuralı olmuyor. Daraltma çağrısı her çağrı noktasında gözle görülüyor, yani inceleme bir aramaya dönüşüyor. Etrafından dolaşmak için ise çalışma anında on karakterden kısa gerekçeyi reddeden bir sarmalayıcı gerekiyor.

GöstergeDoğrudan yol
  1. İstemciler

    • Üç ürün

      TypeScript

    • Altyapı web uygulaması

      Next.js · React · TypeScript

  2. Sözleşme

    • Altyapı API'si

      NestJS · TypeScript · Bun

  3. İstek başına

    • Kiracı bağlamı

      TypeScript

      Neden

      İstek başına tek depo açılıyor, middleware açıyor, token doğrulanırken doluyor. Onu okuyan her şey, o isteğin ömrü boyunca aynı değeri görüyor.

    • İki katmanlı kimlik

      CASL · TypeScript

      Neden

      Rol tablosunu paylaşmayan iki kitle var. Operatör katmanı her şeyi tutuyor, kiracı katmanı ise yalnızca tek bir takımın içinde verilen izinleri tutuyor; kullanıcı satırı kendi başına bir tenant taşımıyor.

  4. Uygulama

    • Altyapı modülleri

      TypeScript

      Neden

      Denetim, rıza ve hukuk, yaşam döngüsü ve veri hakları, entitlement, davet, onboarding. Her ürünün ihtiyaç duyduğu ve hiçbir ürünün ikinci kez yazmak istemediği parçalar.

    • Açık port kaydı

      TypeScript

      Neden

      Üç port açık, dördüncüsünü açmak altyapıda yapılan ve her fork'a merge ile giden bir değişiklik oluyor. Kapalı liste, "bunu geçersiz kıl" cümlesinin uygulamayı baştan yazmanın ikinci yoluna dönüşmesini engelliyor.

  5. Adaptörler

    • Repository'ler

      Drizzle · TypeScript

      Neden

      Sınırın gerçekten uygulandığı yer burası. Tenant id taşıyan bir tablodaki her sorgu daraltma koşulunu içeriyor; incelemeyi yapan kişinin kontrolü de çağrıyı görebilmek oluyor. Koşulu içermeyen sorgu, kiracılar arası sorgu demek.

  6. Durum

    • İlişkisel veritabanı

      PostgreSQL

Her sorgunun taşımak zorunda olduğu koşulTypeScriptRepository'ler düğümünden
/** * Returns a SQL predicate scoping a query to the current tenant. * * Every `.where(...)` clause on a tenant-bearing table MUST include this * helper (or be wrapped in `withMasterScope` — see below). This is the * load-bearing tenant-isolation seam per ADR 0002. * * Visibility is the point: code review scans for `tenantScope(` at every * repository call site. If you don't see it, the query is cross-tenant. * * @example *   db.select().from(users).where(and(tenantScope(ctx, users.tenantId), ...)) */export function tenantScope(ctx: TenantContextService, tenantIdColumn: AnyPgColumn): SQL {  return eq(tenantIdColumn, ctx.tenantId);}/** * Audited escape hatch for master-tier reads/writes that legitimately span * tenants (operator analytics, support tools, tenant directory listing). * * The `reason` is captured for the future audit log. Today it's enforced * to be non-trivial (≥10 chars); when the audit module is rebuilt on * Drizzle, this hook will emit a structured audit event. * * Never call from tenant-tier code paths. A controller bug here would * leak rows — keep this helper out of any module reachable from a * tenant-tier route. */export function withMasterScope<T>(reason: string, fn: () => T): T {  if (typeof reason !== 'string' || reason.trim().length < 10) {    throw new Error(      `withMasterScope requires a non-trivial audit reason (>=10 chars). Got: ${JSON.stringify(reason)}.`,    );  }  return fn();}
İki fonksiyon var ve kiracı sınırının tamamını ikisi kuruyor. İlki, sorguyu o anki kiracıya daraltan SQL koşulunu döndürüyor; etrafındaki kural bir tip kuralı değil, bir inceleme kuralı olarak duruyor. Tenant taşıyan bir tablodaki her `.where()` bu çağrıyı içeriyor, böylece incelemeyi yapan kişi çağrıyı arayarak içermeyenleri görebiliyor. İkincisi, sınırı denetimli biçimde aşmanın yolu ve on karakterden kısa bir gerekçeyi çalışma anında reddediyor. Kimsenin gerekçelendirmek zorunda olmadığı bir kaçış kapısı zaten kapı sayılmıyor.

saas-substrate41a32abapps/api/src/shared/persistence/application/tenant-scope.tsSatır 5 – 4137 satır

/**
 * Returns a SQL predicate scoping a query to the current tenant.
 *
 * Every `.where(...)` clause on a tenant-bearing table MUST include this
 * helper (or be wrapped in `withMasterScope` — see below). This is the
 * load-bearing tenant-isolation seam per ADR 0002.
 *
 * Visibility is the point: code review scans for `tenantScope(` at every
 * repository call site. If you don't see it, the query is cross-tenant.
 *
 * @example
 *   db.select().from(users).where(and(tenantScope(ctx, users.tenantId), ...))
 */
export function tenantScope(ctx: TenantContextService, tenantIdColumn: AnyPgColumn): SQL {
  return eq(tenantIdColumn, ctx.tenantId);
}

/**
 * Audited escape hatch for master-tier reads/writes that legitimately span
 * tenants (operator analytics, support tools, tenant directory listing).
 *
 * The `reason` is captured for the future audit log. Today it's enforced
 * to be non-trivial (≥10 chars); when the audit module is rebuilt on
 * Drizzle, this hook will emit a structured audit event.
 *
 * Never call from tenant-tier code paths. A controller bug here would
 * leak rows — keep this helper out of any module reachable from a
 * tenant-tier route.
 */
export function withMasterScope<T>(reason: string, fn: () => T): T {
  if (typeof reason !== 'string' || reason.trim().length < 10) {
    throw new Error(
      `withMasterScope requires a non-trivial audit reason (>=10 chars). Got: ${JSON.stringify(reason)}.`,
    );
  }
  return fn();
}
  • Uygulamanın içinden geçmiyor. Tenant id, guard'lar çalışmadan önce bir AsyncLocalStorage deposuna açılıyor ve aşağıda repository içinde yeniden okunuyor. Böylece hiçbir use case onu parametre olarak almıyor, hiçbir geliştirici de aktarmayı hatırlamak zorunda kalmıyor.

Bir fork neyi değiştirebilir

Ürün kimliğini ek olarak uyguluyorum: environment değişkenleri, ürünün kendi dosyaları ve küçük bir merge=ours kümesi. Altyapı dosyası hiç düzenlenmiyor. Bu ancak altyapı doğru dikişleri açarsa ayakta kalıyor, o yüzden bir fork’un değiştirebileceği portları kapalı bir üçlü kayıt halinde tutuyorum.

En net örnek spend meter’da görülüyor. Altyapı onu bir no-op’a bağlıyor ve doğru bağlama bu: altyapı para harcayan hiçbir çağrı yapmıyor. Para harcayan ürün gerçek adaptörü bağlıyor ve bunu yaparken hiçbir altyapı modülüne dokunmuyor.

Katkı

Yazılan commit
962
Kaydedilen karar
53
Üzerine kurulu
3

14 haftanın 12 tanesi aktif · 11 May 2026 – 16 Ağu 2026

En uzun seri · 9 hafta · May – Tem

Tek repo. Dördü arasında düzeltmenin yazılabildiği tek yer burası.

Ölçüm 2026-09-08

Düzeltme nerede yazılır

Düzeltmeler burada yazılıp aşağı merge ediliyor. Fork’un içinde asla yazılmıyor. Bir fork’ta yapılan tamir diğer ikisine görünmüyor ve bir sonraki merge’de conflict olarak geri geliyor. Aynı sebeple workspace scope’u dört reponun hepsinde aynı kalıyor.

Üzerinde üç ürün duruyor. İleri Akademi için yazdığım LGS ve YKS koçluk platformu müşteri işi ve kendi başına 1.048 commit taşıyor, AutoAds’in de burada kendi sayfası var.