Spring Boot & Postgres - Multi-Tenancy & Routing

Overview

Spring JPA implementation with multi-tenancy and routing.

Github: https://github.com/gitorko/project101

Postgres Multi-Tenancy

Multi-tenancy is an architectural pattern that allows you to isolate customers even if they are using the same hardware or software components.

  1. Catalog-based - Each region gets its own database
  2. Schema-based - Single database but different schema for each region
  3. Table-based - Single database, single table but a column identifies the region.

There are 2 approache to implement multi-tenancy

  1. AbstractMultiTenantConnectionProvider - Handles Hibernate session factory connections for different tenants.
  2. AbstractRoutingDataSource - Handles other aspects of data source routing in the application, such as switching data sources for non-Hibernate use cases.

Code

 1package com.demo.project101.config;  2  3import java.util.HashMap;  4import java.util.Map;  5import javax.sql.DataSource;  6  7import com.zaxxer.hikari.HikariConfig;  8import com.zaxxer.hikari.HikariDataSource;  9import org.springframework.beans.factory.annotation.Qualifier; 10import org.springframework.context.annotation.Bean; 11import org.springframework.context.annotation.Configuration; 12import org.springframework.core.env.Environment; 13import org.springframework.orm.jpa.JpaTransactionManager; 14import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; 15import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; 16import org.springframework.transaction.PlatformTransactionManager; 17 18@Configuration 19public class DataSourceConfig { 20 21 @Bean(name = "defaultDataSource") 22 public DataSource defaultDataSource(Environment env) { 23 HikariConfig config = new HikariConfig(); 24 config.setJdbcUrl(env.getProperty("spring.datasource.url")); 25 config.setUsername(env.getProperty("spring.datasource.username")); 26 config.setPassword(env.getProperty("spring.datasource.password")); 27 config.setDriverClassName(env.getProperty("spring.datasource.driver-class-name")); 28 config.setMaximumPoolSize(Integer.parseInt(env.getProperty("spring.datasource.hikari.maximum-pool-size"))); 29 config.setMinimumIdle(Integer.parseInt(env.getProperty("spring.datasource.hikari.minimum-idle"))); 30 config.setIdleTimeout(Long.parseLong(env.getProperty("spring.datasource.hikari.idle-timeout"))); 31 config.setMaxLifetime(Long.parseLong(env.getProperty("spring.datasource.hikari.max-lifetime"))); 32 config.setConnectionTimeout(Long.parseLong(env.getProperty("spring.datasource.hikari.connection-timeout"))); 33 return new HikariDataSource(config); 34 } 35 36 @Bean(name = "entityManagerFactory") 37 public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( 38 @Qualifier("routingDataSource") DataSource routingDataSource, Environment env) { 39 HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); 40 LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); 41 factory.setDataSource(routingDataSource); 42 factory.setPackagesToScan("com.demo.project101.domain"); 43 factory.setJpaVendorAdapter(vendorAdapter); 44 factory.setJpaPropertyMap(hibernateProperties(env)); 45 return factory; 46 } 47 48 @Bean(name = "transactionManager") 49 public PlatformTransactionManager primaryTransactionManager( 50 @Qualifier("entityManagerFactory") LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory) { 51 return new JpaTransactionManager(primaryEntityManagerFactory.getObject()); 52 } 53 54 private Map<String, Object> hibernateProperties(Environment env) { 55 Map<String, Object> properties = new HashMap<>(); 56 properties.put("hibernate.hbm2ddl.auto", env.getProperty("spring.jpa.properties.hibernate.hbm2ddl.auto")); 57 properties.put("hibernate.dialect", env.getProperty("spring.jpa.properties.hibernate.dialect")); 58 properties.put("hibernate.show_sql", env.getProperty("spring.jpa.properties.hibernate.show_sql")); 59 return properties; 60 } 61} 62 63 
 1package com.demo.project101.config;  2  3import java.util.List;  4import javax.sql.DataSource;  5  6import jakarta.annotation.PostConstruct;  7import liquibase.exception.LiquibaseException;  8import liquibase.integration.spring.SpringLiquibase;  9import org.springframework.beans.factory.annotation.Autowired; 10import org.springframework.beans.factory.annotation.Qualifier; 11import org.springframework.beans.factory.annotation.Value; 12import org.springframework.context.annotation.Configuration; 13 14@Configuration 15public class LiquibaseConfig { 16 17 @Autowired 18 @Qualifier("defaultDataSource") 19 private DataSource dataSource; 20 21 @Value("${app.tenants}") 22 private List<String> tenants; 23 24 @PostConstruct 25 public void applyLiquibase() throws LiquibaseException { 26 for (String tenant : tenants) { 27 SpringLiquibase liquibase = new SpringLiquibase(); 28 liquibase.setDataSource(dataSource); 29 liquibase.setDefaultSchema(tenant); 30 liquibase.setChangeLog("classpath:db/changelog/db.changelog.yaml"); 31 liquibase.afterPropertiesSet(); 32 } 33 } 34} 35 
 1package com.demo.project101.config;  2  3import java.util.HashMap;  4import java.util.List;  5import java.util.Map;  6import javax.sql.DataSource;  7  8import com.zaxxer.hikari.HikariConfig;  9import com.zaxxer.hikari.HikariDataSource; 10import lombok.extern.slf4j.Slf4j; 11import org.springframework.beans.factory.annotation.Qualifier; 12import org.springframework.beans.factory.annotation.Value; 13import org.springframework.context.annotation.Bean; 14import org.springframework.context.annotation.Configuration; 15import org.springframework.core.env.Environment; 16import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; 17 18@Configuration 19@Slf4j 20public class RoutingConfig { 21 22 @Value("${app.tenants}") 23 private List<String> tenants; 24 25 @Bean(name = "routingDataSource") 26 public DataSource routingDataSource(@Qualifier("defaultDataSource") DataSource defaultDataSource, Environment env) { 27 Map<Object, Object> targetDataSources = new HashMap<>(); 28 for (String tenant : tenants) { 29 targetDataSources.put(tenant, createTenantDataSource(tenant, env)); 30 } 31 AbstractRoutingDataSource routingDataSource = new AbstractRoutingDataSource() { 32 @Override 33 protected Object determineCurrentLookupKey() { 34 return TenantContext.getCurrentTenant(); 35 } 36 }; 37 routingDataSource.setDefaultTargetDataSource(defaultDataSource); 38 routingDataSource.setTargetDataSources(targetDataSources); 39 return routingDataSource; 40 } 41 42 public DataSource createTenantDataSource(String tenant, Environment env) { 43 log.info("New Connection: {}", tenant); 44 HikariConfig config = new HikariConfig(); 45 config.setJdbcUrl(env.getProperty("spring.datasource.url") + "?currentSchema=" + tenant); 46 config.setUsername(env.getProperty("spring.datasource.username")); 47 config.setPassword(env.getProperty("spring.datasource.password")); 48 config.setDriverClassName(env.getProperty("spring.datasource.driver-class-name")); 49 config.setMaximumPoolSize(Integer.parseInt(env.getProperty("spring.datasource.hikari.maximum-pool-size"))); 50 config.setMinimumIdle(Integer.parseInt(env.getProperty("spring.datasource.hikari.minimum-idle"))); 51 config.setIdleTimeout(Long.parseLong(env.getProperty("spring.datasource.hikari.idle-timeout"))); 52 config.setMaxLifetime(Long.parseLong(env.getProperty("spring.datasource.hikari.max-lifetime"))); 53 config.setConnectionTimeout(Long.parseLong(env.getProperty("spring.datasource.hikari.connection-timeout"))); 54 return new HikariDataSource(config); 55 } 56} 
 1package com.demo.project101.config;  2  3public class TenantContext {  4  5 private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();  6  7 public static String getCurrentTenant() {  8 return CURRENT_TENANT.get();  9 } 10 11 public static void setCurrentTenant(String tenant) { 12 CURRENT_TENANT.set(tenant); 13 } 14 15 public static void clear() { 16 CURRENT_TENANT.remove(); 17 } 18} 
 1package com.demo.project101.controller;  2  3import java.util.List;  4  5import com.demo.project101.config.TenantContext;  6import com.demo.project101.domain.Customer;  7import com.demo.project101.service.CustomerService;  8import lombok.RequiredArgsConstructor;  9import org.springframework.web.bind.annotation.GetMapping; 10import org.springframework.web.bind.annotation.PostMapping; 11import org.springframework.web.bind.annotation.RequestBody; 12import org.springframework.web.bind.annotation.RequestHeader; 13import org.springframework.web.bind.annotation.RequestMapping; 14import org.springframework.web.bind.annotation.RestController; 15 16@RestController 17@RequestMapping("/customer") 18@RequiredArgsConstructor 19public class CustomerController { 20 final CustomerService customerService; 21 22 @GetMapping 23 public List<Customer> getAll(@RequestHeader("X-TenantID") String tenantId) { 24 try { 25 TenantContext.setCurrentTenant(tenantId); 26 return customerService.findAll(); 27 } finally { 28 TenantContext.clear(); 29 } 30 } 31 32 @PostMapping 33 public Customer saveCustomer(@RequestHeader("X-TenantID") String tenantId, @RequestBody Customer customer) { 34 try { 35 TenantContext.setCurrentTenant(tenantId); 36 return customerService.save(customer); 37 } finally { 38 TenantContext.clear(); 39 } 40 } 41} 

