├── mt.png ├── tenants ├── atRuntime │ └── tenant3.properties └── onStartUp │ ├── tenant1.properties │ └── tenant2.properties ├── service ├── src │ └── main │ │ ├── java │ │ └── io │ │ │ └── github │ │ │ └── cepr0 │ │ │ └── demo │ │ │ ├── service │ │ │ ├── ModelService.java │ │ │ └── ModelServiceImpl.java │ │ │ ├── repo │ │ │ └── ModelRepo.java │ │ │ ├── exception │ │ │ ├── InvalidTenantIdExeption.java │ │ │ ├── InvalidDbPropertiesException.java │ │ │ └── LoadDataSourceException.java │ │ │ ├── controller │ │ │ ├── TenantController.java │ │ │ └── ModelController.java │ │ │ └── Application.java │ │ └── resources │ │ └── application.yml └── pom.xml ├── multitenant ├── src │ └── main │ │ └── java │ │ └── io │ │ └── github │ │ └── cepr0 │ │ └── demo │ │ └── multitenant │ │ ├── TenantNotFoundException.java │ │ ├── TenantResolvingException.java │ │ └── MultiTenantManager.java └── pom.xml ├── model ├── pom.xml └── src │ └── main │ └── java │ └── io │ └── github │ └── cepr0 │ └── demo │ ├── model │ └── Model.java │ └── support │ └── Cuid.java ├── .gitignore ├── pom.xml └── readme.md /mt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cepr0/sb-multitenant-db-demo/HEAD/mt.png -------------------------------------------------------------------------------- /tenants/atRuntime/tenant3.properties: -------------------------------------------------------------------------------- 1 | id=tenant3 2 | url=jdbc:postgresql://localhost:5432/tenant3 3 | username=postgres 4 | password=postgres -------------------------------------------------------------------------------- /tenants/onStartUp/tenant1.properties: -------------------------------------------------------------------------------- 1 | id=tenant1 2 | url=jdbc:postgresql://localhost:5432/tenant1 3 | username=postgres 4 | password=postgres -------------------------------------------------------------------------------- /tenants/onStartUp/tenant2.properties: -------------------------------------------------------------------------------- 1 | id=tenant2 2 | url=jdbc:postgresql://localhost:5432/tenant2 3 | username=postgres 4 | password=postgres -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/service/ModelService.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.service; 2 | 3 | import io.github.cepr0.demo.model.Model; 4 | 5 | public interface ModelService { 6 | Iterable findAll(); 7 | Model save(Model model); 8 | } 9 | -------------------------------------------------------------------------------- /multitenant/src/main/java/io/github/cepr0/demo/multitenant/TenantNotFoundException.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.multitenant; 2 | 3 | public class TenantNotFoundException extends Exception { 4 | public TenantNotFoundException(String message) { 5 | super(message); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/repo/ModelRepo.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.repo; 2 | 3 | import io.github.cepr0.demo.model.Model; 4 | import org.springframework.data.repository.CrudRepository; 5 | 6 | public interface ModelRepo extends CrudRepository { 7 | } 8 | -------------------------------------------------------------------------------- /multitenant/src/main/java/io/github/cepr0/demo/multitenant/TenantResolvingException.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.multitenant; 2 | 3 | public class TenantResolvingException extends Exception { 4 | public TenantResolvingException(Throwable throwable, String message) { 5 | super(message, throwable); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/exception/InvalidTenantIdExeption.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "DataSource not found for given tenant Id!") 7 | public class InvalidTenantIdExeption extends RuntimeException { 8 | } 9 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/exception/InvalidDbPropertiesException.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "The given Database parameters are incorrect!") 7 | public class InvalidDbPropertiesException extends RuntimeException { 8 | } 9 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/exception/LoadDataSourceException.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(value = HttpStatus.BAD_REQUEST) 7 | public class LoadDataSourceException extends RuntimeException { 8 | public LoadDataSourceException(Throwable cause) { 9 | super("Could not load DataSource! - " + cause.getMessage()); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /model/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | model 8 | jar 9 | 10 | model 11 | Model module 12 | 13 | 14 | io.github.cepr0 15 | sb-multitenant-db-demo 16 | 0.0.1-SNAPSHOT 17 | 18 | 19 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/service/ModelServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.service; 2 | 3 | import io.github.cepr0.demo.model.Model; 4 | import io.github.cepr0.demo.repo.ModelRepo; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.transaction.annotation.Transactional; 7 | 8 | @Service 9 | @Transactional(readOnly = true) 10 | public class ModelServiceImpl implements ModelService { 11 | 12 | private final ModelRepo modelRepo; 13 | 14 | public ModelServiceImpl(ModelRepo modelRepo) { 15 | this.modelRepo = modelRepo; 16 | } 17 | 18 | @Override 19 | public Iterable findAll() { 20 | return modelRepo.findAll(); 21 | } 22 | 23 | @Transactional 24 | @Override 25 | public Model save(Model model) { 26 | return modelRepo.save(model); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /model/src/main/java/io/github/cepr0/demo/model/Model.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.model; 2 | 3 | import io.github.cepr0.demo.support.Cuid; 4 | import com.fasterxml.jackson.annotation.JsonFormat; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import javax.persistence.*; 9 | import java.time.Instant; 10 | 11 | import static com.fasterxml.jackson.annotation.JsonFormat.Shape.STRING; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @Entity 16 | @Table(name = "models") 17 | public class Model { 18 | 19 | @Id 20 | @Column(columnDefinition = "text") 21 | private String id; 22 | 23 | @Column(nullable = false) 24 | @JsonFormat(shape = STRING) 25 | private Instant createdAt; 26 | 27 | @Column(nullable = false, columnDefinition = "text") 28 | private String tenant; 29 | 30 | public Model(String tenant) { 31 | this.tenant = tenant; 32 | } 33 | 34 | @PrePersist 35 | protected void prePersist() { 36 | id = Cuid.createCuid(); 37 | createdAt = Instant.now(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driverClassName: org.postgresql.Driver 4 | jpa: 5 | open-in-view: false 6 | hibernate: 7 | ddl-auto: none 8 | naming: 9 | physical-strategy: org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy 10 | show-sql: false 11 | properties: 12 | hibernate: 13 | format_sql: true 14 | dialect: org.hibernate.dialect.PostgreSQL95Dialect 15 | temp.use_jdbc_metadata_defaults: false 16 | order_inserts: true 17 | order_updates: true 18 | jdbc: 19 | lob.non_contextual_creation: true 20 | batch_size: 20 21 | fetch_size: 20 22 | batch_versioned_data: true 23 | 24 | logging: 25 | level: 26 | jdbc: 27 | sqlonly: info 28 | resultsettable: info 29 | sqltiming: fatal 30 | audit: fatal 31 | resultset: fatal 32 | connection: info 33 | 34 | org: 35 | springframework.orm.jpa: debug 36 | hibernate.jdbc: debug 37 | io.github.cepr0.demo: debug -------------------------------------------------------------------------------- /multitenant/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | multitenant 8 | jar 9 | 10 | multitenant 11 | Multi-tenant datasource config module 12 | 13 | 14 | io.github.cepr0 15 | sb-multitenant-db-demo 16 | 0.0.1-SNAPSHOT 17 | 18 | 19 | 20 | 21 | io.github.cepr0 22 | model 23 | 0.0.1-SNAPSHOT 24 | 25 | 26 | 27 | com.h2database 28 | h2 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | service 8 | jar 9 | 10 | service 11 | Multi-tenant service module 12 | 13 | 14 | io.github.cepr0 15 | sb-multitenant-db-demo 16 | 0.0.1-SNAPSHOT 17 | 18 | 19 | 20 | 21 | io.github.cepr0 22 | model 23 | 0.0.1-SNAPSHOT 24 | 25 | 26 | 27 | io.github.cepr0 28 | multitenant 29 | 0.0.1-SNAPSHOT 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/java,intellij,maven 3 | 4 | ### Java ### 5 | *.class 6 | 7 | # Mobile Tools for Java (J2ME) 8 | .mtj.tmp/ 9 | 10 | # Package Files # 11 | *.jar 12 | *.war 13 | *.ear 14 | 15 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 16 | hs_err_pid* 17 | 18 | 19 | ### Intellij ### 20 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 21 | 22 | *.iml 23 | 24 | ## Directory-based project format: 25 | .idea/ 26 | # if you remove the above rule, at least ignore the following: 27 | 28 | # User-specific stuff: 29 | # .idea/workspace.xml 30 | # .idea/tasks.xml 31 | # .idea/dictionaries 32 | # .idea/shelf 33 | 34 | # Sensitive or high-churn files: 35 | # .idea/dataSources.ids 36 | # .idea/dataSources.xml 37 | # .idea/sqlDataSources.xml 38 | # .idea/dynamic.xml 39 | # .idea/uiDesigner.xml 40 | 41 | # Gradle: 42 | # .idea/gradle.xml 43 | # .idea/libraries 44 | 45 | # Mongo Explorer plugin: 46 | # .idea/mongoSettings.xml 47 | 48 | ## File-based project format: 49 | *.ipr 50 | *.iws 51 | 52 | ## Plugin-specific files: 53 | 54 | # IntelliJ 55 | /out/ 56 | 57 | # mpeltonen/sbt-idea plugin 58 | .idea_modules/ 59 | 60 | # JIRA plugin 61 | atlassian-ide-plugin.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | 70 | ### Maven ### 71 | target/ 72 | pom.xml.tag 73 | pom.xml.releaseBackup 74 | pom.xml.versionsBackup 75 | pom.xml.next 76 | release.properties 77 | dependency-reduced-pom.xml 78 | buildNumber.properties 79 | .mvn/timing.properties 80 | .mvn/ 81 | 82 | ### Application specific ### 83 | service/tenants/ 84 | !/tenants/onStartUp/tenant1.properties 85 | !/tenants/onStartUp/tenant2.properties 86 | !/tenants/ 87 | !/multitenant/tenants/ 88 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/controller/TenantController.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.controller; 2 | 3 | import io.github.cepr0.demo.exception.InvalidDbPropertiesException; 4 | import io.github.cepr0.demo.exception.LoadDataSourceException; 5 | import io.github.cepr0.demo.multitenant.MultiTenantManager; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.http.ResponseEntity; 8 | import org.springframework.web.bind.annotation.*; 9 | 10 | import java.sql.SQLException; 11 | import java.util.Map; 12 | 13 | @Slf4j 14 | @RestController 15 | @RequestMapping("/tenants") 16 | public class TenantController { 17 | 18 | private final MultiTenantManager tenantManager; 19 | 20 | public TenantController(MultiTenantManager tenantManager) { 21 | this.tenantManager = tenantManager; 22 | } 23 | 24 | /** 25 | * Get list of all tenants in the local storage 26 | */ 27 | @GetMapping 28 | public ResponseEntity getAll() { 29 | return ResponseEntity.ok(tenantManager.getTenantList()); 30 | } 31 | 32 | /** 33 | * Add the new tenant on the fly 34 | * 35 | * @param dbProperty Map with tenantId and related datasource properties 36 | */ 37 | @PostMapping 38 | public ResponseEntity add(@RequestBody Map dbProperty) { 39 | 40 | log.info("[i] Received add new tenant params request {}", dbProperty); 41 | 42 | String tenantId = dbProperty.get("tenantId"); 43 | String url = dbProperty.get("url"); 44 | String username = dbProperty.get("username"); 45 | String password = dbProperty.get("password"); 46 | 47 | if (tenantId == null || url == null || username == null || password == null) { 48 | log.error("[!] Received database params are incorrect or not full!"); 49 | throw new InvalidDbPropertiesException(); 50 | } 51 | 52 | try { 53 | tenantManager.addTenant(tenantId, url, username, password); 54 | log.info("[i] Loaded DataSource for tenant '{}'.", tenantId); 55 | return ResponseEntity.ok(dbProperty); 56 | } catch (SQLException e) { 57 | throw new LoadDataSourceException(e); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/controller/ModelController.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.controller; 2 | 3 | import io.github.cepr0.demo.exception.InvalidDbPropertiesException; 4 | import io.github.cepr0.demo.exception.InvalidTenantIdExeption; 5 | import io.github.cepr0.demo.model.Model; 6 | import io.github.cepr0.demo.multitenant.MultiTenantManager; 7 | import io.github.cepr0.demo.multitenant.TenantNotFoundException; 8 | import io.github.cepr0.demo.multitenant.TenantResolvingException; 9 | import io.github.cepr0.demo.service.ModelService; 10 | import lombok.extern.slf4j.Slf4j; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.web.bind.annotation.*; 13 | 14 | import java.sql.SQLException; 15 | 16 | @Slf4j 17 | @RestController 18 | @RequestMapping("/models") 19 | public class ModelController { 20 | 21 | private static final String MSG_INVALID_TENANT_ID = "[!] DataSource not found for given tenant Id '{}'!"; 22 | private static final String MSG_INVALID_DB_PROPERTIES_ID = "[!] DataSource properties related to the given tenant ('{}') is invalid!"; 23 | private static final String MSG_RESOLVING_TENANT_ID = "[!] Could not resolve tenant ID '{}'!"; 24 | 25 | private final ModelService modelService; 26 | private final MultiTenantManager tenantManager; 27 | 28 | public ModelController(ModelService modelService, MultiTenantManager tenantManager) { 29 | this.modelService = modelService; 30 | this.tenantManager = tenantManager; 31 | } 32 | 33 | @GetMapping 34 | public ResponseEntity getAll(@RequestHeader(value = "X-TenantID") String tenantId) { 35 | setTenant(tenantId); 36 | return ResponseEntity.ok(modelService.findAll()); 37 | } 38 | 39 | @PostMapping 40 | public ResponseEntity create(@RequestHeader("X-TenantId") String tenantId) { 41 | log.info("[i] Received POST request for '{}'", tenantId); 42 | setTenant(tenantId); 43 | return ResponseEntity.ok(modelService.save(new Model(tenantId))); 44 | } 45 | 46 | private void setTenant(String tenantId) { 47 | try { 48 | tenantManager.setCurrentTenant(tenantId); 49 | } catch (SQLException e) { 50 | log.error(MSG_INVALID_DB_PROPERTIES_ID, tenantId); 51 | throw new InvalidDbPropertiesException(); 52 | } catch (TenantNotFoundException e) { 53 | log.error(MSG_INVALID_TENANT_ID, tenantId); 54 | throw new InvalidTenantIdExeption(); 55 | } catch (TenantResolvingException e) { 56 | log.error(MSG_RESOLVING_TENANT_ID, tenantId); 57 | throw new InvalidTenantIdExeption(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | io.github.cepr0 7 | sb-multitenant-db-demo 8 | 0.0.1-SNAPSHOT 9 | pom 10 | 11 | sb-multitenant-db-demo 12 | Multi-tenant DB with Spring Boot demo project 13 | 14 | 15 | model 16 | multitenant 17 | service 18 | 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-starter-parent 23 | 2.0.1.RELEASE 24 | 25 | 26 | 27 | 28 | UTF-8 29 | UTF-8 30 | 1.8 31 | 32 | 33 | 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-data-jpa 38 | 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-web 43 | 44 | 45 | 46 | org.springframework.boot 47 | spring-boot-starter-test 48 | test 49 | 50 | 51 | 52 | org.postgresql 53 | postgresql 54 | runtime 55 | 56 | 57 | 58 | 59 | com.integralblue 60 | log4jdbc-spring-boot-starter 61 | 1.0.2 62 | 63 | 64 | 65 | org.projectlombok 66 | lombok 67 | true 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | org.springframework.boot 76 | spring-boot-maven-plugin 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /model/src/main/java/io/github/cepr0/demo/support/Cuid.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.support; 2 | 3 | import java.lang.management.ManagementFactory; 4 | import java.util.Date; 5 | import java.util.regex.Pattern; 6 | 7 | /** 8 | * https://github.com/graphcool/cuid-java 9 | * 10 | * Generates collision-resistant unique ids. 11 | */ 12 | public class Cuid { 13 | private static final int BASE = 36; 14 | private static final int LENGTH = 25; 15 | private static final int BLOCK_SIZE = 4; 16 | private static final int DISCRETE_VALUES = (int) Math.pow(BASE, BLOCK_SIZE); 17 | private static final String LETTER = "c"; 18 | 19 | private static final String FINGERPRINT; 20 | 21 | private static Pattern PATTERN = Pattern.compile("[^A-Za-z0-9]"); 22 | 23 | static { 24 | FINGERPRINT = getFingerprint(); 25 | } 26 | 27 | private static int counter = 0; 28 | 29 | private static String getHostInfo(String idFallback, String nameFallback) { 30 | String jvmName = ManagementFactory.getRuntimeMXBean().getName(); 31 | final int index = jvmName.indexOf('@'); 32 | if (index < 1) { 33 | return String.format("%s@%s", idFallback, nameFallback); 34 | } 35 | 36 | return jvmName; 37 | } 38 | 39 | private static String getFingerprint() { 40 | String hostInfo = getHostInfo(Long.toString(new Date().getTime()), "dummy-host"); 41 | 42 | String hostId = hostInfo.split("@")[0]; 43 | String hostname = hostInfo.split("@")[1]; 44 | 45 | int acc = hostname.length() + BASE; 46 | for (int i = 0; i < hostname.length(); i++) { 47 | acc += acc + (int) hostname.charAt(i); 48 | } 49 | 50 | String idBlock = pad(Long.toString(Long.parseLong(hostId), BASE), 2); 51 | String nameBlock = pad(Integer.toString(acc), 2); 52 | 53 | return idBlock + nameBlock; 54 | } 55 | 56 | private static String pad(String input, int size) { 57 | // courtesy of http://stackoverflow.com/a/4903603/1176596 58 | String repeatedZero = new String(new char[size]).replace("\0", "0"); 59 | String padded = repeatedZero + input; 60 | return (padded).substring(padded.length() - size); 61 | } 62 | 63 | private static String getRandomBlock() { 64 | return pad(Integer.toString((int) (Math.random() * DISCRETE_VALUES), BASE), BLOCK_SIZE); 65 | } 66 | 67 | private static int safeCounter() { 68 | counter = counter < DISCRETE_VALUES ? counter : 0; 69 | return counter++; 70 | } 71 | 72 | /** 73 | * Generates collision-resistant unique ids. 74 | * 75 | * @return a collision-resistant unique id 76 | */ 77 | public static String createCuid() { 78 | String timestamp = Long.toString(new Date().getTime(), BASE); 79 | String counter = pad(Integer.toString(safeCounter(), BASE), BLOCK_SIZE); 80 | String random = getRandomBlock() + getRandomBlock(); 81 | 82 | return LETTER + timestamp + counter + FINGERPRINT + random; 83 | } 84 | 85 | /** 86 | * Validates a cuid 87 | * 88 | * @param cuid 89 | * @return true if it's a valid cuid or false if it's not 90 | */ 91 | public static boolean validate(String cuid) { 92 | return (null != cuid) && (cuid.length() == LENGTH && cuid.substring(0, 1).equals(LETTER)) && hasNotSpecialChars(cuid); 93 | } 94 | 95 | private static boolean hasNotSpecialChars(String cuid) { 96 | return !PATTERN.matcher(cuid).find(); 97 | } 98 | } -------------------------------------------------------------------------------- /service/src/main/java/io/github/cepr0/demo/Application.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo; 2 | 3 | import io.github.cepr0.demo.multitenant.MultiTenantManager; 4 | import lombok.SneakyThrows; 5 | import lombok.extern.slf4j.Slf4j; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.boot.autoconfigure.domain.EntityScan; 9 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; 10 | import org.springframework.boot.context.event.ApplicationReadyEvent; 11 | import org.springframework.context.event.EventListener; 12 | 13 | import java.io.File; 14 | import java.io.FileInputStream; 15 | import java.io.IOException; 16 | import java.nio.file.Paths; 17 | import java.sql.SQLException; 18 | import java.util.Properties; 19 | 20 | import static java.lang.String.format; 21 | 22 | @Slf4j 23 | @EntityScan("io.github.cepr0.demo.model") 24 | @SpringBootApplication 25 | public class Application { 26 | 27 | private final MultiTenantManager tenantManager; 28 | 29 | public Application(MultiTenantManager tenantManager) { 30 | this.tenantManager = tenantManager; 31 | this.tenantManager.setTenantResolver(Application::tenantResolver); 32 | } 33 | 34 | public static void main(String[] args) { 35 | SpringApplication.run(Application.class, args); 36 | } 37 | 38 | /** 39 | * Load tenant datasource properties from the folder 'tenants/onStartUp` 40 | * when the app has started. 41 | */ 42 | @SneakyThrows(IOException.class) 43 | @EventListener 44 | public void onReady(ApplicationReadyEvent event) { 45 | 46 | File[] files = Paths.get("tenants/onStartUp").toFile().listFiles(); 47 | 48 | if (files == null) { 49 | log.warn("[!] Tenant property files not found at ./tenants/onStartUp folder!"); 50 | return; 51 | } 52 | 53 | for (File propertyFile : files) { 54 | Properties tenantProperties = new Properties(); 55 | tenantProperties.load(new FileInputStream(propertyFile)); 56 | 57 | String tenantId = tenantProperties.getProperty("id"); 58 | String url = tenantProperties.getProperty("url"); 59 | String username = tenantProperties.getProperty("username"); 60 | String password = tenantProperties.getProperty("password"); 61 | 62 | try { 63 | tenantManager.addTenant(tenantId, url, username, password); 64 | log.info("[i] Loaded DataSource for tenant '{}'.", tenantId); 65 | } catch (SQLException e) { 66 | log.error(format("[!] Could not load DataSource for tenant '%s'!", tenantId), e); 67 | } 68 | } 69 | } 70 | 71 | /** 72 | * Example of the tenant resolver - load the given tenant datasource properties 73 | * from the folder 'tenants/atRuntime' 74 | * 75 | * @param tenantId tenant id 76 | * @return tenant DataSource 77 | */ 78 | private static DataSourceProperties tenantResolver(String tenantId) { 79 | 80 | File[] files = Paths.get("tenants/atRuntime").toFile().listFiles(); 81 | 82 | if (files == null) { 83 | String msg = "[!] Tenant property files not found at ./tenants/atRuntime folder!"; 84 | log.error(msg); 85 | throw new RuntimeException(msg); 86 | } 87 | 88 | for (File propertyFile : files) { 89 | Properties tenantProperties = new Properties(); 90 | try { 91 | tenantProperties.load(new FileInputStream(propertyFile)); 92 | } catch (IOException e) { 93 | String msg = "[!] Could not read tenant property file at ./tenants/atRuntime folder!"; 94 | log.error(msg); 95 | throw new RuntimeException(msg, e); 96 | } 97 | 98 | String id = tenantProperties.getProperty("id"); 99 | if (tenantId.equals(id)) { 100 | DataSourceProperties properties = new DataSourceProperties(); 101 | properties.setUrl(tenantProperties.getProperty("url")); 102 | properties.setUsername(tenantProperties.getProperty("username")); 103 | properties.setPassword(tenantProperties.getProperty("password")); 104 | return properties; 105 | } 106 | } 107 | String msg = "[!] Any tenant property files not found at ./tenants/atRuntime folder!"; 108 | log.error(msg); 109 | throw new RuntimeException(msg); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /multitenant/src/main/java/io/github/cepr0/demo/multitenant/MultiTenantManager.java: -------------------------------------------------------------------------------- 1 | package io.github.cepr0.demo.multitenant; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; 5 | import org.springframework.boot.jdbc.DataSourceBuilder; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.jdbc.datasource.DriverManagerDataSource; 9 | import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; 10 | 11 | import javax.sql.DataSource; 12 | import java.sql.Connection; 13 | import java.sql.SQLException; 14 | import java.util.Collection; 15 | import java.util.Map; 16 | import java.util.concurrent.ConcurrentHashMap; 17 | import java.util.function.Function; 18 | 19 | import static java.lang.String.format; 20 | 21 | @Slf4j 22 | @Configuration 23 | public class MultiTenantManager { 24 | 25 | private final ThreadLocal currentTenant = new ThreadLocal<>(); 26 | private final Map tenantDataSources = new ConcurrentHashMap<>(); 27 | private final DataSourceProperties properties; 28 | 29 | private Function tenantResolver; 30 | 31 | private AbstractRoutingDataSource multiTenantDataSource; 32 | 33 | public MultiTenantManager(DataSourceProperties properties) { 34 | this.properties = properties; 35 | } 36 | 37 | @Bean 38 | public DataSource dataSource() { 39 | 40 | multiTenantDataSource = new AbstractRoutingDataSource() { 41 | @Override 42 | protected Object determineCurrentLookupKey() { 43 | return currentTenant.get(); 44 | } 45 | }; 46 | multiTenantDataSource.setTargetDataSources(tenantDataSources); 47 | multiTenantDataSource.setDefaultTargetDataSource(defaultDataSource()); 48 | multiTenantDataSource.afterPropertiesSet(); 49 | return multiTenantDataSource; 50 | } 51 | 52 | public void setTenantResolver(Function tenantResolver) { 53 | this.tenantResolver = tenantResolver; 54 | } 55 | 56 | public void setCurrentTenant(String tenantId) throws SQLException, TenantNotFoundException, TenantResolvingException { 57 | if (tenantIsAbsent(tenantId)) { 58 | if (tenantResolver != null) { 59 | DataSourceProperties properties; 60 | try { 61 | properties = tenantResolver.apply(tenantId); 62 | log.debug("[d] Datasource properties resolved for tenant ID '{}'", tenantId); 63 | } catch (Exception e) { 64 | throw new TenantResolvingException(e, "Could not resolve the tenant!"); 65 | } 66 | 67 | String url = properties.getUrl(); 68 | String username = properties.getUsername(); 69 | String password = properties.getPassword(); 70 | 71 | addTenant(tenantId, url, username, password); 72 | } else { 73 | throw new TenantNotFoundException(format("Tenant %s not found!", tenantId)); 74 | } 75 | } 76 | currentTenant.set(tenantId); 77 | log.debug("[d] Tenant '{}' set as current.", tenantId); 78 | } 79 | 80 | public void addTenant(String tenantId, String url, String username, String password) throws SQLException { 81 | 82 | DataSource dataSource = DataSourceBuilder.create() 83 | .driverClassName(properties.getDriverClassName()) 84 | .url(url) 85 | .username(username) 86 | .password(password) 87 | .build(); 88 | 89 | // Check that new connection is 'live'. If not - throw exception 90 | try(Connection c = dataSource.getConnection()) { 91 | tenantDataSources.put(tenantId, dataSource); 92 | multiTenantDataSource.afterPropertiesSet(); 93 | log.debug("[d] Tenant '{}' added.", tenantId); 94 | } 95 | } 96 | 97 | public DataSource removeTenant(String tenantId) { 98 | Object removedDataSource = tenantDataSources.remove(tenantId); 99 | multiTenantDataSource.afterPropertiesSet(); 100 | return (DataSource) removedDataSource; 101 | } 102 | 103 | public boolean tenantIsAbsent(String tenantId) { 104 | return !tenantDataSources.containsKey(tenantId); 105 | } 106 | 107 | public Collection getTenantList() { 108 | return tenantDataSources.keySet(); 109 | } 110 | 111 | private DriverManagerDataSource defaultDataSource() { 112 | DriverManagerDataSource defaultDataSource = new DriverManagerDataSource(); 113 | defaultDataSource.setDriverClassName("org.h2.Driver"); 114 | defaultDataSource.setUrl("jdbc:h2:mem:default"); 115 | defaultDataSource.setUsername("default"); 116 | defaultDataSource.setPassword("default"); 117 | return defaultDataSource; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Multi-tenancy database Spring Boot demo project 2 | 3 | ### A simple single-class solution with support for dynamic loading of the tenant datasources 4 | 5 | #### Description 6 | This solution utilizes the separate schema of [multi-tenant data approaches][1]: 7 | 8 | ![mt.png](mt.png) 9 | 10 | To implement multi-tenancy with Spring Boot we can use [AbstractRoutingDataSource][2] as base **DataSource** class for all '*tenant databases*'. 11 | 12 | It has one abstract method [determineCurrentLookupKey][3] that we have to override. It tells the `AbstractRoutingDataSource` which of the tenant datasource it have to provide at the moment to work with. Because it work in the multi-threading environment, the information of the chosen tenant should be stored in `ThreadLocal` variable. 13 | 14 | The `AbstractRoutingDataSource` stores the info of the tenant datasources in its private `Map targetDataSources`. The key of this map is a **tenant identifier** (for example the String type) and the value - the **tenant datasource**. To put our tenant datasources to this map we have to use its setter `setTargetDataSources`. 15 | 16 | The `AbstractRoutingDataSource` will not work without 'default' datasource which we have to set with method `setDefaultTargetDataSource(Object defaultTargetDataSource)`. 17 | 18 | After we set the tenant datasources and the default one, we have to invoke method `afterPropertiesSet()` to tell the `AbstractRoutingDataSource` to update its state. 19 | 20 | So our 'MultiTenantManager' class in the basic version can be like this: 21 | 22 | ```java 23 | @Configuration 24 | public class MultiTenantManager { 25 | 26 | private final ThreadLocal currentTenant = new ThreadLocal<>(); 27 | private final Map tenantDataSources = new ConcurrentHashMap<>(); 28 | private final DataSourceProperties properties; 29 | 30 | private AbstractRoutingDataSource multiTenantDataSource; 31 | 32 | public MultiTenantManager(DataSourceProperties properties) { 33 | this.properties = properties; 34 | } 35 | 36 | @Bean 37 | public DataSource dataSource() { 38 | multiTenantDataSource = new AbstractRoutingDataSource() { 39 | @Override 40 | protected Object determineCurrentLookupKey() { 41 | return currentTenant.get(); 42 | } 43 | }; 44 | multiTenantDataSource.setTargetDataSources(tenantDataSources); 45 | multiTenantDataSource.setDefaultTargetDataSource(defaultDataSource()); 46 | multiTenantDataSource.afterPropertiesSet(); 47 | return multiTenantDataSource; 48 | } 49 | 50 | public void addTenant(String tenantId, String url, String username, String password) throws SQLException { 51 | 52 | DataSource dataSource = DataSourceBuilder.create() 53 | .driverClassName(properties.getDriverClassName()) 54 | .url(url) 55 | .username(username) 56 | .password(password) 57 | .build(); 58 | 59 | // Check that new connection is 'live'. If not - throw exception 60 | try(Connection c = dataSource.getConnection()) { 61 | tenantDataSources.put(tenantId, dataSource); 62 | multiTenantDataSource.afterPropertiesSet(); 63 | } 64 | } 65 | 66 | public void setCurrentTenant(String tenantId) { 67 | currentTenant.set(tenantId); 68 | } 69 | 70 | private DriverManagerDataSource defaultDataSource() { 71 | DriverManagerDataSource defaultDataSource = new DriverManagerDataSource(); 72 | defaultDataSource.setDriverClassName("org.h2.Driver"); 73 | defaultDataSource.setUrl("jdbc:h2:mem:default"); 74 | defaultDataSource.setUsername("default"); 75 | defaultDataSource.setPassword("default"); 76 | return defaultDataSource; 77 | } 78 | } 79 | ``` 80 | 81 | **Brief explanation** 82 | 83 | - map `tenantDataSources` it's our local tenant datasource storage which we put to the `setTargetDataSources` setter; 84 | 85 | - `DataSourceProperties properties` is used to get Database Driver Class name of tenant database from the `spring.datasource.driverClassName` of the 'application.properties' (for example, `org.postgresql.Driver`); 86 | 87 | - method `addTenant` is used to add a new tenant and its datasource to our local tenant datasource storage. **We can do this on the fly** - thanks to the method `afterPropertiesSet()` (see example [here](service/src/main/java/io/github/cepr0/demo/controller/TenantController.java)); 88 | 89 | - method `setCurrentTenant(String tenantId)` is used to 'switch' onto datasource of the given tenant. We can use this method, for example, in the REST controller when handling a request to work with database. The request should contain the 'tenantId', for example in the `X-TenantId` header, that we can retrieve and put to this method; 90 | 91 | - `defaultDataSource()` is build with in-memory H2 Database to avoid the using the default database on the working SQL server. 92 | 93 | Note: we **must** set `spring.jpa.hibernate.ddl-auto` parameter to `none` to disable the Hibernate make changes in the database schema. We have to create schema of tenant databases beforehand. 94 | 95 | #### Loading tenant datasource dynamically 96 | 97 | To realize this we can add to the class new property `tenantResolver` and it's setter: 98 | 99 | ```java 100 | private Function tenantResolver; 101 | 102 | public void setTenantResolver(Function tenantResolver) { 103 | this.tenantResolver = tenantResolver; 104 | } 105 | ``` 106 | It will work as supplier of tenantId and its datasource. Then we can update the `setCurrentTenant` method: 107 | 108 | ```java 109 | public void setCurrentTenant(String tenantId) throws SQLException, TenantNotFoundException, TenantResolvingException { 110 | if (tenantIsAbsent(tenantId)) { 111 | if (tenantResolver != null) { 112 | DataSourceProperties properties; 113 | try { 114 | properties = tenantResolver.apply(tenantId); 115 | } catch (Exception e) { 116 | throw new TenantResolvingException(e, "Could not resolve the tenant!"); 117 | } 118 | String url = properties.getUrl(); 119 | String username = properties.getUsername(); 120 | String password = properties.getPassword(); 121 | 122 | addTenant(tenantId, url, username, password); 123 | } else { 124 | throw new TenantNotFoundException(format("Tenant %s not found!", tenantId)); 125 | } 126 | } 127 | currentTenant.set(tenantId); 128 | } 129 | ``` 130 | 131 | If tenantId not found in the local storage then we try to resolve them (if resolver is not null) and add it and its datasource parameters. 132 | 133 | Now, during the next request to the our rest controller (for example), the code will check whether the tenant datasource is present in the local storage and, 134 | if it does not exist, will load its parameters dynamically. 135 | 136 | Full code of the `MultiTenantManager` you can find [here](multitenant/src/main/java/io/github/cepr0/demo/multitenant/MultiTenantManager.java). 137 | 138 | #### Usage example 139 | 140 | Will be added... 141 | 142 | [1]: http://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#multitenacy-approaches 143 | [2]: https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/jdbc/datasource/lookup/AbstractRoutingDataSource.html 144 | [3]: https://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/jdbc/datasource/lookup/AbstractRoutingDataSource.html#determineCurrentLookupKey-- 145 | --------------------------------------------------------------------------------