← Kursa Dön
📄 Text · 18 min

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

ÖzellikDatabase Per TenantSchema Per TenantShared 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öner

RLS, 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 AbstractRoutingDataSource kullanılır — her tenant'a ayrı DataSource yönlendirilir

  • Güvenlik katmanlı olmalı: ThreadLocal temizleme, cross-tenant erişim kontrolü, DB seviyesinde RLS — asenkron işlerde tenant propagasyonu ihmal edilmemeli