Multi-Tenancy Mimarisi
Giriş — Apartman Binası
Bir apartman binası düşün. Tek bir bina, ama içinde birden fazla daire var. Her dairenin sakinleri farklı, mobilyaları farklı. Ama hepsi aynı çatıyı, aynı asansörü, aynı su tesisatını paylaşıyor. Bir dairenin sakini diğer dairenin kapısını açamaz — birbirlerinden izole ama altyapıyı paylaşıyorlar.
İşte multi-tenancy tam olarak bu. Tek bir uygulama, birden fazla müşteriye (tenant) hizmet verir. Her tenant kendi verisini görür, ama hepsi aynı uygulama instance'ını paylaşır.
Neden her müşteri için ayrı uygulama deploy etmiyoruz?
Maliyet: 100 müşteri = 100 server. Multi-tenancy ile 1 server yeter.
Bakım: Bug fix'i 1 yere deploy edersin, 100 yere değil.
Onboarding: Yeni müşteri? Tenant oluştur, bitsin. Deployment gerekmez.
SaaS uygulamaları (Slack, Shopify, Jira) multi-tenant mimaridedir. Slack'te her workspace bir tenant'tır — hepsi aynı Slack uygulamasını kullanır, ama birbirlerinin mesajlarını göremezler.
Proje Kurulumu
Bu ders local Spring Boot projesi gerektirir. Spring Initializr'dan Spring Web, Spring Data JPA, H2 Database dependency'leriyle proje oluştur.
application.yml:
spring:
datasource:
url: jdbc:h2:mem:tenantdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
h2:
console:
enabled: trueÜç Multi-Tenancy Stratejisi
1. Database Per Tenant — Her Tenant'a Ayrı Bina
Her tenant'ın tamamen ayrı veritabanı var.
Tenant A → database_tenant_a (products, orders, users...)
Tenant B → database_tenant_b (products, orders, users...)2. Schema Per Tenant — Aynı Bina, Farklı Katlar
Tek veritabanı, ama her tenant'ın ayrı schema'sı var.
PostgreSQL Server
├── schema: tenant_a (products, orders, users...)
├── schema: tenant_b (products, orders, users...)3. Shared Table (Discriminator Column) — Aynı Oda, Ayrı Çekmeceler
Tek veritabanı, tek tablo, ama her satırda tenant_id kolonu.
products tablosu:
| id | tenant_id | name | price |
|----|-----------|------------|-------|
| 1 | acme | Widget | 10.0 |
| 2 | globex | Doohickey | 15.0 |Karşılaştırma Tablosu
| Özellik | Database Per Tenant | Schema Per Tenant | Shared Table |
|---|---|---|---|
| İzolasyon | ✅ Tam | ✅ İyi | ⚠️ Uygulama seviyesinde |
| Güvenlik | ✅ En güvenli | ✅ Güvenli | ⚠️ Dikkat gerekir |
| Maliyet | ❌ En pahalı | ⚠️ Orta | ✅ En ucuz |
| Ölçeklenme | ❌ Zor (çok DB) | ⚠️ Orta | ✅ Kolay |
| Yeni tenant | ❌ Yavaş (DB oluştur) | ⚠️ Orta | ✅ Hızlı |
| Bakım | ❌ Her DB'ye migration | ⚠️ Her schema'ya | ✅ Tek migration |
| Performans | ✅ Tenant başına optimize | ⚠️ Paylaşımlı | ⚠️ Index gerekir |
| Tenant-spesifik yedek | ✅ Kolay | ✅ Kolay | ❌ Zor |
Strateji 1: Shared Table (Discriminator Column)
En yaygın ve en kolay yaklaşım.
TenantContext — ThreadLocal ile Tenant Belirleme
Her HTTP isteği bir thread tarafından işlenir. O thread'in hangi tenant'a ait olduğunu ThreadLocal ile saklarız:
public class TenantContext {
private static final ThreadLocal<String> CURRENT_TENANT =
new ThreadLocal<>();
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 nedir? Her thread'e özel bir "cep" gibi düşün. Thread A "acme" değerini koyar, Thread B "globex" koyar — birbirlerini görmezler.
TenantFilter — HTTP Header'dan Tenant Çekme
@Component
@Order(1)
public class TenantFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String tenantId = request.getHeader("X-Tenant-Id");
if (tenantId == null || tenantId.isBlank()) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write(
"{\"error\": \"X-Tenant-Id header is required\"}");
return;
}
try {
TenantContext.setTenantId(tenantId.trim().toLowerCase());
filterChain.doFilter(request, response);
} finally {
TenantContext.clear(); // Memory leak önleme
}
}
}⚠️ `finally` bloğunda `TenantContext.clear()` kritik. Thread pool kullanan sunucularda (Tomcat) thread'ler yeniden kullanılır. Temizlemezsen, bir sonraki istek yanlış tenant context'inde çalışabilir — ciddi güvenlik açığı.
Entity, Repository, Service, Controller
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
private String name;
private Double price;
@PrePersist
public void prePersist() {
if (this.tenantId == null) {
this.tenantId = TenantContext.getTenantId();
}
}
// Constructors, getters, setters
}@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByTenantId(String tenantId);
Optional<Product> findByIdAndTenantId(Long id, String tenantId);
void deleteByIdAndTenantId(Long id, String tenantId);
}@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public List<Product> getAllProducts() {
return productRepository.findByTenantId(
TenantContext.getTenantId());
}
public Product getProduct(Long id) {
return productRepository.findByIdAndTenantId(
id, TenantContext.getTenantId())
.orElseThrow(() -> new RuntimeException("Not found: " + id));
}
public Product createProduct(Product product) {
product.setTenantId(TenantContext.getTenantId());
return productRepository.save(product);
}
}@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public List<Product> list() {
return productService.getAllProducts();
}
@PostMapping
public Product create(@RequestBody Product product) {
return productService.createProduct(product);
}
}Test:
# Acme için ürün oluştur
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: acme" \
-d '{"name": "Widget", "price": 10.0}'
# Globex için ürün oluştur
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-H "X-Tenant-Id: globex" \
-d '{"name": "Doohickey", "price": 15.0}'
# Acme sadece kendi ürünlerini görür
curl -H "X-Tenant-Id: acme" http://localhost:8080/api/products
# → [{"id":1, "tenantId":"acme", "name":"Widget", "price":10.0}]Hibernate Filter ile Otomatik Filtreleme
Yukarıdaki yaklaşımda her sorguda tenantId geçmemiz gerekiyor. Bir yerde unutursan tenant izolasyonu kırılır. Hibernate Filter ile bunu otomatik hale getirebilirsin:
@Entity
@Table(name = "products")
@FilterDef(
name = "tenantFilter",
parameters = @ParamDef(name = "tenantId", type = String.class)
)
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "tenant_id", nullable = false)
private String tenantId;
private String name;
private Double price;
@PrePersist
public void prePersist() {
if (this.tenantId == null) {
this.tenantId = TenantContext.getTenantId();
}
}
}Hibernate Filter'ı her service çağrısında aktif eden aspect:
@Aspect
@Component
public class TenantHibernateFilter {
private final EntityManager entityManager;
public TenantHibernateFilter(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Around("execution(* com.example..service.*.*(..))")
public Object enableFilter(ProceedingJoinPoint joinPoint)
throws Throwable {
Session session = entityManager.unwrap(Session.class);
String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
session.enableFilter("tenantFilter")
.setParameter("tenantId", tenantId);
}
try {
return joinPoint.proceed();
} finally {
session.disableFilter("tenantFilter");
}
}
}spring-boot-starter-aop dependency'si gerekir. Artık standart JPA metotları otomatik filtrelenir:
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// findAll() otomatik olarak WHERE tenant_id = :tenantId ekler
List<Product> findByPriceLessThan(Double price);
// Bu da: WHERE price < ? AND tenant_id = ?
}💡 Hibernate Filter sadece SELECT sorgularını filtreler. save(), delete() gibi işlemlerde tenant_id'yi yine @PrePersist veya service katmanında set etmen gerekir.
Strateji 2: Database Per Tenant — AbstractRoutingDataSource
Yüksek izolasyon gerektiren senaryolarda (finans, sağlık) her tenant'a ayrı veritabanı verilir.
Routing DataSource
public class TenantRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TenantContext.getTenantId();
}
}Konfigürasyon
@Configuration
public class DataSourceConfig {
@Bean
public DataSource dataSource() {
TenantRoutingDataSource routing = new TenantRoutingDataSource();
Map<Object, Object> dataSources = new HashMap<>();
dataSources.put("acme", createDataSource(
"jdbc:h2:mem:acme_db"));
dataSources.put("globex", createDataSource(
"jdbc:h2:mem:globex_db"));
routing.setTargetDataSources(dataSources);
routing.setDefaultTargetDataSource(dataSources.get("acme"));
return routing;
}
private DataSource createDataSource(String url) {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(url);
ds.setUsername("sa");
ds.setPassword("");
ds.setMaximumPoolSize(10);
return ds;
}
}TenantFilter aynı kalır. Fark: sorgu çalıştığında AbstractRoutingDataSource otomatik olarak doğru DataSource'u seçer.
Güvenlik: Tenant İzolasyonu
Multi-tenancy'de en kritik konu güvenlik. Bir tenant'ın başka tenant'ın verisine erişmesi kabul edilemez. Birden fazla savunma katmanı oluşturmalısın.
Cross-Tenant Erişim Kontrolü
@Service
public class SecureProductService {
private final ProductRepository productRepository;
public SecureProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product getProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException(
"Product not found"));
if (!product.getTenantId().equals(TenantContext.getTenantId())) {
throw new AccessDeniedException(
"Cross-tenant access denied for product: " + id);
}
return product;
}
}Veritabanı Seviyesinde İzolasyon
Uygulama katmanındaki kontrollere ek olarak, PostgreSQL'in Row Level Security (RLS) özelliğini kullanabilirsin:
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON products
USING (tenant_id = current_setting('app.current_tenant'));
-- Her bağlantıda tenant ayarla
SET app.current_tenant = 'acme';
SELECT * FROM products; -- Sadece acme'nin ürünleri dönerRLS, veritabanı seviyesinde izolasyon sağlar. Uygulama katmanında bir bug olsa bile, veritabanı yanlış veriyi döndürmez.
Test Yazma
@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ProductRepository productRepository;
@BeforeEach
void setUp() {
productRepository.deleteAll();
}
@Test
void tenants_see_only_their_own_products() throws Exception {
// Acme için ürün oluştur
mockMvc.perform(post("/api/products")
.header("X-Tenant-Id", "acme")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Widget\",\"price\":10.0}"))
.andExpect(status().isOk());
// Globex için ürün oluştur
mockMvc.perform(post("/api/products")
.header("X-Tenant-Id", "globex")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Doohickey\",\"price\":15.0}"))
.andExpect(status().isOk());
// Acme sadece kendi ürünlerini görmeli
mockMvc.perform(get("/api/products")
.header("X-Tenant-Id", "acme"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].name").value("Widget"));
// Globex sadece kendi ürünlerini görmeli
mockMvc.perform(get("/api/products")
.header("X-Tenant-Id", "globex"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].name").value("Doohickey"));
}
@Test
void missing_tenant_header_returns_400() throws Exception {
mockMvc.perform(get("/api/products"))
.andExpect(status().isBadRequest());
}
}Unit Test ile TenantContext
@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
@Mock
private ProductRepository productRepository;
@InjectMocks
private ProductService productService;
@AfterEach
void tearDown() {
TenantContext.clear();
}
@Test
void getAllProducts_filters_by_tenant() {
TenantContext.setTenantId("acme");
Product p = new Product();
p.setTenantId("acme");
p.setName("Widget");
when(productRepository.findByTenantId("acme"))
.thenReturn(List.of(p));
List<Product> products = productService.getAllProducts();
assertEquals(1, products.size());
assertEquals("acme", products.get(0).getTenantId());
}
}Ne Zaman Hangi Strateji? — Karar Ağacı
Tenant sayısı az (< 20) ve yüksek izolasyon mu?
├── Evet → Database Per Tenant
│ (Finans, sağlık, büyük enterprise)
└── Hayır
├── Orta sayı (20-200), orta izolasyon?
│ └── Schema Per Tenant
│ (Orta ölçekli SaaS, compliance)
└── Çok tenant (200+)?
└── Shared Table
(Kesinlikle — başka yolu yok)Shared Table: Startup, çok müşteri, hızlı onboarding, düşük maliyet. Schema Per Tenant: Orta ölçek, PostgreSQL, orta izolasyon yeterli. Database Per Tenant: Düzenleyici zorunluluk, fiziksel izolasyon talebi, az ama değerli müşteri.
⚠️ Hybrid yaklaşım da mümkün. Büyük enterprise müşterilere Database Per Tenant, küçük müşterilere Shared Table sunabilirsin.
Yaygın Hatalar
1. TenantContext Temizlemeyi Unutma
// ❌ finally yok → thread havuzda sızıntı
TenantContext.setTenantId(req.getHeader("X-Tenant-Id"));
chain.doFilter(req, res);
// ✅ Her zaman finally ile temizle
try {
TenantContext.setTenantId(req.getHeader("X-Tenant-Id"));
chain.doFilter(req, res);
} finally {
TenantContext.clear();
}2. Asenkron İşlerde Tenant Kaybı
// ❌ @Async metoda geçince ThreadLocal kaybolur
@Async
public void processOrder(Order order) {
TenantContext.getTenantId(); // null!
}
// ✅ Tenant'ı parametre olarak geç
@Async
public void processOrder(Order order, String tenantId) {
TenantContext.setTenantId(tenantId);
try { /* işlem */ }
finally { TenantContext.clear(); }
}3. Index Unutma (Shared Table)
-- tenant_id'ye index olmadan sorgular yavaşlar
CREATE INDEX idx_products_tenant ON products(tenant_id);
CREATE INDEX idx_products_tenant_name ON products(tenant_id, name);4. Toplu İşlemlerde Tenant Kontrolü
// ❌ TÜM tenant'ların ürünlerini etkiler
@Query("UPDATE Product p SET p.price = p.price * 1.1")
void applyPriceIncrease();
// ✅ Tenant filtresini unutma
@Query("UPDATE Product p SET p.price = p.price * 1.1 " +
"WHERE p.tenantId = :tenantId")
void applyPriceIncrease(@Param("tenantId") String tenantId);Özet
Multi-tenancy, tek uygulamanın birden fazla müşteriye hizmet vermesidir — SaaS uygulamalarının temel mimarisi
Üç strateji var: Database Per Tenant (en izole, en pahalı), Schema Per Tenant (orta), Shared Table (en ucuz, en kolay)
TenantContext + ThreadLocal ile mevcut tenant bilgisi thread boyunca taşınır, TenantFilter ile HTTP header'dan çekilir
Hibernate Filter ile tenant izolasyonu otomatikleştirilir — manuel filtrelemeye göre hata riski çok daha düşük
Database Per Tenant için
AbstractRoutingDataSourcekullanılır — her tenant'a ayrı DataSource yönlendirilirGüvenlik katmanlı olmalı: ThreadLocal temizleme, cross-tenant erişim kontrolü, DB seviyesinde RLS — asenkron işlerde tenant propagasyonu ihmal edilmemeli
AI Asistan
Sorularını yanıtlamaya hazır