├── .gitignore ├── LICENSE ├── README.md ├── docker-compose.yml └── multi-tenant-service ├── README.md ├── pom.xml └── src ├── main ├── java │ └── se │ │ └── callista │ │ └── blog │ │ └── service │ │ ├── MultiTenantServiceApplication.java │ │ ├── config │ │ ├── DataSourceConfiguration.java │ │ ├── LiquibaseConfig.java │ │ └── WebConfiguration.java │ │ ├── controller │ │ ├── AbstractBaseApiController.java │ │ ├── ApiException.java │ │ ├── ContentType.java │ │ ├── NotFoundException.java │ │ └── ProductApiController.java │ │ ├── domain │ │ └── entity │ │ │ └── Product.java │ │ ├── model │ │ ├── ErrorMessage.java │ │ └── ProductValue.java │ │ ├── multi_tenancy │ │ ├── async │ │ │ ├── AsyncConfig.java │ │ │ └── TenantAwareTaskDecorator.java │ │ ├── interceptor │ │ │ └── TenantInterceptor.java │ │ └── util │ │ │ └── TenantContext.java │ │ ├── repository │ │ └── ProductRepository.java │ │ └── services │ │ ├── ProductService.java │ │ └── ProductServiceImpl.java └── resources │ ├── application.yml │ └── db │ └── changelog │ ├── db.changelog-tenant-1.0.yaml │ └── db.changelog-tenant.yaml └── test ├── java └── se │ └── callista │ └── blog │ └── service │ ├── MultiTenantServiceApplicationTests.java │ ├── annotation │ ├── SpringBootDbIntegrationTest.java │ └── SpringBootIntegrationTest.java │ ├── controller │ └── ProductApiControllerTest.java │ ├── persistence │ └── PostgresqlTestContainer.java │ ├── repository │ └── ProductRepositoryTest.java │ └── services │ └── ProductServiceTest.java └── resources ├── application-default.yml ├── application-integration-test.yml ├── application-test.yml ├── datasets └── products.yml └── dbunit.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Maven # 2 | target/ 3 | 4 | ### Eclipse ### 5 | .apt_generated 6 | .classpath 7 | .factorypath 8 | .project 9 | .settings 10 | .springBeans 11 | .sts4-cache 12 | 13 | ### IntelliJ IDEA ### 14 | .idea/ 15 | *.iws 16 | *.iml 17 | *.ipr 18 | 19 | ### VS Code ### 20 | .vscode/ 21 | 22 | ### Other ### 23 | *.orig 24 | *.hprof 25 | lombok.config 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Multi Tenancy with Spring Boot, Hibernate & Liquibase 2 | 3 | ## Overview 4 | 5 | Multi Tenancy usually plays an important role in the business case for 6 | SAAS solutions. Spring Data and Hibernate provide out-of-the-box support 7 | for different Multi-tenancy strategies. Configuration however becomes more 8 | complicated, and the available examples are few. 9 | 10 | This project complements my blog series on Multi Tenancy 11 | (see https://callistaenterprise.se/blogg/teknik/2020/09/19/multi-tenancy-with-spring-boot-part1/), 12 | and contains working examples of different Multi Tenant strategies implemented with 13 | Spring Boot, Hibemate and Liquibase, complete with support for database 14 | migrations as well as dynamically set up new tenants on the fly. 15 | 16 | ## How to use the examples 17 | 18 | The master branch contains a common, minimal example project skeleton. The 19 | different Multi-tenancy strategy examples are in separate branches. 20 | 21 | ### Database per tenant 22 | 23 | The `database` branch implements the *Database per tenant* strategy. 24 | 25 | ### Schema per tenant 26 | 27 | The `schema` branch implements the *Schema per tenant* strategy. 28 | 29 | ### Shared Database with Discriminator, using Hibernate Filters 30 | 31 | The `shared_database_hibernate` branch implements the *Shared Database with Discriminator* 32 | strategy, using Hibernate's experimental support for discriminator-based multi-tenancy 33 | (see e.g. https://hibernate.atlassian.net/browse/HHH-6054) 34 | 35 | ### Shared Database with Discriminator, using PostgreSQL's Row Level Security 36 | 37 | The `shared_database_postgres_rls` branch implements the *Shared Database with Discriminator* 38 | strategy, using PostgreSQL's Row Level Security. 39 | 40 | ## How to start a Dockerized postgres database 41 | 42 | All the examples require a postgres database running at localhost:5432. Run the following command 43 | to use the provided `docker-compose.yml` configuration to start a dockerized postgres 44 | container: 45 | 46 | ``` 47 | docker-compose up -d 48 | ``` 49 | 50 | Close it down with the following command when done, or if you need to recreate the database: 51 | 52 | ``` 53 | docker-compose down 54 | ``` 55 | 56 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | services: 3 | postgres: 4 | image: postgres 5 | ports: 6 | - "5432:5432" 7 | restart: always 8 | environment: 9 | POSTGRES_USER: postgres 10 | POSTGRES_PASSWORD: secret 11 | POSTGRES_DB: blog 12 | healthcheck: 13 | test: ["CMD-SHELL", "pg_isready -U postgres -d blog"] 14 | interval: 10s 15 | timeout: 5s 16 | retries: 3 17 | -------------------------------------------------------------------------------- /multi-tenant-service/README.md: -------------------------------------------------------------------------------- 1 | # Multi Tenant Service 2 | 3 | ## Running the Multi Tenant Service 4 | 5 | Build the Multi Tenant Service executable: 6 | 7 | ``` 8 | mvn package 9 | ``` 10 | 11 | then start it as an simple java application: 12 | 13 | ``` 14 | java -jar target/multi-tenant-service-0-SNAPSHOT.jar 15 | ``` 16 | or via maven 17 | ``` 18 | mvn spring-boot:run 19 | ``` 20 | 21 | ## Testing the Multi Tenant Service 22 | 23 | Insert some test data for different tenants: 24 | 25 | ``` 26 | curl -H "X-TENANT-ID: tenant1" -H "Content-Type: application/se.callista.blog.service.api.product.v1_0+json" -X POST -d '{"name":"Product 1"}' localhost:8080/products 27 | curl -H "X-TENANT-ID: tenant2" -H "Content-Type: application/se.callista.blog.service.api.product.v1_0+json" -X POST -d '{"name":"Product 2"}' localhost:8080/products 28 | ``` 29 | 30 | Then query for the data, and verify that the data is properly isolated between tenants: 31 | 32 | ``` 33 | curl -H "X-TENANT-ID: tenant1" localhost:8080/products 34 | curl -H "X-TENANT-ID: tenant2" localhost:8080/products 35 | ``` 36 | 37 | ## Configuration 38 | 39 | Change default port value and other settings in src/main/resources/application.yml. 40 | -------------------------------------------------------------------------------- /multi-tenant-service/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 2.3.3.RELEASE 9 | 10 | 11 | se.callista.blog 12 | multi-tenant-service 13 | 0-SNAPSHOT 14 | multi-tenant-service 15 | Sample Multi-tenant Service 16 | 17 | 18 | 1.8 19 | 20 | 21 | 22 | 23 | org.springframework.boot 24 | spring-boot-starter-data-jpa 25 | 26 | 27 | javax.validation 28 | validation-api 29 | 2.0.1.Final 30 | 31 | 32 | org.springframework.boot 33 | spring-boot-starter-web 34 | 35 | 36 | com.google.guava 37 | guava 38 | 29.0-jre 39 | 40 | 41 | org.liquibase 42 | liquibase-core 43 | 44 | 45 | org.projectlombok 46 | lombok 47 | provided 48 | 49 | 50 | 51 | org.springframework.boot 52 | spring-boot-devtools 53 | runtime 54 | true 55 | 56 | 57 | org.postgresql 58 | postgresql 59 | runtime 60 | 61 | 62 | org.springframework.boot 63 | spring-boot-starter-test 64 | test 65 | 66 | 67 | org.junit.vintage 68 | junit-vintage-engine 69 | 70 | 71 | 72 | 73 | org.junit.jupiter 74 | junit-jupiter 75 | test 76 | 77 | 78 | org.testcontainers 79 | junit-jupiter 80 | 1.14.3 81 | test 82 | 83 | 84 | org.testcontainers 85 | postgresql 86 | 1.14.3 87 | test 88 | 89 | 90 | com.github.database-rider 91 | rider-spring 92 | 1.16.0 93 | test 94 | 95 | 96 | org.slf4j 97 | slf4j-simple 98 | 99 | 100 | 101 | 102 | com.github.database-rider 103 | rider-junit5 104 | 1.16.0 105 | test 106 | 107 | 108 | 109 | 110 | 111 | 112 | org.springframework.boot 113 | spring-boot-maven-plugin 114 | 115 | 116 | org.apache.maven.plugins 117 | maven-surefire-plugin 118 | 3.0.0-M5 119 | 120 | 121 | **/*.class 122 | 123 | integration 124 | 125 | 126 | 127 | org.apache.maven.plugins 128 | maven-failsafe-plugin 129 | 3.0.0-M5 130 | 131 | 132 | **/*.class 133 | 134 | integration 135 | 136 | 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/MultiTenantServiceApplication.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; 6 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration; 7 | import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; 8 | import org.springframework.scheduling.annotation.EnableAsync; 9 | import org.springframework.transaction.annotation.EnableTransactionManagement; 10 | 11 | @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class, LiquibaseAutoConfiguration.class }) 12 | @EnableTransactionManagement 13 | @EnableAsync 14 | public class MultiTenantServiceApplication extends SpringBootServletInitializer { 15 | 16 | public static void main(String[] args) { 17 | SpringApplication.run(MultiTenantServiceApplication.class, args); 18 | } 19 | 20 | } 21 | 22 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/config/DataSourceConfiguration.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.config; 2 | 3 | import com.zaxxer.hikari.HikariDataSource; 4 | import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; 5 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseDataSource; 6 | import org.springframework.boot.context.properties.ConfigurationProperties; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.stereotype.Component; 10 | 11 | import javax.sql.DataSource; 12 | 13 | @Component 14 | @Configuration 15 | public class DataSourceConfiguration { 16 | 17 | @Bean 18 | @ConfigurationProperties("multitenancy.master.datasource") 19 | public DataSourceProperties masterDataSourceProperties() { 20 | return new DataSourceProperties(); 21 | } 22 | 23 | @Bean 24 | @LiquibaseDataSource 25 | @ConfigurationProperties("multitenancy.master.datasource.hikari") 26 | public DataSource masterDataSource() { 27 | HikariDataSource dataSource = masterDataSourceProperties() 28 | .initializeDataSourceBuilder() 29 | .type(HikariDataSource.class) 30 | .build(); 31 | dataSource.setPoolName("masterDataSource"); 32 | return dataSource; 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/config/LiquibaseConfig.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.config; 2 | 3 | import liquibase.integration.spring.SpringLiquibase; 4 | import org.springframework.beans.factory.ObjectProvider; 5 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseDataSource; 6 | import org.springframework.boot.autoconfigure.liquibase.LiquibaseProperties; 7 | import org.springframework.boot.context.properties.ConfigurationProperties; 8 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.context.annotation.Lazy; 12 | 13 | import javax.sql.DataSource; 14 | 15 | @Lazy(false) 16 | @Configuration 17 | @EnableConfigurationProperties(LiquibaseProperties.class) 18 | public class LiquibaseConfig { 19 | 20 | @Bean 21 | @ConfigurationProperties("multitenancy.master.liquibase") 22 | public LiquibaseProperties masterLiquibaseProperties() { 23 | return new LiquibaseProperties(); 24 | } 25 | 26 | @Bean 27 | public SpringLiquibase liquibase(@LiquibaseDataSource ObjectProvider liquibaseDataSource) { 28 | LiquibaseProperties liquibaseProperties = masterLiquibaseProperties(); 29 | SpringLiquibase liquibase = new SpringLiquibase(); 30 | liquibase.setDataSource(liquibaseDataSource.getIfAvailable()); 31 | liquibase.setChangeLog(liquibaseProperties.getChangeLog()); 32 | liquibase.setContexts(liquibaseProperties.getContexts()); 33 | liquibase.setDefaultSchema(liquibaseProperties.getDefaultSchema()); 34 | liquibase.setLiquibaseSchema(liquibaseProperties.getLiquibaseSchema()); 35 | liquibase.setLiquibaseTablespace(liquibaseProperties.getLiquibaseTablespace()); 36 | liquibase.setDatabaseChangeLogTable(liquibaseProperties.getDatabaseChangeLogTable()); 37 | liquibase.setDatabaseChangeLogLockTable(liquibaseProperties.getDatabaseChangeLogLockTable()); 38 | liquibase.setDropFirst(liquibaseProperties.isDropFirst()); 39 | liquibase.setShouldRun(liquibaseProperties.isEnabled()); 40 | liquibase.setLabels(liquibaseProperties.getLabels()); 41 | liquibase.setChangeLogParameters(liquibaseProperties.getParameters()); 42 | liquibase.setRollbackFile(liquibaseProperties.getRollbackFile()); 43 | liquibase.setTestRollbackOnUpdate(liquibaseProperties.isTestRollbackOnUpdate()); 44 | return liquibase; 45 | } 46 | 47 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/config/WebConfiguration.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.config; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 7 | import se.callista.blog.service.multi_tenancy.interceptor.TenantInterceptor; 8 | 9 | @Configuration 10 | public class WebConfiguration implements WebMvcConfigurer { 11 | 12 | private final TenantInterceptor tenantInterceptor; 13 | 14 | @Autowired 15 | public WebConfiguration(TenantInterceptor tenantInterceptor) { 16 | this.tenantInterceptor = tenantInterceptor; 17 | } 18 | 19 | @Override 20 | public void addInterceptors(InterceptorRegistry registry) { 21 | registry.addWebRequestInterceptor(tenantInterceptor); 22 | } 23 | 24 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/controller/AbstractBaseApiController.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.controller; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.ExceptionHandler; 6 | import org.springframework.web.bind.annotation.RequestMapping; 7 | import org.springframework.web.bind.annotation.RestController; 8 | import org.springframework.web.context.request.ServletWebRequest; 9 | import org.springframework.web.context.request.WebRequest; 10 | import se.callista.blog.service.model.ErrorMessage; 11 | 12 | import javax.servlet.http.HttpServletRequest; 13 | 14 | @RestController 15 | @RequestMapping("/") 16 | public class AbstractBaseApiController { 17 | 18 | @ExceptionHandler(ApiException.class) 19 | public final ResponseEntity handleApiException(ApiException ex, WebRequest request) { 20 | HttpStatus status = ex.getStatus(); 21 | ErrorMessage errorDetails = 22 | ErrorMessage.builder() 23 | .timestamp(ex.getTimestamp()) 24 | .status(status.value()) 25 | .error(status.getReasonPhrase()) 26 | .message(ex.getMessage()) 27 | .build(); 28 | if (request instanceof ServletWebRequest) { 29 | ServletWebRequest servletWebRequest = (ServletWebRequest) request; 30 | HttpServletRequest servletRequest = servletWebRequest.getNativeRequest(HttpServletRequest.class); 31 | errorDetails.setPath(servletRequest.getRequestURI()); 32 | } 33 | return new ResponseEntity<>(errorDetails, status); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/controller/ApiException.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.controller; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | import java.time.OffsetDateTime; 6 | import java.time.ZoneId; 7 | 8 | public class ApiException extends RuntimeException { 9 | 10 | private static final long serialVersionUID = 1L; 11 | private static final ZoneId utc = ZoneId.of("UTC"); 12 | private final OffsetDateTime timestamp; 13 | private final HttpStatus status; 14 | 15 | public ApiException(HttpStatus status, String msg) { 16 | super(msg); 17 | this.timestamp = OffsetDateTime.now(utc); 18 | this.status = status; 19 | } 20 | 21 | public OffsetDateTime getTimestamp() { 22 | return timestamp; 23 | } 24 | 25 | public HttpStatus getStatus() { 26 | return status; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/controller/ContentType.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.controller; 2 | 3 | public final class ContentType { 4 | 5 | private ContentType() {} 6 | 7 | public static final String PRODUCT_1_0 = "application/se.callista.blog.service.api.product.v1_0+json"; 8 | public static final String PRODUCTS_1_0 = "application/se.callista.blog.service.api.products.v1_0+json"; 9 | 10 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/controller/NotFoundException.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.controller; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | public class NotFoundException extends ApiException { 6 | 7 | private static final long serialVersionUID = 1L; 8 | 9 | public NotFoundException(String msg) { 10 | super(HttpStatus.NOT_FOUND, msg); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/controller/ProductApiController.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.controller; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.http.HttpHeaders; 5 | import org.springframework.http.HttpStatus; 6 | import org.springframework.http.ResponseEntity; 7 | import org.springframework.util.LinkedMultiValueMap; 8 | import org.springframework.util.MultiValueMap; 9 | import org.springframework.web.bind.annotation.DeleteMapping; 10 | import org.springframework.web.bind.annotation.GetMapping; 11 | import org.springframework.web.bind.annotation.PathVariable; 12 | import org.springframework.web.bind.annotation.PostMapping; 13 | import org.springframework.web.bind.annotation.PutMapping; 14 | import org.springframework.web.bind.annotation.RequestBody; 15 | import org.springframework.web.bind.annotation.RequestMapping; 16 | import org.springframework.web.bind.annotation.RestController; 17 | import se.callista.blog.service.model.ProductValue; 18 | import se.callista.blog.service.services.ProductService; 19 | 20 | import javax.persistence.EntityNotFoundException; 21 | import javax.validation.Valid; 22 | import java.util.List; 23 | import java.util.concurrent.CompletableFuture; 24 | 25 | @RestController 26 | @RequestMapping("/") 27 | public class ProductApiController extends AbstractBaseApiController { 28 | 29 | private final ProductService productService; 30 | 31 | @Autowired 32 | public ProductApiController(ProductService productService) { 33 | this.productService = productService; 34 | } 35 | 36 | @GetMapping(value = "/products", produces = {ContentType.PRODUCTS_1_0}) 37 | public ResponseEntity> getProducts() { 38 | List productValues = productService.getProducts(); 39 | return new ResponseEntity<>(productValues, HttpStatus.OK); 40 | } 41 | 42 | @GetMapping(value = "/products/{productId}", produces = {ContentType.PRODUCT_1_0}) 43 | public ResponseEntity getProduct(@PathVariable("productId") long productId) { 44 | try { 45 | ProductValue branch = productService.getProduct(productId); 46 | return new ResponseEntity<>(branch, HttpStatus.OK); 47 | } catch (EntityNotFoundException e) { 48 | throw new NotFoundException(e.getMessage()); 49 | } 50 | } 51 | 52 | @PostMapping(value = "/products", 53 | consumes = {ContentType.PRODUCT_1_0}, 54 | produces = {ContentType.PRODUCT_1_0}) 55 | public ResponseEntity createProduct(@Valid @RequestBody ProductValue productValue) { 56 | ProductValue product = productService.createProduct(productValue); 57 | MultiValueMap headers = new LinkedMultiValueMap<>(); 58 | headers.add(HttpHeaders.LOCATION, "/products/" + product.getProductId()); 59 | return new ResponseEntity<>(product, headers, HttpStatus.CREATED); 60 | } 61 | 62 | @PutMapping(value = "/products/{productId}", 63 | consumes = {ContentType.PRODUCT_1_0}, 64 | produces = {ContentType.PRODUCT_1_0}) 65 | ResponseEntity updateProduct(@PathVariable long productId, @Valid @RequestBody ProductValue productValue) { 66 | productValue.setProductId(productId); 67 | try { 68 | ProductValue product = productService.updateProduct(productValue); 69 | return new ResponseEntity<>(product, HttpStatus.OK); 70 | } catch (EntityNotFoundException e) { 71 | throw new NotFoundException(e.getMessage()); 72 | } 73 | } 74 | 75 | @DeleteMapping("/products/{productId}") 76 | ResponseEntity deleteProduct(@PathVariable long productId) { 77 | try { 78 | productService.deleteProductById(productId); 79 | return new ResponseEntity<>(HttpStatus.NO_CONTENT); 80 | } catch (EntityNotFoundException e) { 81 | throw new NotFoundException(e.getMessage()); 82 | } 83 | } 84 | 85 | @GetMapping(value = "/async/products", produces = {ContentType.PRODUCTS_1_0}) 86 | public CompletableFuture>> asyncGetProducts() { 87 | List productValues = productService.getProducts(); 88 | return CompletableFuture.completedFuture(new ResponseEntity<>(productValues, HttpStatus.OK)); 89 | } 90 | 91 | @GetMapping(value = "/async/products/{productId}", produces = {ContentType.PRODUCT_1_0}) 92 | public CompletableFuture> asyncGetProduct(@PathVariable("productId") long productId) { 93 | return CompletableFuture.completedFuture(getProduct(productId)); 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/domain/entity/Product.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.domain.entity; 2 | 3 | import lombok.Builder; 4 | import lombok.Getter; 5 | import lombok.NoArgsConstructor; 6 | import lombok.Setter; 7 | 8 | import javax.persistence.Column; 9 | import javax.persistence.Entity; 10 | import javax.persistence.GeneratedValue; 11 | import javax.persistence.GenerationType; 12 | import javax.persistence.Id; 13 | import javax.persistence.SequenceGenerator; 14 | import javax.persistence.Table; 15 | import javax.persistence.Version; 16 | import javax.validation.constraints.NotNull; 17 | import javax.validation.constraints.Size; 18 | 19 | @Entity 20 | @Table(name = "product") 21 | @Getter 22 | @Setter 23 | @NoArgsConstructor 24 | @Builder 25 | public class Product { 26 | 27 | @Builder 28 | public Product(Long id, String name, Integer version) { 29 | this.id = id; 30 | this.name = name; 31 | this.version = version; 32 | } 33 | 34 | @Id 35 | @Column(name = "id", unique = true, nullable = false, updatable = false) 36 | @SequenceGenerator(name="product_seq", sequenceName="product_seq", allocationSize=50) 37 | @GeneratedValue(strategy = GenerationType.SEQUENCE, generator="product_seq") 38 | protected Long id; 39 | 40 | @Column(name = "name", length = 255, nullable = false) 41 | @NotNull 42 | @Size(max = 255) 43 | private String name; 44 | 45 | @Version 46 | @Column(name = "version", nullable = false, columnDefinition = "int default 0") 47 | protected Integer version; 48 | 49 | } 50 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/model/ErrorMessage.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | 9 | import javax.validation.constraints.NotNull; 10 | import javax.validation.constraints.Size; 11 | import java.time.OffsetDateTime; 12 | 13 | /** 14 | * A message containing more info why an operation failed. 15 | */ 16 | @Data 17 | @NoArgsConstructor 18 | @AllArgsConstructor 19 | @Builder 20 | public class ErrorMessage { 21 | 22 | @JsonProperty("timestamp") 23 | @NotNull 24 | private OffsetDateTime timestamp; 25 | 26 | @JsonProperty("status") 27 | @NotNull 28 | private Integer status; 29 | 30 | @JsonProperty("error") 31 | @NotNull 32 | private String error; 33 | 34 | @JsonProperty("message") 35 | @NotNull 36 | @Size(max=255) 37 | private String message; 38 | 39 | @JsonProperty("path") 40 | private String path; 41 | 42 | } 43 | 44 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/model/ProductValue.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.model; 2 | 3 | import com.fasterxml.jackson.annotation.JsonProperty; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Builder; 6 | import lombok.Data; 7 | import lombok.NoArgsConstructor; 8 | import se.callista.blog.service.domain.entity.Product; 9 | 10 | import javax.validation.constraints.NotNull; 11 | import javax.validation.constraints.Size; 12 | 13 | @Data 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | @Builder 17 | public class ProductValue { 18 | 19 | @JsonProperty("productId") 20 | private Long productId; 21 | 22 | @NotNull 23 | @Size(max = 255) 24 | @JsonProperty("name") 25 | private String name; 26 | 27 | public static ProductValue fromEntity(Product product) { 28 | return ProductValue.builder() 29 | .productId(product.getId()) 30 | .name(product.getName()) 31 | .build(); 32 | } 33 | 34 | public static Product fromValue(ProductValue product) { 35 | return Product.builder() 36 | .id(product.getProductId()) 37 | .name(product.getName()) 38 | .build(); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/async/AsyncConfig.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.multi_tenancy.async; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.scheduling.annotation.AsyncConfigurerSupport; 5 | import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; 6 | 7 | import java.util.concurrent.Executor; 8 | 9 | @Configuration 10 | public class AsyncConfig extends AsyncConfigurerSupport { 11 | 12 | @Override 13 | public Executor getAsyncExecutor() { 14 | ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); 15 | 16 | executor.setCorePoolSize(7); 17 | executor.setMaxPoolSize(42); 18 | executor.setQueueCapacity(11); 19 | executor.setThreadNamePrefix("TenantAwareTaskExecutor-"); 20 | executor.setTaskDecorator(new TenantAwareTaskDecorator()); 21 | executor.initialize(); 22 | 23 | return executor; 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/async/TenantAwareTaskDecorator.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.multi_tenancy.async; 2 | 3 | import org.springframework.core.task.TaskDecorator; 4 | import org.springframework.lang.NonNull; 5 | import se.callista.blog.service.multi_tenancy.util.TenantContext; 6 | 7 | public class TenantAwareTaskDecorator implements TaskDecorator { 8 | 9 | @Override 10 | @NonNull 11 | public Runnable decorate(@NonNull Runnable runnable) { 12 | String tenantId = TenantContext.getTenantId(); 13 | return () -> { 14 | try { 15 | TenantContext.setTenantId(tenantId); 16 | runnable.run(); 17 | } finally { 18 | TenantContext.setTenantId(null); 19 | } 20 | }; 21 | } 22 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/interceptor/TenantInterceptor.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.multi_tenancy.interceptor; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.lang.NonNull; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.ui.ModelMap; 8 | import org.springframework.web.context.request.ServletWebRequest; 9 | import org.springframework.web.context.request.WebRequest; 10 | import org.springframework.web.context.request.WebRequestInterceptor; 11 | import se.callista.blog.service.multi_tenancy.util.TenantContext; 12 | 13 | @Component 14 | public class TenantInterceptor implements WebRequestInterceptor { 15 | 16 | private final String defaultTenant; 17 | 18 | @Autowired 19 | public TenantInterceptor( 20 | @Value("${multitenancy.tenant.default-tenant:#{null}}") String defaultTenant) { 21 | this.defaultTenant = defaultTenant; 22 | } 23 | 24 | @Override 25 | public void preHandle(WebRequest request) { 26 | String tenantId; 27 | if (request.getHeader("X-TENANT-ID") != null) { 28 | tenantId = request.getHeader("X-TENANT-ID"); 29 | } else if (this.defaultTenant != null) { 30 | tenantId = this.defaultTenant; 31 | } else { 32 | tenantId = ((ServletWebRequest)request).getRequest().getServerName().split("\\.")[0]; 33 | } 34 | TenantContext.setTenantId(tenantId); 35 | } 36 | 37 | @Override 38 | public void postHandle(@NonNull WebRequest request, ModelMap model) { 39 | TenantContext.clear(); 40 | } 41 | 42 | @Override 43 | public void afterCompletion(@NonNull WebRequest request, Exception ex) { 44 | // NOOP 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/multi_tenancy/util/TenantContext.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.multi_tenancy.util; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | 5 | @Slf4j 6 | public final class TenantContext { 7 | 8 | private TenantContext() {} 9 | 10 | private static final InheritableThreadLocal currentTenant = new InheritableThreadLocal<>(); 11 | 12 | public static void setTenantId(String tenantId) { 13 | log.debug("Setting tenantId to " + tenantId); 14 | currentTenant.set(tenantId); 15 | } 16 | 17 | public static String getTenantId() { 18 | return currentTenant.get(); 19 | } 20 | 21 | public static void clear(){ 22 | currentTenant.remove(); 23 | } 24 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/repository/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.repository; 2 | 3 | import org.springframework.data.repository.CrudRepository; 4 | import se.callista.blog.service.domain.entity.Product; 5 | 6 | public interface ProductRepository extends CrudRepository { 7 | 8 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/services/ProductService.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.services; 2 | 3 | 4 | import se.callista.blog.service.model.ProductValue; 5 | 6 | import java.util.List; 7 | 8 | public interface ProductService { 9 | 10 | List getProducts(); 11 | 12 | ProductValue getProduct(long productId); 13 | 14 | ProductValue createProduct(ProductValue productValue); 15 | 16 | ProductValue updateProduct(ProductValue productValue); 17 | 18 | void deleteProductById(long productId); 19 | } 20 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/java/se/callista/blog/service/services/ProductServiceImpl.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.services; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.stereotype.Component; 5 | import org.springframework.transaction.annotation.Transactional; 6 | import se.callista.blog.service.domain.entity.Product; 7 | import se.callista.blog.service.model.ProductValue; 8 | import se.callista.blog.service.repository.ProductRepository; 9 | 10 | import javax.persistence.EntityNotFoundException; 11 | import java.util.List; 12 | import java.util.stream.Collectors; 13 | import java.util.stream.StreamSupport; 14 | 15 | @Component 16 | public class ProductServiceImpl implements ProductService { 17 | 18 | private final ProductRepository productRepository; 19 | 20 | @Autowired 21 | public ProductServiceImpl(ProductRepository productRepository) { 22 | this.productRepository = productRepository; 23 | } 24 | 25 | @Override 26 | @Transactional(readOnly = true) 27 | public List getProducts() { 28 | return StreamSupport.stream(productRepository.findAll().spliterator(), false) 29 | .map(ProductValue::fromEntity) 30 | .collect(Collectors.toList()); 31 | } 32 | 33 | @Override 34 | @Transactional(readOnly = true) 35 | public ProductValue getProduct(long productId) { 36 | return productRepository.findById(productId) 37 | .map(ProductValue::fromEntity) 38 | .orElseThrow(() -> new EntityNotFoundException("Product " + productId + " not found")); 39 | } 40 | 41 | @Override 42 | @Transactional 43 | public ProductValue createProduct(ProductValue productValue) { 44 | Product product = Product.builder() 45 | .name(productValue.getName()) 46 | .build(); 47 | product = productRepository.save(product); 48 | return ProductValue.fromEntity(product); 49 | } 50 | 51 | @Override 52 | @Transactional 53 | public ProductValue updateProduct(ProductValue productValue) { 54 | Product product = productRepository.findById(productValue.getProductId()) 55 | .orElseThrow(() -> new EntityNotFoundException("Product " + productValue.getProductId() + " not found")); 56 | product.setName(productValue.getName()); 57 | return ProductValue.fromEntity(product); 58 | } 59 | 60 | @Override 61 | @Transactional 62 | public void deleteProductById(long productId) { 63 | Product product = productRepository.findById(productId) 64 | .orElseThrow(() -> new EntityNotFoundException("Product " + productId + " not found")); 65 | productRepository.delete(product); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: 8080 3 | spring: 4 | jpa: 5 | properties: 6 | hibernate: 7 | dialect: org.hibernate.dialect.PostgreSQLDialect 8 | hibernate: 9 | ddl-auto: none 10 | open-in-view: false 11 | multitenancy: 12 | master: 13 | datasource: 14 | url: jdbc:postgresql://localhost:5432/blog 15 | username: postgres 16 | password: secret 17 | liquibase: 18 | enabled: true 19 | changeLog: classpath:db/changelog/db.changelog-tenant.yaml 20 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/resources/db/changelog/db.changelog-tenant-1.0.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | 3 | - changeSet: 4 | id: product 5 | author: bjobes 6 | changes: 7 | - createSequence: 8 | sequenceName: product_seq 9 | startValue: 100000 10 | incrementBy: 50 11 | - createTable: 12 | tableName: product 13 | columns: 14 | - column: 15 | name: id 16 | type: BIGINT 17 | constraints: 18 | primaryKey: true 19 | primaryKeyName: branch_pkey 20 | - column: 21 | name: version 22 | type: INTEGER 23 | constraints: 24 | nullable: false 25 | defaultValueNumeric: 0 26 | - column: 27 | name: name 28 | type: VARCHAR(255) 29 | constraints: 30 | nullable: false 31 | -------------------------------------------------------------------------------- /multi-tenant-service/src/main/resources/db/changelog/db.changelog-tenant.yaml: -------------------------------------------------------------------------------- 1 | databaseChangeLog: 2 | - include: 3 | file: db/changelog/db.changelog-tenant-1.0.yaml 4 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/MultiTenantServiceApplicationTests.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.testcontainers.junit.jupiter.Container; 5 | import org.testcontainers.junit.jupiter.Testcontainers; 6 | import se.callista.blog.service.annotation.SpringBootIntegrationTest; 7 | import se.callista.blog.service.persistence.PostgresqlTestContainer; 8 | 9 | @Testcontainers 10 | @SpringBootIntegrationTest 11 | class MultiTenantServiceApplicationTests { 12 | 13 | @Container 14 | private static final PostgresqlTestContainer POSTGRESQL_CONTAINER = PostgresqlTestContainer.getInstance(); 15 | 16 | @Test 17 | void contextLoads() { 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/annotation/SpringBootDbIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.annotation; 2 | 3 | import com.github.database.rider.junit5.api.DBRider; 4 | 5 | import java.lang.annotation.ElementType; 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.RetentionPolicy; 8 | import java.lang.annotation.Target; 9 | 10 | @SpringBootIntegrationTest 11 | @DBRider(dataSourceBeanName = "masterDataSource") 12 | @Retention(RetentionPolicy.RUNTIME) 13 | @Target(ElementType.TYPE) 14 | public @interface SpringBootDbIntegrationTest { 15 | } 16 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/annotation/SpringBootIntegrationTest.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.annotation; 2 | 3 | import org.junit.jupiter.api.Tag; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | import org.springframework.test.context.ActiveProfiles; 6 | 7 | import java.lang.annotation.ElementType; 8 | import java.lang.annotation.Retention; 9 | import java.lang.annotation.RetentionPolicy; 10 | import java.lang.annotation.Target; 11 | 12 | @SpringBootTest 13 | @Tag("integration") 14 | @ActiveProfiles("integration-test") 15 | @Retention(RetentionPolicy.RUNTIME) 16 | @Target(ElementType.TYPE) 17 | public @interface SpringBootIntegrationTest { 18 | } 19 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/controller/ProductApiControllerTest.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.controller; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; 7 | import org.springframework.boot.test.mock.mockito.MockBean; 8 | import org.springframework.context.annotation.Import; 9 | import org.springframework.test.web.servlet.MockMvc; 10 | import org.springframework.test.web.servlet.MvcResult; 11 | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; 12 | import se.callista.blog.service.model.ProductValue; 13 | import se.callista.blog.service.multi_tenancy.interceptor.TenantInterceptor; 14 | import se.callista.blog.service.services.ProductService; 15 | 16 | import static org.hamcrest.Matchers.is; 17 | import static org.mockito.BDDMockito.given; 18 | import static org.mockito.Mockito.verify; 19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; 20 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; 22 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 23 | 24 | @WebMvcTest(ProductApiController.class) 25 | @Import(TenantInterceptor.class) 26 | class ProductApiControllerTest { 27 | 28 | @Autowired 29 | private MockMvc mvc; 30 | @Autowired 31 | private ObjectMapper objectMapper; 32 | 33 | @MockBean 34 | private ProductService productService; 35 | 36 | @Test 37 | void getProduct() throws Exception { 38 | 39 | ProductValue product = ProductValue.builder() 40 | .productId(1L) 41 | .name("Product Name") 42 | .build(); 43 | 44 | given(productService.getProduct(product.getProductId())).willReturn(product); 45 | 46 | mvc.perform(MockMvcRequestBuilders.get("/products/" + product.getProductId())) 47 | .andExpect(status().isOk()) 48 | .andExpect(jsonPath("$.name", is(product.getName()))); 49 | 50 | verify(productService).getProduct(product.getProductId()); 51 | } 52 | 53 | @Test 54 | void asyncGetProduct() throws Exception { 55 | 56 | ProductValue product = ProductValue.builder() 57 | .productId(1L) 58 | .name("Product Name") 59 | .build(); 60 | 61 | given(productService.getProduct(product.getProductId())).willReturn(product); 62 | 63 | MvcResult mvcResult = mvc.perform(MockMvcRequestBuilders.get("/async/products/" + product.getProductId())) 64 | .andExpect(request().asyncStarted()) 65 | .andReturn(); 66 | 67 | mvc.perform(asyncDispatch(mvcResult)) 68 | .andExpect(status().isOk()) 69 | .andExpect(jsonPath("$.name", is(product.getName()))); 70 | 71 | verify(productService).getProduct(product.getProductId()); 72 | } 73 | 74 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/persistence/PostgresqlTestContainer.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.persistence; 2 | 3 | import org.testcontainers.containers.PostgreSQLContainer; 4 | 5 | public class PostgresqlTestContainer extends PostgreSQLContainer { 6 | private static final String IMAGE_VERSION = "postgres:11.5"; 7 | private static PostgresqlTestContainer container; 8 | 9 | private PostgresqlTestContainer() { 10 | super(IMAGE_VERSION); 11 | } 12 | 13 | public static PostgresqlTestContainer getInstance() { 14 | if (container == null) { 15 | container = new PostgresqlTestContainer(); 16 | } 17 | return container; 18 | } 19 | 20 | @Override 21 | public void start() { 22 | super.start(); 23 | System.setProperty("DB_NAME", container.getDatabaseName()); 24 | System.setProperty("DB_HOST", container.getContainerIpAddress() + ":" + container.getMappedPort(POSTGRESQL_PORT)); 25 | System.setProperty("DB_URL", container.getJdbcUrl()); 26 | System.setProperty("DB_USERNAME", container.getUsername()); 27 | System.setProperty("DB_PASSWORD", container.getPassword()); 28 | } 29 | 30 | @Override 31 | public void stop() { 32 | //do nothing, JVM handles shut down 33 | } 34 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/repository/ProductRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.repository; 2 | 3 | import com.github.database.rider.core.api.dataset.DataSet; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.testcontainers.junit.jupiter.Container; 7 | import org.testcontainers.junit.jupiter.Testcontainers; 8 | import se.callista.blog.service.annotation.SpringBootDbIntegrationTest; 9 | import se.callista.blog.service.domain.entity.Product; 10 | import se.callista.blog.service.multi_tenancy.util.TenantContext; 11 | import se.callista.blog.service.persistence.PostgresqlTestContainer; 12 | 13 | import java.util.Optional; 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | 17 | @Testcontainers 18 | @SpringBootDbIntegrationTest 19 | class ProductRepositoryTest { 20 | 21 | @Container 22 | private static final PostgresqlTestContainer POSTGRESQL_CONTAINER = PostgresqlTestContainer.getInstance(); 23 | 24 | @Autowired 25 | private ProductRepository productRepository; 26 | 27 | @Test 28 | @DataSet(value = {"products.yml"}) 29 | public void findByIdForTenant1() { 30 | 31 | TenantContext.setTenantId("tenant1"); 32 | Optional product = productRepository.findById(1L); 33 | assertThat(product).isPresent(); 34 | assertThat(product.get().getName()).isEqualTo("Product 1"); 35 | TenantContext.clear(); 36 | 37 | } 38 | 39 | @Test 40 | @DataSet(value = {"products.yml"}) 41 | public void findByIdForTenant2() { 42 | 43 | TenantContext.setTenantId("tenant2"); 44 | assertThat(productRepository.findById(1L)).isNotPresent(); 45 | TenantContext.clear(); 46 | 47 | } 48 | 49 | } -------------------------------------------------------------------------------- /multi-tenant-service/src/test/java/se/callista/blog/service/services/ProductServiceTest.java: -------------------------------------------------------------------------------- 1 | package se.callista.blog.service.services; 2 | 3 | import com.github.database.rider.core.api.dataset.DataSet; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.testcontainers.junit.jupiter.Container; 7 | import org.testcontainers.junit.jupiter.Testcontainers; 8 | import se.callista.blog.service.annotation.SpringBootDbIntegrationTest; 9 | import se.callista.blog.service.model.ProductValue; 10 | import se.callista.blog.service.multi_tenancy.util.TenantContext; 11 | import se.callista.blog.service.persistence.PostgresqlTestContainer; 12 | 13 | import javax.persistence.EntityNotFoundException; 14 | 15 | import static org.assertj.core.api.Assertions.assertThat; 16 | import static org.junit.jupiter.api.Assertions.assertThrows; 17 | 18 | @Testcontainers 19 | @SpringBootDbIntegrationTest 20 | public class ProductServiceTest { 21 | 22 | @Container 23 | private static final PostgresqlTestContainer POSTGRESQL_CONTAINER = PostgresqlTestContainer.getInstance(); 24 | 25 | @Autowired 26 | private ProductService productService; 27 | 28 | @Test 29 | @DataSet(value = {"products.yml"}) 30 | public void getProductForTenant1() { 31 | 32 | TenantContext.setTenantId("tenant1"); 33 | ProductValue product = productService.getProduct(1); 34 | assertThat(product.getName()).isEqualTo("Product 1"); 35 | TenantContext.clear(); 36 | 37 | } 38 | 39 | @Test 40 | @DataSet(value = {"products.yml"}) 41 | public void getProductForTenant2() { 42 | 43 | TenantContext.setTenantId("tenant2"); 44 | assertThrows(EntityNotFoundException.class, () -> productService.getProduct(1)); 45 | TenantContext.clear(); 46 | 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/resources/application-default.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | profiles: 3 | active: test -------------------------------------------------------------------------------- /multi-tenant-service/src/test/resources/application-integration-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | lazy-initialization: true 4 | banner-mode: "off" 5 | multitenancy: 6 | master: 7 | datasource: 8 | url: ${DB_URL} 9 | username: ${DB_USERNAME} 10 | password: ${DB_PASSWORD} 11 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | lazy-initialization: true 4 | banner-mode: "off" 5 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/resources/datasets/products.yml: -------------------------------------------------------------------------------- 1 | product: 2 | - id: 1 3 | name: Product 1 4 | - id: 2 5 | name: Product 2 6 | -------------------------------------------------------------------------------- /multi-tenant-service/src/test/resources/dbunit.yml: -------------------------------------------------------------------------------- 1 | properties: 2 | caseSensitiveTableNames: true 3 | datatypeFactory: !!org.dbunit.ext.postgresql.PostgresqlDataTypeFactory {} 4 | --------------------------------------------------------------------------------