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.
Sistem
İstemciler
Üç ürün
TypeScript
Altyapı web uygulaması
Next.js · React · TypeScript
Sözleşme
Altyapı API'si
NestJS · TypeScript · Bun
İ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.
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.
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.
Durum
İlişkisel veritabanı
PostgreSQL
5/**6 * Returns a SQL predicate scoping a query to the current tenant.7 *8 * Every `.where(...)` clause on a tenant-bearing table MUST include this9 * helper (or be wrapped in `withMasterScope` — see below). This is the10 * load-bearing tenant-isolation seam per ADR 0002.11 *12 * Visibility is the point: code review scans for `tenantScope(` at every13 * repository call site. If you don't see it, the query is cross-tenant.14 *15 * @example16 * db.select().from(users).where(and(tenantScope(ctx, users.tenantId), ...))17 */18export function tenantScope(ctx: TenantContextService, tenantIdColumn: AnyPgColumn): SQL {19 return eq(tenantIdColumn, ctx.tenantId);20}2122/**23 * Audited escape hatch for master-tier reads/writes that legitimately span24 * tenants (operator analytics, support tools, tenant directory listing).25 *26 * The `reason` is captured for the future audit log. Today it's enforced27 * to be non-trivial (≥10 chars); when the audit module is rebuilt on28 * Drizzle, this hook will emit a structured audit event.29 *30 * Never call from tenant-tier code paths. A controller bug here would31 * leak rows — keep this helper out of any module reachable from a32 * tenant-tier route.33 */34export function withMasterScope<T>(reason: string, fn: () => T): T {35 if (typeof reason !== 'string' || reason.trim().length < 10) {36 throw new Error(37 `withMasterScope requires a non-trivial audit reason (>=10 chars). Got: ${JSON.stringify(reason)}.`,38 );39 }40 return fn();41}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.