Multi-Tenant SaaS'ta Tenant Routing'i Nereye Koyacağıma 4 Kez Karar Verdim
Backend

Multi-Tenant SaaS'ta Tenant Routing'i Nereye Koyacağıma 4 Kez Karar Verdim

Elly'de tenant routing'i nereye koyacağıma dört kez karar verdim. İlk üçü yeni katman ekledi, dördüncüsü kod sildi — ve asıl çözüm oydu. Multi-tenant mimari serisinin ilk bölümü.

Hüseyin DOL
Hüseyin DOL
14 dk okuma

Bu yazı 3 bölümlük bir serinin ilk bölümü. Bölüm 1: Tenant Routing Kararları (buradasınız) Bölüm 2: Database-per-Tenant ve Connection Pool Patlaması Bölüm 3: Frontend'de Tenant Context — Provider'sız Yaklaşım

Multi-tenant bir SaaS yazarken kafayı en çok yoran soru genelde "veritabanını nasıl ayıracağım?" sanılır. Ama benim için asıl zor olan kısım başka bir yerdeydi:

Gelen bir HTTP isteğinin hangi tenant'a ait olduğunu nereden anlayacağım?

Bu sorunun cevabını Elly'de dört farklı kez verdim. İlk üçünde her seferinde bir şey ekledim: yeni bir header, yeni bir token tipi, yeni bir filtre dalı. Dördüncüsünde ilk kez bir şey sildim — ve asıl çözüm o oldu.

Bu yazı o dört kararın hikâyesi. Sonuncusu, ilk üçünün neden yetmediğini de açıklıyor.

Elly'nin Mimarisi Kısaca

Yazıya geçmeden önce kısa bir bağlam:

Elly, headless CMS mimarisi barındıran bir Java Spring Boot API projesi. İki ayrı frontend tarafından tüketiliyor:

  • Backend (Java 21 / Spring Boot 3.5) — Tüm iş mantığı ve veri katmanı
  • Panel (Next.js) — Admin/içerik yönetim paneli
  • Tenant Website (Next.js) — Müşterinin public sitesi + kayıtlı kullanıcı alanı

Database-per-tenant modelini seçtim: her tenant'ın kendi PostgreSQL veritabanı var. K3s üzerinde Kubernetes ile ayağa kalkıyor, GitHub Actions ile deploy oluyor. RabbitMQ mail kuyruğu, Redis cache, WebSocket/STOMP ile gerçek zamanlı chat ve WebRTC görüntülü görüşme.

Asıl problem şu: Backend'e bir HTTP isteği geldiğinde, hangi veritabanına bağlanacağımı bilmem gerek. Bu yüzden tenant routing her şeyin başlangıç noktası.


İlk Deneme: X-Tenant-Id Header

İlk çözümüm en basit olanıydı. Frontend her API çağrısına bir custom header eklesin:

GET /api/v1/chat/groups
X-Tenant-Id: acme
Authorization: Bearer <admin-jwt>

Backend tarafında bir interceptor bu header'ı okuyup ThreadLocal'a yazıyor, sonra AbstractRoutingDataSource doğru tenant DB'sine yönlendiriyor. Temiz, basit, çalışıyor.

İki hafta sonra bir güvenlik gözden geçirmesinde soru geldi:

"Bu header'ı herhangi bir client gönderebilir. Bir tenant kullanıcısı kendi JWT'siyle giriş yapıp X-Tenant-Id: rakip-firma header'ı atarsa ne olur?"

Cevap: Olur. Engellemek için her endpoint'te "bu kullanıcının bu tenant'a erişim yetkisi var mı?" kontrolü gerekirdi. Yani header sadece "öneri" düzeyinde, otorite değil.

Bu, "kim?" ile "nereye?" sorularını ayrı kanallardan taşımanın yarattığı klasik bir problem. Authorization katmanı tenant bağlamından habersiz olduğu için, her endpoint'in kendi kontrolünü yapması gerekiyor — ve bu hata yapmaya çok açık.

