├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml ├── docker ├── .env └── rls-api │ └── Dockerfile ├── pom.xml ├── sql ├── create_data.sql └── create_database.sql └── src └── main ├── java └── de │ └── bytefish │ └── multitenancy │ ├── SampleSpringApplication.java │ ├── async │ ├── AsyncConfig.java │ └── TenantAwareTaskDecorator.java │ ├── conf │ ├── ApplicationConfiguration.java │ └── TenantConfiguration.java │ ├── core │ ├── TenantAware.java │ ├── TenantListener.java │ └── ThreadLocalStorage.java │ ├── datasource │ └── TenantAwareRoutingSource.java │ ├── model │ ├── Address.java │ ├── Customer.java │ ├── CustomerAddress.java │ └── Tenant.java │ ├── repositories │ ├── IAddressRepository.java │ ├── ICustomerAddressRepository.java │ └── ICustomerRepository.java │ └── web │ ├── configuration │ └── WebMvcConfig.java │ ├── controllers │ └── CustomerController.java │ ├── converter │ └── Converters.java │ ├── interceptors │ └── TenantNameInterceptor.java │ └── model │ ├── AddressDto.java │ └── CustomerDto.java └── resources ├── application-docker.yml ├── application.yml └── logback.xml /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.idea 3 | target 4 | .m2 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Philipp Wagner 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Providing Multitenancy with Spring Boot # 2 | 3 | ## Project ## 4 | 5 | This project is an example project for Multi Tenancy using Row Level Security: 6 | 7 | * https://bytefish.de/blog/spring_boot_multitenancy_using_rls/ 8 | 9 | ### Example ### 10 | 11 | You can use `docker compose` to create the PostgreSQL database and start the Spring Boot application: 12 | 13 | ``` 14 | docker compose --profile dev up 15 | ``` 16 | 17 | The Postgres Database currently has a customer for each Tenant: 18 | 19 | ```sql 20 | > select * from multitenant.customer 21 | 22 | customer_id | first_name | last_name | tenant_name 23 | ----------------+---------------+---------------+-------------- 24 | 1 | Philipp | Wagner | tenant_a 25 | 2 | John | Wick | tenant_b 26 | (2 rows) 27 | ``` 28 | 29 | The list of customers for `tenant_a` only contains "Philipp Wagner", as expected: 30 | 31 | ``` 32 | > curl -H "X-TenantID: tenant_a" -X GET http://localhost:8080/customers 33 | 34 | [{"id":1,"firstName":"Philipp","lastName":"Wagner","addresses":[{"id":1,"name":"Philipp Wagner","street":"Fakestreet 1","postalcode":"12345","city":"Faketown","country":"Germany"}]}] 35 | ``` 36 | 37 | And the list of customers for `tenant_b` only contains "John Wick", again as expected: 38 | 39 | ``` 40 | >curl -H "X-TenantID: tenant_b" -X GET http://localhost:8080/customers 41 | 42 | [{"id":2,"firstName":"John","lastName":"Wick","addresses":[{"id":2,"name":"John Wick","street":"Fakestreet 55","postalcode":"00000","city":"Fakecity","country":"USA"}]}] 43 | ``` 44 | 45 | We now insert a new customer for Tenant `tenant_a`: 46 | 47 | ``` 48 | > curl -H "X-TenantID: tenant_a" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Max\", \"lastName\" : \"Mustermann\"}" http://localhost:8080/customers 49 | 50 | {"id":38187,"firstName":"Max","lastName":"Mustermann","addresses":[]} 51 | ``` 52 | 53 | Getting a list of all customers for `tenant_a` will now return two customers: 54 | 55 | ``` 56 | > curl -H "X-TenantID: tenant_a" -X GET http://localhost:8080/customers 57 | 58 | [{"id":1,"firstName":"Philipp","lastName":"Wagner"},{"id":38187,"firstName":"Max","lastName":"Mustermann"}] 59 | ``` 60 | 61 | While requesting a list of all customers for `tenant_b` returns John Wick only: 62 | 63 | ``` 64 | > curl -H "X-TenantID: tenant_b" -X GET http://localhost:8080/customers 65 | 66 | [{"id":2,"firstName":"John","lastName":"Wick"}] 67 | ``` 68 | 69 | We can now insert a customer for `tenant_b`: 70 | 71 | ``` 72 | > curl -H "X-TenantID: tenant_b" -H "Content-Type: application/json" -X POST -d "{\"firstName\" : \"Hans\", \"lastName\" : \"Wurst\"}" http://localhost:8080/customers 73 | 74 | {"id":38188,"firstName":"Hans","lastName":"Wurst","addresses":[]} 75 | ``` 76 | 77 | Querying the `tenant_a` database still returns "Philipp Wagner" and "Max Mustermann": 78 | 79 | ``` 80 | > curl -H "X-TenantID: tenant_a" -X GET http://localhost:8080/customers 81 | 82 | [{"id":1,"firstName":"Philipp","lastName":"Wagner"},{"id":38187,"firstName":"Max","lastName":"Mustermann"}] 83 | ``` 84 | 85 | While querying as `tenant_b` now returns "John Wick" and "Hans Wurst": 86 | 87 | ``` 88 | > curl -H "X-TenantID: tenant_b" -X GET http://localhost:8080/customers 89 | 90 | [{"id":2,"firstName":"John","lastName":"Wick"},{"id":38188,"firstName":"Hans","lastName":"Wurst"}] 91 | ``` 92 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | networks: 2 | services: 3 | 4 | services: 5 | postgres: 6 | image: postgres:16 7 | container_name: postgres 8 | networks: 9 | - services 10 | ports: 11 | - "5432:5432" 12 | environment: 13 | - POSTGRES_USER=postgres 14 | - POSTGRES_PASSWORD=password 15 | - POSTGRES_DB=sampledb 16 | volumes: 17 | - ./sql/create_database.sql:/docker-entrypoint-initdb.d/1-create_database.sql 18 | - ./sql/create_data.sql:/docker-entrypoint-initdb.d/2-create_data.sql 19 | healthcheck: 20 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 21 | interval: 5s 22 | timeout: 5s 23 | retries: 5 24 | profiles: ["postgres", "dev"] 25 | rls-api: 26 | depends_on: 27 | - postgres 28 | build: 29 | context: . 30 | dockerfile: ./docker/rls-api/Dockerfile 31 | networks: 32 | - services 33 | restart: on-failure 34 | env_file: ./docker/.env 35 | profiles: ["api", "dev"] 36 | ports: 37 | - "8080:8080" 38 | environment: 39 | - SPRING_PROFILES_ACTIVE=docker 40 | volumes: 41 | - /docker/.m2:/root/.m2 42 | stdin_open: true 43 | tty: true -------------------------------------------------------------------------------- /docker/.env: -------------------------------------------------------------------------------- 1 | ELASTIC_HOSTNAME=es01 2 | ELASTIC_USERNAME=elastic 3 | ELASTIC_PASSWORD=secret 4 | ELASTIC_PORT=9200 5 | ELASTIC_SECURITY=true 6 | ELASTIC_SCHEME=https 7 | ELASTIC_VERSION=8.14.1 8 | -------------------------------------------------------------------------------- /docker/rls-api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM maven:3.8.5-openjdk-17 2 | 3 | WORKDIR /rls-api 4 | 5 | COPY pom.xml pom.xml 6 | COPY src src 7 | 8 | RUN mvn clean install 9 | 10 | CMD mvn spring-boot:run -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 3.3.2 11 | 12 | 13 | 14 | de.bytefish 15 | multitenancy 16 | 0.1 17 | 18 | 19 | 20 | 21 | org.springframework.boot 22 | spring-boot-maven-plugin 23 | 24 | 25 | 26 | 27 | 28 | 29 | UTF-8 30 | 17 31 | 32 | 33 | 34 | 35 | 36 | org.springframework.boot 37 | spring-boot-starter-web 38 | 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-jdbc 43 | 44 | 45 | 46 | org.springframework.boot 47 | spring-boot-starter-data-jpa 48 | 49 | 50 | 51 | org.postgresql 52 | postgresql 53 | 42.7.3 54 | 55 | 56 | 57 | com.zaxxer 58 | HikariCP 59 | 5.1.0 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /sql/create_data.sql: -------------------------------------------------------------------------------- 1 | DO $$ 2 | BEGIN 3 | 4 | ---------------------------------------------- 5 | -- Create the Sample Data for Tenant A -- 6 | ---------------------------------------------- 7 | INSERT INTO multitenant.customer(customer_id, first_name, last_name, tenant_name) 8 | VALUES 9 | (1, 'Philipp', 'Wagner', 'tenant_a') 10 | ON CONFLICT DO NOTHING; 11 | 12 | INSERT INTO multitenant.address(address_id, name, street, postalcode, city, country, tenant_name) 13 | VALUES 14 | (1, 'Philipp Wagner', 'Fakestreet 1', '12345', 'Faketown', 'Germany', 'tenant_a') 15 | ON CONFLICT DO NOTHING; 16 | 17 | INSERT INTO multitenant.customer_address(customer_id, address_id, tenant_name) 18 | VALUES 19 | (1, 1, 'tenant_a') 20 | ON CONFLICT DO NOTHING; 21 | 22 | ---------------------------------------------- 23 | -- Create the Sample Data for Tenant B -- 24 | ---------------------------------------------- 25 | INSERT INTO multitenant.customer(customer_id, first_name, last_name, tenant_name) 26 | VALUES 27 | (2, 'John', 'Wick', 'tenant_b') 28 | ON CONFLICT DO NOTHING; 29 | 30 | INSERT INTO multitenant.address(address_id, name, street, postalcode, city, country, tenant_name) 31 | VALUES 32 | (2, 'John Wick', 'Fakestreet 55', '00000', 'Fakecity', 'USA', 'tenant_b') 33 | ON CONFLICT DO NOTHING; 34 | 35 | INSERT INTO multitenant.customer_address(customer_id, address_id, tenant_name) 36 | VALUES 37 | (2, 2, 'tenant_b') 38 | ON CONFLICT DO NOTHING; 39 | 40 | END; 41 | $$; -------------------------------------------------------------------------------- /sql/create_database.sql: -------------------------------------------------------------------------------- 1 | DO $$ 2 | BEGIN 3 | 4 | --------------------------- 5 | -- Create the tenants -- 6 | --------------------------- 7 | IF NOT EXISTS ( 8 | SELECT FROM pg_catalog.pg_roles 9 | WHERE rolname = 'tenant_a') THEN 10 | 11 | CREATE ROLE tenant_a LOGIN PASSWORD 'tenant_a'; 12 | 13 | END IF; 14 | 15 | IF NOT EXISTS ( 16 | SELECT FROM pg_catalog.pg_roles 17 | WHERE rolname = 'tenant_b') THEN 18 | 19 | CREATE ROLE tenant_b LOGIN PASSWORD 'tenant_b'; 20 | 21 | END IF; 22 | 23 | --------------------------- 24 | -- Create the Schema -- 25 | --------------------------- 26 | CREATE SCHEMA IF NOT EXISTS multitenant; 27 | 28 | ---------------------------- 29 | -- Create Sequences -- 30 | ---------------------------- 31 | CREATE SEQUENCE IF NOT EXISTS multitenant.customer_seq 32 | start 38187 33 | increment 1 34 | NO MAXVALUE 35 | CACHE 1; 36 | 37 | CREATE SEQUENCE IF NOT EXISTS multitenant.address_seq 38 | start 38187 39 | increment 1 40 | NO MAXVALUE 41 | CACHE 1; 42 | 43 | ---------------------------- 44 | -- Create the Tables -- 45 | ---------------------------- 46 | CREATE TABLE IF NOT EXISTS multitenant.customer 47 | ( 48 | customer_id integer default nextval('multitenant.customer_seq'), 49 | first_name VARCHAR(255) NOT NULL, 50 | last_name VARCHAR(255) NOT NULL, 51 | tenant_name VARCHAR(255) NOT NULL, 52 | CONSTRAINT customer_pkey 53 | PRIMARY KEY (customer_id) 54 | 55 | ); 56 | 57 | CREATE TABLE IF NOT EXISTS multitenant.address 58 | ( 59 | address_id integer default nextval('multitenant.address_seq'), 60 | name VARCHAR(255) NOT NULL, 61 | street VARCHAR(255) NULL, 62 | postalcode VARCHAR(255) NULL, 63 | city VARCHAR(255) NULL, 64 | country VARCHAR(255) NULL, 65 | tenant_name VARCHAR(255) NOT NULL, 66 | CONSTRAINT address_pkey 67 | PRIMARY KEY (address_id) 68 | 69 | ); 70 | 71 | CREATE TABLE IF NOT EXISTS multitenant.customer_address 72 | ( 73 | customer_id integer NOT NULL, 74 | address_id integer NOT NULL, 75 | tenant_name VARCHAR(255) NOT NULL, 76 | CONSTRAINT fk_customer_address_customer 77 | FOREIGN KEY(customer_id) 78 | REFERENCES multitenant.customer(customer_id), 79 | CONSTRAINT fk_customer_address_address 80 | FOREIGN KEY(address_id) 81 | REFERENCES multitenant.address(address_id) 82 | ); 83 | 84 | --------------------------- 85 | -- Enable RLS -- 86 | --------------------------- 87 | ALTER TABLE multitenant.customer 88 | ENABLE ROW LEVEL SECURITY; 89 | 90 | ALTER TABLE multitenant.address 91 | ENABLE ROW LEVEL SECURITY; 92 | 93 | ALTER TABLE multitenant.customer_address 94 | ENABLE ROW LEVEL SECURITY; 95 | 96 | --------------------------- 97 | -- Create the RLS Policy -- 98 | --------------------------- 99 | DROP POLICY IF EXISTS tenant_customer_isolation_policy ON multitenant.customer; 100 | DROP POLICY IF EXISTS tenant_address_isolation_policy ON multitenant.address; 101 | DROP POLICY IF EXISTS tenant_customer_address_isolation_policy ON multitenant.customer_address; 102 | 103 | CREATE POLICY tenant_customer_isolation_policy ON multitenant.customer 104 | USING (tenant_name = current_user); 105 | 106 | CREATE POLICY tenant_address_isolation_policy ON multitenant.address 107 | USING (tenant_name = current_user); 108 | 109 | CREATE POLICY tenant_customer_address_isolation_policy ON multitenant.customer_address 110 | USING (tenant_name = current_user); 111 | 112 | -------------------------------- 113 | -- Grant Access to the Schema -- 114 | -------------------------------- 115 | GRANT USAGE ON SCHEMA multitenant TO tenant_a; 116 | GRANT USAGE ON SCHEMA multitenant TO tenant_b; 117 | 118 | ------------------------------------------------------- 119 | -- Grant Access to multitenant.customer for Tenant A -- 120 | ------------------------------------------------------- 121 | GRANT ALL ON SEQUENCE multitenant.customer_seq TO tenant_a; 122 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer TO tenant_a; 123 | 124 | GRANT ALL ON SEQUENCE multitenant.address_seq TO tenant_a; 125 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.address TO tenant_a; 126 | 127 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer_address TO tenant_a; 128 | 129 | ------------------------------------------------------- 130 | -- Grant Access to multitenant.customer for Tenant B -- 131 | ------------------------------------------------------- 132 | GRANT ALL ON SEQUENCE multitenant.customer_seq TO tenant_b; 133 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer TO tenant_b; 134 | 135 | GRANT ALL ON SEQUENCE multitenant.address_seq TO tenant_b; 136 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.address TO tenant_b; 137 | 138 | GRANT SELECT, UPDATE, INSERT, DELETE ON TABLE multitenant.customer_address TO tenant_b; 139 | 140 | END; 141 | $$; -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/SampleSpringApplication.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy; 4 | 5 | import com.zaxxer.hikari.HikariDataSource; 6 | import de.bytefish.multitenancy.conf.ApplicationConfiguration; 7 | import de.bytefish.multitenancy.conf.TenantConfiguration; 8 | import de.bytefish.multitenancy.datasource.TenantAwareRoutingSource; 9 | import de.bytefish.multitenancy.model.Tenant; 10 | import org.springframework.boot.SpringApplication; 11 | import org.springframework.boot.autoconfigure.SpringBootApplication; 12 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 13 | import org.springframework.context.annotation.Bean; 14 | import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; 15 | import org.springframework.scheduling.annotation.EnableAsync; 16 | import org.springframework.transaction.annotation.EnableTransactionManagement; 17 | 18 | import javax.sql.DataSource; 19 | import java.util.HashMap; 20 | import java.util.Map; 21 | 22 | @SpringBootApplication 23 | @EnableAsync 24 | @EnableTransactionManagement 25 | @ConfigurationPropertiesScan 26 | public class SampleSpringApplication { 27 | 28 | public static void main(String[] args) { 29 | SpringApplication.run(SampleSpringApplication.class, args); 30 | } 31 | 32 | @Bean 33 | public DataSource dataSource(ApplicationConfiguration applicationConfiguration) { 34 | 35 | AbstractRoutingDataSource dataSource = new TenantAwareRoutingSource(); 36 | 37 | Map targetDataSources = new HashMap<>(); 38 | 39 | for(var tenantConfiguration : applicationConfiguration.getTenants()) { 40 | // Builds the DataSource for the Tenant 41 | var tenantDataSource = buildDataSource(tenantConfiguration); 42 | // Puts it into the DataSources available for routing a Request 43 | targetDataSources.put(tenantConfiguration.getName(), tenantDataSource); 44 | } 45 | 46 | dataSource.setTargetDataSources(targetDataSources); 47 | 48 | dataSource.afterPropertiesSet(); 49 | 50 | return dataSource; 51 | } 52 | 53 | public DataSource buildDataSource(TenantConfiguration tenantConfiguration) { 54 | HikariDataSource dataSource = new HikariDataSource(); 55 | 56 | dataSource.setInitializationFailTimeout(0); 57 | dataSource.setMaximumPoolSize(5); 58 | dataSource.setDataSourceClassName("org.postgresql.ds.PGSimpleDataSource"); 59 | dataSource.addDataSourceProperty("url", tenantConfiguration.getDbUrl()); 60 | dataSource.addDataSourceProperty("user", tenantConfiguration.getDbUser()); 61 | dataSource.addDataSourceProperty("password", tenantConfiguration.getDbPassword()); 62 | 63 | return dataSource; 64 | } 65 | } -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/async/AsyncConfig.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.async; 4 | 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.scheduling.annotation.AsyncConfigurerSupport; 7 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 8 | 9 | import java.util.concurrent.Executor; 10 | 11 | @Configuration 12 | public class AsyncConfig extends AsyncConfigurerSupport { 13 | 14 | @Override 15 | public Executor getAsyncExecutor() { 16 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 17 | 18 | executor.setCorePoolSize(7); 19 | executor.setMaxPoolSize(42); 20 | executor.setQueueCapacity(11); 21 | executor.setThreadNamePrefix("TenantAwareTaskExecutor-"); 22 | executor.setTaskDecorator(new TenantAwareTaskDecorator()); 23 | executor.initialize(); 24 | 25 | return executor; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/async/TenantAwareTaskDecorator.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.async; 4 | 5 | import de.bytefish.multitenancy.core.ThreadLocalStorage; 6 | import org.springframework.core.task.TaskDecorator; 7 | 8 | public class TenantAwareTaskDecorator implements TaskDecorator { 9 | 10 | @Override 11 | public Runnable decorate(Runnable runnable) { 12 | String tenantName = ThreadLocalStorage.getTenantName(); 13 | return () -> { 14 | try { 15 | ThreadLocalStorage.setTenantName(tenantName); 16 | runnable.run(); 17 | } finally { 18 | ThreadLocalStorage.setTenantName(null); 19 | } 20 | }; 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/conf/ApplicationConfiguration.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.conf; 4 | 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | 7 | import java.util.List; 8 | 9 | @ConfigurationProperties(prefix = "application") 10 | public class ApplicationConfiguration { 11 | 12 | private final List tenants; 13 | 14 | public ApplicationConfiguration(List tenants) { 15 | this.tenants = tenants; 16 | } 17 | 18 | public List getTenants() { 19 | return tenants; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/conf/TenantConfiguration.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.conf; 4 | 5 | public class TenantConfiguration { 6 | 7 | private final String name; 8 | private final String dbUrl; 9 | private final String dbUser; 10 | private final String dbPassword; 11 | 12 | public TenantConfiguration(String name, String dbUrl, String dbUser, String dbPassword) { 13 | this.name = name; 14 | this.dbUrl = dbUrl; 15 | this.dbUser = dbUser; 16 | this.dbPassword = dbPassword; 17 | } 18 | 19 | public String getName() { 20 | return name; 21 | } 22 | 23 | public String getDbUrl() { 24 | return dbUrl; 25 | } 26 | 27 | public String getDbUser() { 28 | return dbUser; 29 | } 30 | 31 | public String getDbPassword() { 32 | return dbPassword; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/core/TenantAware.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.core; 4 | 5 | import de.bytefish.multitenancy.model.Tenant; 6 | 7 | public interface TenantAware { 8 | 9 | Tenant getTenant(); 10 | 11 | void setTenant(Tenant tenant); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/core/TenantListener.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.core; 4 | 5 | import de.bytefish.multitenancy.model.Tenant; 6 | 7 | import jakarta.persistence.PrePersist; 8 | import jakarta.persistence.PreRemove; 9 | import jakarta.persistence.PreUpdate; 10 | 11 | public class TenantListener { 12 | 13 | @PreUpdate 14 | @PreRemove 15 | @PrePersist 16 | public void setTenant(TenantAware entity) { 17 | Tenant tenant = entity.getTenant(); 18 | 19 | if(tenant == null) { 20 | tenant = new Tenant(); 21 | 22 | entity.setTenant(tenant); 23 | } 24 | 25 | final String tenantName = ThreadLocalStorage.getTenantName(); 26 | 27 | tenant.setTenantName(tenantName); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/core/ThreadLocalStorage.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.core; 4 | 5 | public class ThreadLocalStorage { 6 | 7 | private static ThreadLocal tenant = new ThreadLocal<>(); 8 | 9 | public static void setTenantName(String tenantName) { 10 | tenant.set(tenantName); 11 | } 12 | 13 | public static String getTenantName() { 14 | return tenant.get(); 15 | } 16 | 17 | } 18 | 19 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/datasource/TenantAwareRoutingSource.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.datasource; 4 | 5 | import de.bytefish.multitenancy.core.ThreadLocalStorage; 6 | import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; 7 | 8 | public class TenantAwareRoutingSource extends AbstractRoutingDataSource { 9 | 10 | @Override 11 | protected Object determineCurrentLookupKey() { 12 | return ThreadLocalStorage.getTenantName(); 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/model/Address.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.model; 4 | 5 | import de.bytefish.multitenancy.core.TenantAware; 6 | import de.bytefish.multitenancy.core.TenantListener; 7 | 8 | import jakarta.persistence.*; 9 | import java.util.Set; 10 | 11 | @Entity 12 | @Table(schema = "multitenant", name = "address") 13 | @EntityListeners(TenantListener.class) 14 | public class Address implements TenantAware { 15 | 16 | @Id 17 | @GeneratedValue(strategy = GenerationType.IDENTITY) 18 | @Column(name = "address_id") 19 | private Long id; 20 | 21 | @Embedded 22 | private Tenant tenant; 23 | 24 | @Column(name = "name") 25 | private String name; 26 | 27 | @Column(name = "street") 28 | private String street; 29 | 30 | @Column(name = "postalcode") 31 | private String postalcode; 32 | 33 | @Column(name = "city") 34 | private String city; 35 | 36 | @Column(name = "country") 37 | private String country; 38 | 39 | public Address() { 40 | 41 | } 42 | 43 | public Long getId() { 44 | return id; 45 | } 46 | 47 | public void setId(Long id) { 48 | this.id = id; 49 | } 50 | 51 | public String getName() { 52 | return name; 53 | } 54 | 55 | public void setName(String name) { 56 | this.name = name; 57 | } 58 | 59 | public String getStreet() { 60 | return street; 61 | } 62 | 63 | public void setStreet(String street) { 64 | this.street = street; 65 | } 66 | 67 | public String getPostalcode() { 68 | return postalcode; 69 | } 70 | 71 | public void setPostalcode(String postalcode) { 72 | this.postalcode = postalcode; 73 | } 74 | 75 | public String getCity() { 76 | return city; 77 | } 78 | 79 | public void setCity(String city) { 80 | this.city = city; 81 | } 82 | 83 | public String getCountry() { 84 | return country; 85 | } 86 | 87 | public void setCountry(String country) { 88 | this.country = country; 89 | } 90 | 91 | @Override 92 | public Tenant getTenant() { 93 | return tenant; 94 | } 95 | 96 | @Override 97 | public void setTenant(Tenant tenant) { 98 | this.tenant = tenant; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/model/Customer.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.model; 4 | 5 | import de.bytefish.multitenancy.core.TenantAware; 6 | import de.bytefish.multitenancy.core.TenantListener; 7 | 8 | import jakarta.persistence.*; 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | @Entity 13 | @Table(schema = "multitenant", name = "customer") 14 | @EntityListeners(TenantListener.class) 15 | public class Customer implements TenantAware { 16 | 17 | @Id 18 | @GeneratedValue(strategy = GenerationType.IDENTITY) 19 | @Column(name = "customer_id") 20 | private Long id; 21 | 22 | @Embedded 23 | private Tenant tenant; 24 | 25 | @Column(name = "first_name") 26 | private String firstName; 27 | 28 | @Column(name = "last_name") 29 | private String lastName; 30 | 31 | @OneToMany(mappedBy = "customer", fetch = FetchType.EAGER, cascade = CascadeType.ALL) 32 | List addresses = new ArrayList<>(); 33 | 34 | public Customer() { 35 | } 36 | 37 | public Long getId() { 38 | return id; 39 | } 40 | 41 | public void setId(Long id) { 42 | this.id = id; 43 | } 44 | 45 | public String getFirstName() { 46 | return firstName; 47 | } 48 | 49 | public void setFirstName(String firstName) { 50 | this.firstName = firstName; 51 | } 52 | 53 | public String getLastName() { 54 | return lastName; 55 | } 56 | 57 | public void setLastName(String lastName) { 58 | this.lastName = lastName; 59 | } 60 | 61 | public List getAddresses() { 62 | return addresses; 63 | } 64 | 65 | public void setAddresses(List addresses) { 66 | this.addresses = addresses; 67 | } 68 | 69 | @Override 70 | public Tenant getTenant() { 71 | return tenant; 72 | } 73 | 74 | @Override 75 | public void setTenant(Tenant tenant) { 76 | this.tenant = tenant; 77 | } 78 | } -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/model/CustomerAddress.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.model; 4 | 5 | import de.bytefish.multitenancy.core.TenantAware; 6 | import de.bytefish.multitenancy.core.TenantListener; 7 | 8 | import jakarta.persistence.*; 9 | import java.io.Serializable; 10 | import java.util.Objects; 11 | 12 | @Entity 13 | @Table(schema = "multitenant", name = "customer_address") 14 | @EntityListeners(TenantListener.class) 15 | public class CustomerAddress implements TenantAware { 16 | 17 | @Embeddable 18 | public static class Id implements Serializable { 19 | 20 | @Column(name = "customer_id", nullable = false) 21 | private Long customerId; 22 | 23 | @Column(name = "address_id", nullable = false) 24 | private Long addressId; 25 | 26 | private Id() { 27 | 28 | } 29 | 30 | public Id(Long customerId, Long addressId) { 31 | this.customerId = customerId; 32 | this.addressId = addressId; 33 | } 34 | 35 | public Long getCustomerId() { 36 | return customerId; 37 | } 38 | 39 | public Long getAddressId() { 40 | return addressId; 41 | } 42 | 43 | @Override 44 | public boolean equals(Object o) { 45 | if (this == o) return true; 46 | if (o == null || getClass() != o.getClass()) return false; 47 | Id that = (Id) o; 48 | return Objects.equals(customerId, that.customerId) && 49 | Objects.equals(addressId, that.addressId); 50 | } 51 | 52 | @Override 53 | public int hashCode() { 54 | return Objects.hash(customerId, addressId); 55 | } 56 | } 57 | 58 | @EmbeddedId 59 | private Id id; 60 | 61 | @ManyToOne(fetch = FetchType.LAZY) 62 | @JoinColumn(name = "customer_id", insertable = false, updatable = false) 63 | private Customer customer; 64 | 65 | @ManyToOne(fetch = FetchType.LAZY) 66 | @JoinColumn(name = "address_id", insertable = false, updatable = false) 67 | private Address address; 68 | 69 | @Embedded 70 | private Tenant tenant; 71 | 72 | private CustomerAddress() { 73 | } 74 | 75 | public CustomerAddress(Customer customer, Address address) { 76 | this.id = new Id(customer.getId(), address.getId()); 77 | this.customer = customer; 78 | this.address = address; 79 | } 80 | 81 | public Id getId() { 82 | return id; 83 | } 84 | 85 | public Customer getCustomer() { 86 | return customer; 87 | } 88 | 89 | public Address getAddress() { 90 | return address; 91 | } 92 | 93 | @Override 94 | public Tenant getTenant() { 95 | return tenant; 96 | } 97 | 98 | @Override 99 | public void setTenant(Tenant tenant) { 100 | this.tenant = tenant; 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/model/Tenant.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.model; 4 | 5 | import jakarta.persistence.Column; 6 | import jakarta.persistence.Embeddable; 7 | 8 | @Embeddable 9 | public class Tenant { 10 | 11 | @Column(name = "tenant_name") 12 | private String tenantName; 13 | 14 | public String getTenantName() { 15 | return tenantName; 16 | } 17 | 18 | public void setTenantName(String tenantName) { 19 | this.tenantName = tenantName; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/repositories/IAddressRepository.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.repositories; 4 | 5 | import de.bytefish.multitenancy.model.Address; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.data.repository.CrudRepository; 8 | import org.springframework.scheduling.annotation.Async; 9 | 10 | import java.util.List; 11 | import java.util.concurrent.CompletableFuture; 12 | 13 | public interface IAddressRepository extends CrudRepository { 14 | 15 | @Async 16 | @Query("select a from Address a") 17 | CompletableFuture> findAllAsync(); 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/repositories/ICustomerAddressRepository.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.repositories; 4 | 5 | import de.bytefish.multitenancy.model.Customer; 6 | import de.bytefish.multitenancy.model.CustomerAddress; 7 | import org.springframework.data.jpa.repository.Query; 8 | import org.springframework.data.repository.CrudRepository; 9 | import org.springframework.scheduling.annotation.Async; 10 | 11 | import java.util.List; 12 | import java.util.concurrent.CompletableFuture; 13 | 14 | public interface ICustomerAddressRepository extends CrudRepository { 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/repositories/ICustomerRepository.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.repositories; 4 | 5 | import de.bytefish.multitenancy.model.Customer; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.data.repository.CrudRepository; 8 | import org.springframework.data.repository.query.Param; 9 | import org.springframework.scheduling.annotation.Async; 10 | 11 | import java.util.List; 12 | import java.util.Optional; 13 | import java.util.concurrent.CompletableFuture; 14 | 15 | public interface ICustomerRepository extends CrudRepository { 16 | 17 | @Async 18 | @Query("select c from Customer c left join fetch c.addresses a left join fetch a.address") 19 | CompletableFuture> findAllAsync(); 20 | 21 | // Do a JOIN FETCH to prevent Hibernate from doing N+1 Queries... 22 | @Query("select c from Customer c left join fetch c.addresses a left join fetch a.address where c.id = :id") 23 | Optional findById(@Param("id") Long id); 24 | 25 | // Do a JOIN FETCH to prevent Hibernate from doing N+1 Queries... 26 | @Query("select c from Customer c left join fetch c.addresses a left join fetch a.address") 27 | List findAll(); 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/web/configuration/WebMvcConfig.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.web.configuration; 4 | 5 | import de.bytefish.multitenancy.web.interceptors.TenantNameInterceptor; 6 | import org.springframework.context.annotation.Configuration; 7 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 8 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 9 | 10 | @Configuration 11 | public class WebMvcConfig implements WebMvcConfigurer { 12 | 13 | @Override 14 | public void addInterceptors(InterceptorRegistry registry) { 15 | registry.addInterceptor(new TenantNameInterceptor()); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/web/controllers/CustomerController.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.web.controllers; 4 | 5 | import de.bytefish.multitenancy.model.Address; 6 | import de.bytefish.multitenancy.model.Customer; 7 | import de.bytefish.multitenancy.model.CustomerAddress; 8 | import de.bytefish.multitenancy.repositories.IAddressRepository; 9 | import de.bytefish.multitenancy.repositories.ICustomerAddressRepository; 10 | import de.bytefish.multitenancy.repositories.ICustomerRepository; 11 | import de.bytefish.multitenancy.web.converter.Converters; 12 | import de.bytefish.multitenancy.web.model.CustomerDto; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.web.bind.annotation.*; 15 | 16 | import java.util.List; 17 | import java.util.concurrent.ExecutionException; 18 | import java.util.stream.Collectors; 19 | 20 | @RestController 21 | public class CustomerController { 22 | 23 | private final ICustomerRepository customerRepository; 24 | private final IAddressRepository addressRepository; 25 | private final ICustomerAddressRepository customerAddressRepository; 26 | 27 | @Autowired 28 | public CustomerController( IAddressRepository addressRepository, ICustomerRepository customerRepository, ICustomerAddressRepository customerAddressRepository) { 29 | this.addressRepository = addressRepository; 30 | this.customerRepository = customerRepository; 31 | this.customerAddressRepository = customerAddressRepository; 32 | } 33 | 34 | @GetMapping("/customers") 35 | public List getAll() { 36 | Iterable customers = customerRepository.findAll(); 37 | 38 | return Converters.convert(customers); 39 | } 40 | 41 | @GetMapping("/customers/{id}") 42 | public CustomerDto get(@PathVariable("id") long id) { 43 | Customer customer = customerRepository 44 | .findById(id) 45 | .orElse(null); 46 | 47 | return Converters.convert(customer); 48 | } 49 | 50 | @GetMapping("/async/customers") 51 | public List getAllAsync() throws ExecutionException, InterruptedException { 52 | return customerRepository.findAllAsync() 53 | .thenApply(x -> Converters.convert(x)) 54 | .get(); 55 | } 56 | 57 | @PostMapping("/customers") 58 | public CustomerDto post(@RequestBody CustomerDto customerDto) { 59 | 60 | // Save the Customer: 61 | Customer customer = Converters.convert(customerDto); 62 | 63 | customerRepository.save(customer); 64 | 65 | // Create and Assign Addresses: 66 | if(customerDto.getAddresses() != null) { 67 | 68 | // First insert the Address: 69 | List
addresses = Converters.convert(customerDto.getAddresses()); 70 | 71 | addresses.forEach(addressRepository::save); 72 | 73 | // Then associate them: 74 | List customerAddresses = addresses.stream() 75 | .map(address -> new CustomerAddress(customer, address)) 76 | .collect(Collectors.toList()); 77 | 78 | customerAddresses.forEach(customerAddressRepository::save); 79 | 80 | // And set in the Customer: 81 | customer.setAddresses(customerAddresses); 82 | } 83 | 84 | // Return the DTO: 85 | return Converters.convert(customer); 86 | } 87 | 88 | @DeleteMapping("/customers/{id}") 89 | public void delete(@PathVariable("id") long id) { 90 | customerRepository.deleteById(id); 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/web/converter/Converters.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.web.converter; 4 | 5 | import de.bytefish.multitenancy.model.Address; 6 | import de.bytefish.multitenancy.model.Customer; 7 | import de.bytefish.multitenancy.web.model.AddressDto; 8 | import de.bytefish.multitenancy.web.model.CustomerDto; 9 | 10 | import java.util.List; 11 | import java.util.stream.Collectors; 12 | import java.util.stream.StreamSupport; 13 | 14 | public class Converters { 15 | 16 | private Converters() { 17 | 18 | } 19 | 20 | public static CustomerDto convert(Customer source) { 21 | if(source == null) { 22 | return null; 23 | } 24 | 25 | List addresses = null; 26 | 27 | if(source.getAddresses() != null) { 28 | addresses = source.getAddresses() 29 | .stream() 30 | .map(x -> convert(x.getAddress())) 31 | .collect(Collectors.toList()); 32 | } 33 | 34 | return new CustomerDto(source.getId(), source.getFirstName(), source.getLastName(), addresses); 35 | } 36 | 37 | public static AddressDto convert(Address source) { 38 | if(source == null) { 39 | return null; 40 | } 41 | 42 | return new AddressDto(source.getId(), source.getName(), source.getStreet(), source.getPostalcode(), source.getCity(), source.getCountry()); 43 | } 44 | 45 | public static Customer convert(CustomerDto source) { 46 | if(source == null) { 47 | return null; 48 | } 49 | 50 | Customer customer = new Customer(); 51 | 52 | customer.setFirstName(source.getFirstName()); 53 | customer.setLastName(source.getLastName()); 54 | 55 | return customer; 56 | } 57 | 58 | public static List
convert(List source) { 59 | 60 | if(source == null) { 61 | return null; 62 | } 63 | 64 | return source.stream() 65 | .map(x -> convert(x)) 66 | .collect(Collectors.toList()); 67 | } 68 | 69 | public static Address convert(AddressDto source) { 70 | 71 | if(source == null) { 72 | return null; 73 | } 74 | 75 | Address address = new Address(); 76 | 77 | address.setId(source.getId()); 78 | address.setName(source.getName()); 79 | address.setStreet(source.getStreet()); 80 | address.setPostalcode(source.getPostalcode()); 81 | address.setCity(source.getCity()); 82 | address.setCountry(source.getCountry()); 83 | 84 | return address; 85 | } 86 | 87 | public static List convert(Iterable customers) { 88 | return StreamSupport.stream(customers.spliterator(), false) 89 | .map(Converters::convert) 90 | .collect(Collectors.toList()); 91 | } 92 | } -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/web/interceptors/TenantNameInterceptor.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.web.interceptors; 4 | 5 | import de.bytefish.multitenancy.core.ThreadLocalStorage; 6 | import org.springframework.web.servlet.HandlerInterceptor; 7 | 8 | import jakarta.servlet.http.HttpServletRequest; 9 | import jakarta.servlet.http.HttpServletResponse; 10 | import org.springframework.web.servlet.ModelAndView; 11 | 12 | public class TenantNameInterceptor implements HandlerInterceptor { 13 | 14 | @Override 15 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 16 | 17 | // Implement your logic to extract the Tenant Name here. Another way would be to 18 | // parse a JWT and extract the Tenant Name from the Claims in the Token. In the 19 | // example code we are just extracting a Header value: 20 | String tenantName = request.getHeader("X-TenantID"); 21 | 22 | // Always set the Tenant Name, so we avoid leaking Tenants between Threads even in the scenario, when no 23 | // Tenant is given. I do this because if somehow the afterCompletion Handler isn't called the Tenant Name 24 | // could still be persisted within the ThreadLocal: 25 | ThreadLocalStorage.setTenantName(tenantName); 26 | 27 | return true; 28 | } 29 | 30 | @Override 31 | public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { 32 | 33 | // After completing the request, make sure to erase the Tenant from the current Thread. It's 34 | // because Spring may reuse the Thread in the Thread Pool and you don't want to leak this 35 | // information: 36 | ThreadLocalStorage.setTenantName(null); 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/web/model/AddressDto.java: -------------------------------------------------------------------------------- 1 | package de.bytefish.multitenancy.web.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import com.fasterxml.jackson.annotation.JsonProperty; 5 | 6 | public class AddressDto { 7 | 8 | private final Long id; 9 | 10 | private final String name; 11 | 12 | private final String street; 13 | 14 | private final String postalcode; 15 | 16 | private final String city; 17 | 18 | private final String country; 19 | 20 | @JsonCreator 21 | public AddressDto(@JsonProperty("id") Long id, @JsonProperty("name") String name, @JsonProperty("street") String street, @JsonProperty("postalcode") String postalcode, @JsonProperty("city") String city, @JsonProperty("country") String country) { 22 | this.id = id; 23 | this.name = name; 24 | this.street = street; 25 | this.postalcode = postalcode; 26 | this.city = city; 27 | this.country = country; 28 | } 29 | 30 | @JsonProperty("id") 31 | public Long getId() { 32 | return id; 33 | } 34 | 35 | @JsonProperty("name") 36 | public String getName() { 37 | return name; 38 | } 39 | 40 | @JsonProperty("street") 41 | public String getStreet() { 42 | return street; 43 | } 44 | 45 | @JsonProperty("postalcode") 46 | public String getPostalcode() { 47 | return postalcode; 48 | } 49 | 50 | @JsonProperty("city") 51 | public String getCity() { 52 | return city; 53 | } 54 | 55 | @JsonProperty("country") 56 | public String getCountry() { 57 | return country; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/de/bytefish/multitenancy/web/model/CustomerDto.java: -------------------------------------------------------------------------------- 1 | // Licensed under the MIT license. See LICENSE file in the project root for full license information. 2 | 3 | package de.bytefish.multitenancy.web.model; 4 | 5 | import com.fasterxml.jackson.annotation.JsonCreator; 6 | import com.fasterxml.jackson.annotation.JsonProperty; 7 | 8 | import java.util.List; 9 | 10 | public class CustomerDto { 11 | 12 | private final Long id; 13 | 14 | private final String firstName; 15 | 16 | private final String lastName; 17 | 18 | private final List addresses; 19 | 20 | @JsonCreator 21 | public CustomerDto(@JsonProperty("id") Long id, @JsonProperty("firstName") String firstName, @JsonProperty("lastName") String lastName, @JsonProperty("addresses") List addresses) { 22 | this.id = id; 23 | this.firstName = firstName; 24 | this.lastName = lastName; 25 | this.addresses = addresses; 26 | } 27 | 28 | @JsonProperty("id") 29 | public Long getId() { 30 | return id; 31 | } 32 | 33 | @JsonProperty("firstName") 34 | public String getFirstName() { 35 | return firstName; 36 | } 37 | 38 | @JsonProperty("lastName") 39 | public String getLastName() { 40 | return lastName; 41 | } 42 | 43 | @JsonProperty("addresses") 44 | public List getAddresses() { 45 | return addresses; 46 | } 47 | } -------------------------------------------------------------------------------- /src/main/resources/application-docker.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | config: 3 | activate: 4 | on-profile: docker 5 | jpa: 6 | properties: 7 | hibernate.temp.use_jdbc_metadata_defaults: false 8 | database: postgresql 9 | database-platform: org.hibernate.dialect.PostgreSQLDialect 10 | open-in-view: false 11 | datasource: 12 | initialize: false 13 | hibernate: 14 | ddl-auto: none 15 | dialect: 16 | format_sql: true 17 | naming: 18 | physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl 19 | 20 | application: 21 | tenants: 22 | - name: "tenant_a" 23 | dbUrl: "jdbc:postgresql://postgres:5432/sampledb" 24 | dbUser: "tenant_a" 25 | dbPassword: "tenant_a" 26 | - name: "tenant_b" 27 | dbUrl: "jdbc:postgresql://postgres:5432/sampledb" 28 | dbUser: "tenant_b" 29 | dbPassword: "tenant_b" 30 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | config: 3 | activate: 4 | on-profile: test 5 | jpa: 6 | properties: 7 | hibernate.temp.use_jdbc_metadata_defaults: false 8 | database: postgresql 9 | database-platform: org.hibernate.dialect.PostgreSQLDialect 10 | open-in-view: false 11 | datasource: 12 | initialize: false 13 | hibernate: 14 | ddl-auto: none 15 | dialect: 16 | format_sql: true 17 | naming: 18 | physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl 19 | 20 | application: 21 | tenants: 22 | - name: "tenant_a" 23 | dbUrl: "jdbc:postgresql://localhost:5432/sampledb" 24 | dbUser: "tenant_a" 25 | dbPassword: "tenant_a" 26 | - name: "tenant_b" 27 | dbUrl: "jdbc:postgresql://localhost:5432/sampledb" 28 | dbUser: "tenant_b" 29 | dbPassword: "tenant_b" 30 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | 22 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{60} %X{sourceThread} - %msg%n 23 | 24 | 25 | 26 | 27 | 28 | 29 | --------------------------------------------------------------------------------