Postman

Import the postman collection to postman

Postman Collection

Setup

 1# Project101  2  3Spring Boot & Postgres - Multi-tenancy & Routing  4  5### Version  6  7Check version  8  9```bash 10$java --version 11openjdk version "21.0.3" 2024-04-16 LTS 12``` 13 14### Postgres DB 15 16``` 17docker run -p 5432:5432 --name pg-container -e POSTGRES_PASSWORD=password -d postgres:14 18docker ps 19docker exec -it pg-container psql -U postgres -W postgres 20CREATE USER test WITH PASSWORD 'test@123'; 21CREATE DATABASE "test-db" WITH OWNER "test" ENCODING UTF8 TEMPLATE template0; 22grant all PRIVILEGES ON DATABASE "test-db" to test; 23``` 24 25Schema 26 27```bash 28 29CREATE SCHEMA america; 30CREATE SCHEMA asia; 31 32select nspname as "Schema" 33from pg_catalog.pg_namespace 34where nspname not like 'pg_%'; 35``` 36 37```bash 38docker stop pg-container 39docker start pg-container 40``` 41 42### Dev 43 44To run the backend in dev mode. 45 46```bash 47./gradlew clean build 48./gradlew bootRun 49```

References

https://spring.io

https://vladmihalcea.com/database-multitenancy/

https://vladmihalcea.com/hibernate-database-schema-multitenancy/

https://vladmihalcea.com/read-write-read-only-transaction-routing-spring/

comments powered by Disqus