Birinci ders: Tenant bilgisi keyfi bir header'da yaşayamaz. Bir otorite zincirine bağlı olmalı.


İkinci Deneme: Tenant Switch Token

İkinci çözümüm daha karmaşık bir yapıya dayanıyordu. Mantık şuydu:

Kullanıcı login olduğunda admin JWT'si alıyor. Bir tenant'a erişmek istediğinde, bu admin JWT'siyle ayrı bir "tenant-scoped token" fetch ediyor. Bu yeni token kısa ömürlü ve içinde hangi tenant'a erişim hakkı olduğu yazıyor.

1. POST /api/v1/auth/login → admin-jwt
2. POST /api/v1/tenants/token { tenantId: "acme" } → tenant-scoped-jwt
3. GET /api/v1/chat/groups
   Authorization: Bearer <tenant-scoped-jwt>

Teorik olarak güzel. Pratikte yıkımdı. Birkaç saat içinde frontend'de şu sorunlar çıktı:

Token cache yönetimi: Panel'de kullanıcı bir tenant'tan diğerine geçtiğinde token'ı invalidate edip yenisini fetch etmek gerekiyordu. Her grup değişiminde fazladan bir istek.

WebSocket sorunu: Chat gerçek zamanlı çalışıyor. WS bağlantısı uzun ömürlü olduğu için admin JWT kullanıyordu; REST çağrıları ise tenant token. Yani WS ve REST farklı kimlik kurallarıyla çalışıyordu. Bir grup mesajı gönderirken WS "ben admin'im" diyordu, REST "ben acme tenant'ındayım". Hangisi gerçek?

Spring Security ile kavga: Tenant token'ını admin token'ıyla aynı SecurityContext üzerinde yaşatmak Spring'in authentication mantığını kırıyordu.

Ama bütün bu belirtilerin altında yatan asıl kök neden şuydu: ürettiğim tenant-scoped token kimliksizdi. İçinde yalnızca tenantId ve type: "tenant" claim'leri vardı, kullanıcı bilgisi yoktu. Spring Security'nin isAuthenticated() kontrolü bu token'ı geçerli bir kimlik saymıyor ve reddediyordu — sonuçta tenant chat sekmesi tamamen çalışmaz hale geldi. Token'a kullanıcıyı da gömmeye çalışınca, bu sefer "kim?" ve "nereye?" bilgisini aynı nesnede taşıma problemine geri döndüm.

İkinci ders: "Kim?" sorusu ve "Nereye?" sorusu farklı yerlerde yaşamalı. Kimlik JWT'de kalmalı, scope başka bir kanaldan gelmeli.


Üçüncü Deneme: URL Path Routing

Üçüncü çözüm geriye dönüp baktığımda neredeyse fazla basit görünüyor — ama oraya ulaşmak için iki "katmanlı" çözümün başarısız olması gerekti.

Kimlik JWT'de, hedef tenant URL'de.

GET /api/v1/chat/tenant/acme/groups
Authorization: Bearer <admin-jwt>

URL diyor ki "acme tenant'ının chat'ine bakıyorum", JWT diyor ki "ben admin'im". İki bilgi farklı katmanlarda taşınıyor, birbirine karışmıyor. WebSocket destination'ı da aynı kurala uydu (/app/tenant-chat/{tid}/{groupId}/typing), böylece REST ve WS ilk kez aynı dili konuştu. Bu değişiklikle frontend'den 200+ satır kod silindi: overrideAuth kalktı, tenant.services.ts tamamen gitti.

Bir süre "tamam, çözdüm" dedim. Ama bir şey rahatsız ediyordu — ve o rahatsızlık resolveTenantId fonksiyonundaydı.

Yavaş yavaş büyüyen istisna listesi

