Navigate back to the homepage

Building a multi-tenant SaaS solution: A Spring Boot sample app

Johannes Matt
November 28th, 2023 · 3 min read

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:

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/multitenant
2spring.datasource.username=user
3spring.datasource.password=pw
4spring.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@Component
5public class CurrentTenantResolver implements CurrentTenantIdentifierResolver {
6
7 public static final String DEFAULT_SCHEMA = "public";
8
9 @Override
10 public String resolveCurrentTenantIdentifier() {
11 return TenantContext.getTenantId() != null
12 ? TenantContext.getTenantId()
13 : DEFAULT_SCHEMA;
14 }
15
16 @Override
17 public boolean validateExistingCurrentSessions() {
18 return true;
19 }
20
21}

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 tenant
3 * distinction is realized by using separate schemas, i.e. each tenant uses its own schema in a shared
4 * (common) database.
5 */
6@Component
7@Slf4j
8public class MultiTenantSchemaConnectionProvider implements MultiTenantConnectionProvider {
9
10 private final DataSource dataSource;
11
12 @Autowired
13 public MultiTenantSchemaConnectionProvider(DataSource dataSource) {
14 this.dataSource = dataSource;
15 }
16
17 @Override
18 public Connection getAnyConnection() throws SQLException {
19 return dataSource.getConnection();
20 }
21
22 @Override
23 public void releaseAnyConnection(Connection connection) throws SQLException {
24 connection.close();
25 }
26
27 @Override
28 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 }
34
35 @Override
36 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 }
41
42 @Override
43 public boolean supportsAggressiveRelease() {
44 return false;
45 }
46
47 @Override
48 public boolean isUnwrappableAs(Class unwrapType) {
49 return false;
50 }
51
52 @Override
53 public <T> T unwrap(Class<T> unwrapType) {
54 return null;
55 }
56
57}

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@Configuration
5public class MultiTenantSchemaHibernateConfiguration {
6
7 private final JpaProperties jpaProperties;
8
9 @Autowired
10 public MultiTenantSchemaHibernateConfiguration(JpaProperties jpaProperties) {
11 this.jpaProperties = jpaProperties;
12 }
13
14 @Bean
15 JpaVendorAdapter jpaVendorAdapter() {
16 return new HibernateJpaVendorAdapter();
17 }
18
19 @Bean
20 LocalContainerEntityManagerFactoryBean entityManagerFactory(
21 DataSource dataSource,
22 MultiTenantConnectionProvider multiTenantConnectionProvider,
23 CurrentTenantIdentifierResolver tenantIdentifierResolver) {
24
25 LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean();
26 em.setDataSource(dataSource);
27 em.setPackagesToScan(MultiTenantApplication.class.getPackage().getName());
28 em.setJpaVendorAdapter(this.jpaVendorAdapter());
29
30 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);
34
35 return em;
36 }
37
38}

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@Getter
2@NoArgsConstructor(access = AccessLevel.PROTECTED)
3@ToString
4@Entity
5@Table(name = "tenant", schema = CurrentTenantResolver.DEFAULT_SCHEMA)
6public class Tenant implements TenantSchemaDetails {
7
8 @Id
9 @Column(name = "id", updatable = false, nullable = false, unique = true)
10 private String id;
11
12 @NotNull
13 @Column(name = "name", nullable = false, unique = true)
14 private String name;
15
16 @NotNull
17 @Column(name = "schema", nullable = false, unique = true)
18 private String schema;
19
20 @NotNull
21 @Column(name = "issuer", nullable = false, unique = true)
22 private String issuer;
23
24 @Override
25 public String getJwkSetUrl() {
26 return this.issuer + "/protocol/openid-connect/certs";
27 }
28
29}

Creating the tenant DB table

