Update November 2023
The original version of this post was published on August 2, 2022 using Spring Boot 2.7. The article has been updated for Spring Boot 3.
In this article I will present a sample multi-tenant Spring Boot app. The article is part of a four-part blog series about the implementation of a multi-tenant SaaS solution. If you haven't read the previous two parts of this series, you can find them here:
- Part 1: How to design a multi-tenant SaaS solution
- Part 2: How to build a multi-tenant SaaS solution with Spring
I will show you how you can easily build your own multi-tenant application without the need to write the code that was presented in part 2. This way, you can fully focus on implementing your business logic instead of worrying about building up the base structure of a multi-tenant application.
Table of Contents
Libraries & Tools
We will use the following libraries and tools for the implementation of this sample app:
Project Setup
Our application will demonstrate the request flow in a multi-tenant environment by resolving the tenant from each incoming HTTP request, establishing a connection to the tenant's data source and managing multiple concurrent DB connections that are isolated between different tenants.
The main dependency is Quantics' multitenant-oauth2-spring-boot-starter
library. It comes with a range of configuration options for your multi-tenant app and includes all code as presented in part 2.
1<dependencies>2 <dependency>3 <groupId>io.quantics</groupId>4 <artifactId>multitenant-oauth2-spring-boot-starter</artifactId>5 <version>0.4.0</version>6 </dependency>7 <dependency>8 <groupId>org.springframework.boot</groupId>9 <artifactId>spring-boot-starter-data-jpa</artifactId>10 </dependency>11 <dependency>12 <groupId>org.postgresql</groupId>13 <artifactId>postgresql</artifactId>14 <scope>runtime</scope>15 </dependency>16</dependencies>
Connecting the DB
As described in part 1, there are mainly three approaches to separate the access of tenants in a multi-tenant application. For this sample app, we are using the schema per tenant approach. We will use a shared database with a separate schema per tenant to store the individual tenants' data.
DB Setup
Let's set up our multi-tenant DB and connect our application to it. We need to add the following configuration to our application.properties
file:
1spring.datasource.url=jdbc:postgresql://localhost:5432/multitenant2spring.datasource.username=user3spring.datasource.password=pw4spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
Hibernate Config
In order to route each incoming request to the correct tenant schema in the DB, we need to resolve the current tenant ID from the request. By using the multitenant-oauth2-spring-boot-starter
library, the TenantContext
is automatically populated with the current tenant ID. All we need to do is look it up from there.
1/**2 * Resolver for translating the current tenant-id into the schema to be used for the data source.3 */4@Component5public class CurrentTenantResolver implements CurrentTenantIdentifierResolver {67 public static final String DEFAULT_SCHEMA = "public";89 @Override10 public String resolveCurrentTenantIdentifier() {11 return TenantContext.getTenantId() != null12 ? TenantContext.getTenantId()13 : DEFAULT_SCHEMA;14 }1516 @Override17 public boolean validateExistingCurrentSessions() {18 return true;19 }2021}
To allow concurrent DB access for multiple tenants, we need to implement a Hibernate connection provider that takes care of assigning a tenant-specific connection to each incoming request which accesses the DB.
1/**2 * Provider that provides tenant-specific connection handling in a multi-tenant application. The tenant3 * distinction is realized by using separate schemas, i.e. each tenant uses its own schema in a shared4 * (common) database.5 */6@Component7@Slf4j8public class MultiTenantSchemaConnectionProvider implements MultiTenantConnectionProvider {910 private final DataSource dataSource;1112 @Autowired13 public MultiTenantSchemaConnectionProvider(DataSource dataSource) {14 this.dataSource = dataSource;15 }1617 @Override18 public Connection getAnyConnection() throws SQLException {19 return dataSource.getConnection();20 }2122 @Override23 public void releaseAnyConnection(Connection connection) throws SQLException {24 connection.close();25 }2627 @Override28 public Connection getConnection(String tenantIdentifier) throws SQLException {29 log.trace("Get connection for tenant '{}'", tenantIdentifier);30 final Connection connection = getAnyConnection();31 connection.setSchema(tenantIdentifier);32 return connection;33 }3435 @Override36 public void releaseConnection(String tenantIdentifier, Connection connection) throws SQLException {37 log.trace("Release connection for tenant '{}'", tenantIdentifier);38 connection.setSchema(CurrentTenantResolver.DEFAULT_SCHEMA);39 releaseAnyConnection(connection);40 }4142 @Override43 public boolean supportsAggressiveRelease() {44 return false;45 }4647 @Override48 public boolean isUnwrappableAs(Class unwrapType) {49 return false;50 }5152 @Override53 public <T> T unwrap(Class<T> unwrapType) {54 return null;55 }5657}
The final step in setting up the DB configuration is to create an Entity Manager Factory Bean that uses the previously configured CurrentTenantResolver
and MultiTenantSchemaConnectionProvider
.
1/**2 * Configures an EntityManagerFactoryBean for a schema-based multi-tenant data source.3 */4@Configuration5public class MultiTenantSchemaHibernateConfiguration {67 private final JpaProperties jpaProperties;89 @Autowired10 public MultiTenantSchemaHibernateConfiguration(JpaProperties jpaProperties) {11 this.jpaProperties = jpaProperties;12 }1314 @Bean15 JpaVendorAdapter jpaVendorAdapter() {16 return new HibernateJpaVendorAdapter();17 }1819 @Bean20 LocalContainerEntityManagerFactoryBean entityManagerFactory(21 DataSource dataSource,22 MultiTenantConnectionProvider multiTenantConnectionProvider,23 CurrentTenantIdentifierResolver tenantIdentifierResolver) {2425 LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();26 em.setDataSource(dataSource);27 em.setPackagesToScan(MultiTenantApplication.class.getPackage().getName());28 em.setJpaVendorAdapter(this.jpaVendorAdapter());2930 Map<String, Object> jpaPropertiesMap = new HashMap<>(jpaProperties.getProperties());31 jpaPropertiesMap.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER, multiTenantConnectionProvider);32 jpaPropertiesMap.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER, tenantIdentifierResolver);33 em.setJpaPropertyMap(jpaPropertiesMap);3435 return em;36 }3738}
Tenant
JPA Entity
After setting up the DB configuration, we create the Tenant
JPA entity as well as the corresponding table in the default public
schema.
1@Getter2@NoArgsConstructor(access = AccessLevel.PROTECTED)3@ToString4@Entity5@Table(name = "tenant", schema = CurrentTenantResolver.DEFAULT_SCHEMA)6public class Tenant implements TenantSchemaDetails {78 @Id9 @Column(name = "id", updatable = false, nullable = false, unique = true)10 private String id;1112 @NotNull13 @Column(name = "name", nullable = false, unique = true)14 private String name;1516 @NotNull17 @Column(name = "schema", nullable = false, unique = true)18 private String schema;1920 @NotNull21 @Column(name = "issuer", nullable = false, unique = true)22 private String issuer;2324 @Override25 public String getJwkSetUrl() {26 return this.issuer + "/protocol/openid-connect/certs";27 }2829}
Creating the tenant
DB table
1CREATE TABLE tenant (2 "id" varchar NOT NULL3 CONSTRAINT tenant_pk4 PRIMARY KEY,5 "name" varchar NOT NULL6 CONSTRAINT tenant_name_uc7 UNIQUE,8 "schema" varchar NOT NULL9 CONSTRAINT tenant_schema_uc10 UNIQUE,11 "issuer" varchar NOT NULL12 CONSTRAINT tenant_issuer_uc13 UNIQUE14);
Business Logic
To demonstrate how we can protect our resources from unwanted access, we create a sample User
entity. Each tenant has a collection of their own users and users are only allowed to see the existing users of their own tenant.
User
JPA Entity
1@Getter2@NoArgsConstructor(access = AccessLevel.PROTECTED)3@ToString4@Entity5@Table(name = "usr")6public class User {78 @Id9 @NotNull10 @Column(name = "name", updatable = false, nullable = false, unique = true)11 private String name;1213}
UserRepository
1@Repository2interface UserRepository extends CrudRepository<User, String> {3}
UserService
1@Service2public class UserService {34 private final UserRepository repository;56 @Autowired7 public UserService(UserRepository repository) {8 this.repository = repository;9 }1011 public Optional<User> getByName(String name) {12 return repository.findById(name);13 }1415 public Iterable<User> getAll() {16 return repository.findAll();17 }1819}
UserController
1@RestController2@RequestMapping("/users")3public class UserController {45 private final UserService service;67 @Autowired8 public UserController(UserService service) {9 this.service = service;10 }1112 @GetMapping13 public Iterable<User> getAll() {14 return service.getAll();15 }1617}
Testing
Now that we've set up the essential parts of our multi-tenant app, we can test it to ensure that everything is working as expected. For testing purposes, we will use a simple HTTP header to look up the tenant ID. Note that this configuration should not be used in production since it does not involve any authorization checks. In a real-world application, the tenant should rather be extracted from a token such as a JSON Web Token (JWT) to ensure that only authorized users can access the resources provided by our API.
Keep an eye out for the release of part 4 (Update: released here), where I will present some tips and tricks on how to make your multi-tenant application production-ready.
Let's add the following configuration to our application.properties
file:
1spring.security.oauth2.resourceserver.multitenant.enabled=true2spring.security.oauth2.resourceserver.multitenant.resolve-mode=header3spring.security.oauth2.resourceserver.multitenant.header.header-name=X-TENANT-ID
We add 2 tenants to our tenant DB and create a separate schema for each:
1INSERT INTO tenant2VALUES ('tenant1', 'Tenant 1', 'tenant1', 'https://idp.example.org/tenant-1'),3 ('tenant2', 'Tenant 2', 'tenant2', 'https://idp.example.org/tenant-2');45CREATE SCHEMA "tenant1";6CREATE SCHEMA "tenant2";
In addition, let's create the usr
tables for each tenant and add some test data:
1CREATE TABLE tenant1.usr ("name" varchar PRIMARY KEY);2CREATE TABLE tenant2.usr ("name" varchar PRIMARY KEY);34INSERT INTO tenant1.usr VALUES ('alice');5INSERT INTO tenant1.usr VALUES ('alex');67INSERT INTO tenant2.usr VALUES ('bob');8INSERT INTO tenant2.usr VALUES ('bella');
Now we can test the behavior of our app by sending different HTTP requests to our /users
API:
X-TENANT-ID
= tenant1: Should return alice and alexX-TENANT-ID
= tenant2: Should return bob and bellaX-TENANT-ID
= unknown-tenant: Should return 401 (Unauthorized)- No
X-TENANT-ID
header: Should return 401 (Unauthorized)
The following test cases verify the correct behavior of our application:
1@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)2@ActiveProfiles("test")3@AutoConfigureMockMvc4@Sql(scripts = { "/create-tenants.sql", "/insert-data.sql" }, executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)5@Sql(scripts = { "/delete-data.sql" }, executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)6public class MultiTenantHeaderApplicationTests {78 @Autowired9 private MockMvc mvc;1011 @Test12 void tenant1() throws Exception {13 sendRequest("tenant1")14 .andExpect(status().isOk())15 .andExpect(content().string(containsString("alice")))16 .andExpect(content().string(containsString("alex")));17 }1819 @Test20 void tenant2() throws Exception {21 sendRequest("tenant2")22 .andExpect(status().isOk())23 .andExpect(content().string(containsString("bob")))24 .andExpect(content().string(containsString("bella")));25 }2627 @Test28 void unknownTenant() throws Exception {29 sendRequest("unknown-tenant")30 .andExpect(status().isUnauthorized());31 }3233 @Test34 void noTenant() throws Exception {35 sendRequest("")36 .andExpect(status().isUnauthorized());37 }3839 private ResultActions sendRequest(String tenantId) throws Exception {40 return mvc.perform(get("/users").with(tenantHeader(tenantId)));41 }4243 private static TenantHeaderRequestPostProcessor tenantHeader(String token) {44 return new TenantHeaderRequestPostProcessor(token);45 }4647 private static class TenantHeaderRequestPostProcessor implements RequestPostProcessor {4849 private final String tenantId;5051 TenantHeaderRequestPostProcessor(String tenantId) {52 this.tenantId = tenantId;53 }5455 @Override56 @NonNull57 public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) {58 request.addHeader("X-TENANT-ID", tenantId);59 return request;60 }6162 }6364}
If we implemented everything correctly, all tests should pass. This means that we have successfully set up a fully working multi-tenant application with Spring Boot.
Conclusion
In this article we have seen how to build a multi-tenant application with Spring Boot, JPA and Hibernate. Using Quantics' multitenant-oauth2-spring-boot-starter
library makes it really easy to create your app without having to build up the base structure of a multi-tenant application (e.g., resolving the tenant from each request, etc.). It includes all necessary building blocks for creating a production-ready Spring Boot application.
Since the goal of this article was to show how simple and easy it is to build your own multi-tenant app, we did not look at security aspects of our solution. However, if you are interested in building a production-ready application using best-in-class security, I will show you in the next part of this series how you can make your multi-tenant app production-ready.
You can find the code from this article on GitHub.
Update: The final part of this series (part 4) is available here.
References
- Code on GitHub: https://github.com/jomatt/spring-boot-multitenant-sample
- Multi-tenant OAuth2 Spring Boot starter library: https://github.com/quantics-io/multitenant-oauth2-spring-boot-starter