Başlangıçta "JWT'den tenantId claim'ini çıkar ve dön" diye 5 satırlık bir fonksiyon olacağını sanmıştım. Zamanla şuna dönüştü:

private String resolveTenantId(HttpServletRequest request) {
    String path = request.getRequestURI();
 
    // 1) Chat + Notifications REST: her zaman merkezi DB
    if (path.startsWith("/api/v1/chat/") || path.startsWith("/api/v1/notifications")) {
        return null;   // null = "varsayılan DataSource'a git"
    }
 
    String authHeader = request.getHeader("Authorization");
    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
        return null;
    }
 
    String jwt = authHeader.substring(7);
    String tenantId  = jwtUtil.extractTenantId(jwt);
    String loginSource = jwtUtil.extractLoginSource(jwt);
 
    boolean isCentralPath = path.startsWith("/api/v1/auth/")
        || path.startsWith("/api/v1/users")
        || path.startsWith("/api/v1/roles");
 
    // 2) Admin login: auth, users, roles her zaman merkezi DB
    if ("admin".equals(loginSource) && isCentralPath) {
        return null;
    }
 
    return (tenantId != null && !tenantId.isBlank()) ? tenantId : null;
}

Bu fonksiyonun her return null; satırı şu anlama geliyordu: "Bu istek tenant'a ait değil, merkezi veritabanına (basedb) gitsin."

Çünkü o dönemde mimaride basedb diye ortak bir veritabanı vardı: admin kullanıcılar, roller, admin chat mesajları ve bildirimler orada duruyordu. Tenant DB'leri yalnızca "müşteriye ait içerik" tutuyordu.

Kod çalışıyordu. Ama üç ay içinde şu üç şey oldu:

  1. İstisna listesi büyüdü. Her yeni modülde "bu merkezi mi, tenant mı?" sorusunu yeniden cevaplamam gerekti. Chat merkeziydi, sonra "tenant chat" çıktı ve artık ikisi de vardı: aynı tabloların bir kopyası basedb'de, bir kopyası tenant DB'sinde.

  2. null iki farklı anlama gelmeye başladı. Hem "bilinçli olarak merkezi DB istiyorum" hem de "tenant'ı çözemedim". İkisi kodda ayırt edilemiyordu.

  3. En kötüsü: sessiz yanlış DB. Tenant context kurulmayı unutulduğunda istek hata vermiyordu — sessizce basedb'ye gidiyordu. Kullanıcı "kaydettim ama görünmüyor" diyordu, log'da hata yoktu. Bu tip bug'ları avlamak saatler alıyor.

  4. maddeyi bir kez canlıda yaşayınca şunu anladım: sorun resolveTenantId'nin karmaşıklığı değildi. Sorun basedb'nin kendisiydi.


Dördüncü Karar: Merkezi Veritabanını Tamamen Sildim

Bu sefer bir katman eklemedim. Şu soruyu sordum:

"Bu merkezi veritabanı gerçekten neyi çözüyor?"

Dürüst cevap: hiçbir şeyi. Sadece "admin kullanıcılar bir yerde toplansın" konforu sağlıyordu. Karşılığında ödediğim bedel ise ağırdı: her istekte "bu hangi DB?" belirsizliği, çift kopya tablolar, sessiz yanlış-DB hataları ve iki farklı chat modeli.

Yeni kural tek cümle:

Sistemde ortak veritabanı yoktur. Kullanıcılar dahil her şey, ait olduğu tenant'ın kendi veritabanında yaşar.

Admin kullanıcılar da bir tenant'a ait artık. tenant1'in admini elly_tenant1 veritabanında; tenant2'nin admini elly_tenant2'de. Chat grupları, bildirimler, roller, izinler — hepsi tenant DB'sinde.

Bu kararın kodda yarattığı etki beni bile şaşırttı. resolveTenantId şuna indi:

/** Tenant = Authorization Bearer JWT'sindeki tenantId claim'i; yoksa null. */
private String resolveTenantId(HttpServletRequest request) {
    String authHeader = request.getHeader("Authorization");
    if (authHeader == null || !authHeader.startsWith("Bearer ")) {
        return null;
    }
    try {
        String tenantId = jwtUtil.extractTenantId(authHeader.substring(7));
        return (tenantId != null && !tenantId.isBlank()) ? tenantId : null;
    } catch (Exception e) {
        log.debug("Could not extract tenantId from JWT: {}", e.getMessage());
        return null;
    }
}

Path kontrolü yok. loginSource istisnası yok. Merkezi DB dalı yok. Tek kural: tenant, JWT'nin tenantId claim'inden gelir.

Peki anonim istekler?

Bir ziyaretçi tenant sitesinde blog yazısı okurken JWT'si yok. O akış ayrı bir filtreye ait — PublicApiFilter:

GET /api/v1/public/tenant1/posts/list

Bu filtre URL'den {tid}'yi çıkarır, context'e yazar, path'i içeride /api/v1/posts/list'e yeniden yazar. Yazma istekleri için ayrıca bir allowlist tutar (/auth/login, /auth/register, /forms/{id}/submit gibi) — listede olmayan bir POST 405 alır. Yani "public" demek "her şey serbest" demek değil.

JwtTenantFilter bu isteklere hiç karışmaz:

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
    return Boolean.TRUE.equals(request.getAttribute(PublicApiFilter.PUBLIC_API_ATTRIBUTE));
}

İki filtre, iki net sorumluluk: JWT'li istek → claim'den tenant. Anonim istek → URL'den tenant. Üçüncü bir yol yok.

Sessiz hatayı imkânsız hale getirmek

En kritik değişiklik burada. Eskiden context boş kalırsa AbstractRoutingDataSource varsayılan (basedb) bağlantıya düşüyordu — sessizce. Artık düşecek bir yer yok, ama daha önemlisi: düşmesini yasakladım.

@Override
protected Object determineCurrentLookupKey() {
    String tenantId = TenantContext.getTenantId();
    if (tenantId == null || tenantId.isBlank()) {
        if (strict) {
            throw new TenantRequiredException(
                "Tenant context bos — JWT tenantId claim veya /api/v1/public/{tid} URL gerekli");
        }
        return null;   // yalnız boot/seed aşamasında
    }
    return tenantId;
}

strict bayrağı uygulama açılışında false; ApplicationReadyEvent ile true'ya çevriliyor. Sebebi pratik: Hibernate açılışta dialect tespiti ve şema doğrulaması için bağlantı istiyor, o an henüz bir "istek" yok. Ama uygulama hazır olduktan sonra tenant'sız bir sorgu hatadır.

Bu tek satırlık karar, benim için bu projedeki en değerli değişikliklerden biri oldu: sessiz-yanlış-DB sınıfı bug'lar artık imkânsız. Kod yanlışsa hemen ve yüksek sesle patlıyor.

Bir yan etki: chat modeli de sadeleşti

basedb kalkınca "admin chat / tenant chat" ikiliği de anlamını kaybetti. Eskiden aynı chat tabloları iki yerde vardı ve hangi mesajın nereye yazılacağı tenant_id'nin null olup olmamasına bakıyordu. Şimdi tek model var: her chat grubu ait olduğu tenant'ın veritabanında yaşar. WebSocket topic'leri de bunu yansıtıyor:

/topic/tenant/{tenantId}/group/{groupId}

İki modeli tek modele indirmek, sonradan eklediğim WebRTC görüntülü görüşme modülünü de kolaylaştırdı — çünkü "bu çağrı hangi DB'ye loglanacak?" sorusunun tek bir cevabı vardı.


TenantContext — Mütevazı Ama Kritik

Tüm bu routing oyununun kalbinde 30 satırlık bir sınıf var:

public final class TenantContext {
 
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();
 