1CREATE TABLE tenant (
2 "id" varchar NOT NULL
3 CONSTRAINT tenant_pk
4 PRIMARY KEY,
5 "name" varchar NOT NULL
6 CONSTRAINT tenant_name_uc
7 UNIQUE,
8 "schema" varchar NOT NULL
9 CONSTRAINT tenant_schema_uc
10 UNIQUE,
11 "issuer" varchar NOT NULL
12 CONSTRAINT tenant_issuer_uc
13 UNIQUE
14);

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@Getter
2@NoArgsConstructor(access = AccessLevel.PROTECTED)
3@ToString
4@Entity
5@Table(name = "usr")
6public class User {
7
8 @Id
9 @NotNull
10 @Column(name = "name", updatable = false, nullable = false, unique = true)
11 private String name;
12
13}

UserRepository

1@Repository
2interface UserRepository extends CrudRepository<User, String> {
3}

UserService

1@Service
2public class UserService {
3
4 private final UserRepository repository;
5
6 @Autowired
7 public UserService(UserRepository repository) {
8 this.repository = repository;
9 }
10
11 public Optional<User> getByName(String name) {
12 return repository.findById(name);
13 }
14
15 public Iterable<User> getAll() {
16 return repository.findAll();
17 }
18
19}

UserController

1@RestController
2@RequestMapping("/users")
3public class UserController {
4
5 private final UserService service;
6
7 @Autowired
8 public UserController(UserService service) {
9 this.service = service;
10 }
11
12 @GetMapping
13 public Iterable<User> getAll() {
14 return service.getAll();
15 }
16
17}

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=true
2spring.security.oauth2.resourceserver.multitenant.resolve-mode=header
3spring.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 tenant
2VALUES ('tenant1', 'Tenant 1', 'tenant1', 'https://idp.example.org/tenant-1'),
3 ('tenant2', 'Tenant 2', 'tenant2', 'https://idp.example.org/tenant-2');
4
5CREATE 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);
3
4INSERT INTO tenant1.usr VALUES ('alice');
5INSERT INTO tenant1.usr VALUES ('alex');
6
7INSERT 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 alex
  • X-TENANT-ID = tenant2: Should return bob and bella
  • X-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@AutoConfigureMockMvc
4@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 {
7
8 @Autowired
9 private MockMvc mvc;
10
11 @Test
12 void tenant1() throws Exception {
13 sendRequest("tenant1")
14 .andExpect(status().isOk())
15 .andExpect(content().string(containsString("alice")))
16 .andExpect(content().string(containsString("alex")));
17 }
18
19 @Test
20 void tenant2() throws Exception {
21 sendRequest("tenant2")
22 .andExpect(status().isOk())
23 .andExpect(content().string(containsString("bob")))
24 .andExpect(content().string(containsString("bella")));
25 }
26
27 @Test
28 void unknownTenant() throws Exception {
29 sendRequest("unknown-tenant")
30 .andExpect(status().isUnauthorized());
31 }
32
33 @Test
34 void noTenant() throws Exception {
35 sendRequest("")
36 .andExpect(status().isUnauthorized());
37 }
38
39 private ResultActions sendRequest(String tenantId) throws Exception {
40 return mvc.perform(get("/users").with(tenantHeader(tenantId)));
41 }
42
43 private static TenantHeaderRequestPostProcessor tenantHeader(String token) {
44 return new TenantHeaderRequestPostProcessor(token);
45 }
46
47 private static class TenantHeaderRequestPostProcessor implements RequestPostProcessor {
48
49 private final String tenantId;
50
51 TenantHeaderRequestPostProcessor(String tenantId) {
52 this.tenantId = tenantId;
53 }
54
55 @Override
56 @NonNull
57 public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) {
58 request.addHeader("X-TENANT-ID", tenantId);
59 return request;
60 }
61
62 }
63
64}

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

More articles from Johannes Matt

Setting up Keycloak for a multi-tenant app

Setting up Keycloak as an IDP for a multi-tenant software solution

October 11th, 2022 · 1 min read

How to build a multi-tenant SaaS solution with Spring

In the second part of our multi-tenant SaaS series, we will implement an OAuth2 resource server using Spring Security

April 12th, 2022 · 3 min read
© 2022–2023 Johannes Matt
Link to $https://linkedin.com/in/johannes-mattLink to $https://github.com/jomatt