    private TenantContext() { }
 
    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }
 
    public static String getTenantId() {
        return CURRENT_TENANT.get();
    }
 
    public static void clear() {
        CURRENT_TENANT.remove();
    }
}

ThreadLocal doğru yapı, çünkü Spring her isteği bir thread'de işliyor ve aynı thread içindeki tüm JPA çağrıları aynı tenant'ı görmeli. Ama iki tuzağı var — ikisini de canlıda öğrendim:

1. Tomcat thread reuse. Tomcat thread'leri havuzdan yeniden kullanır. TenantContext.clear() çağrısını atlarsan bir isteğin tenant'ı sonraki isteğe sızar. Bu yüzden filtredeki finally bloğu pazarlık konusu değil:

try {
    String tenantId = resolveTenantId(request);
    if (tenantId != null) {
        TenantContext.setTenantId(tenantId);
    }
    filterChain.doFilter(request, response);
} finally {
    TenantContext.clear();   // asla atlanmaz
}

2. Context, transaction'dan ÖNCE kurulmalı. Bu, en pahalı derslerimden biriydi. @Transactional bir servis metodunu çağırdığında Spring, metodun ilk satırından önce bağlantıyı alır. Yani context'i servisin içinde kurarsan, transaction zaten yanlış (ya da strict modda hiçbir) DataSource ile açılmış olur.

Bu yüzden login/register gibi tenant'ın body'den veya URL'den geldiği uçlarda context'i controller'da kuruyorum:

@PostMapping("/login")
public RootEntityResponse<DtoAuthResponse> login(@Valid @RequestBody DtoLogin dtoLogin, ...) {
    // Context @Transactional servis çağrısından ÖNCE kurulmalı —
    // aksi halde transaction açılırken routing yanlış tenant'a düşer.
    if (dtoLogin.getTenantId() != null && !dtoLogin.getTenantId().isBlank()) {
        TenantContext.setTenantId(dtoLogin.getTenantId());
    }
    return ok(authService.login(dtoLogin));   // @Transactional burada başlıyor
}

Strict mod açıldıktan sonra bu hatanın bedeli de değişti: eskiden sessizce yanlış DB'ye gidiyordu, şimdi TenantRequiredException ile anında patlıyor. Yani kural artık kendini kendisi öğretiyor.


Genel Ders

Dört kararın damıttığı iki cümle:

"Kim?" sorusu ve "Nereye?" sorusu farklı yerlerde yaşamalı. Ve "bazen buraya, bazen şuraya" diyen her istisna, mimarideki bir çatlağın habercisidir.

İlk üç deneme birinci cümleyi öğretti. Dördüncüsü ikincisini: resolveTenantId içinde biriken istisna listesi aslında bir semptomdu; hastalık, sistemde "tenant'a ait olmayan veri" diye bir kategorinin var olmasıydı. O kategoriyi silince istisnalar da kendiliğinden gitti.

Yazılımda çözümü genelde bir şey ekleyerek ararız. Bazen doğru hamle, daha önce eklediğimiz şeyi silmektir.


Sırada Ne Var?

Bu yazıda tenant routing'i çözdük — gelen isteğin hangi tenant'a ait olduğunu nasıl bildiğimizi anlattım. Ama henüz veritabanı katmanına dokunmadık.

TenantContext.getTenantId() doğru tenant'ı veriyor; peki Spring bu bilgiyle hangi PostgreSQL'e nasıl bağlanıyor? Hibernate'in resmi multi-tenancy API'sini neden kullanmadım? Ve tenant sayısı × pool boyutu × pod sayısı hesabı neden geceleri uykumu kaçırdı?

Bunları sıradaki yazıda anlatacağım:

Bölüm 2 → Database-per-Tenant: AbstractRoutingDataSource ve Connection Pool Patlaması


Elly ekosistemi hakkında daha fazla bilgi için: huseyindol.com/projects/elly