Spring Boot Multitenancy by Schema/Database for SaaS Applications with Custom Connection Management

Modern SaaS platforms are expected to serve multiple customers while maintaining strong data isolation, scalability, and cost efficiency. This is where Spring Boot Multitenancy becomes a core architectural concern. Choosing the right SaaS Database Architecture and implementing efficient Tenant Routing are critical decisions that directly affect performance, security, and maintainability.

This article provides a production-ready, enterprise-focused guide on implementing Spring Boot Multitenancy by schema and database using custom connection management. The solution is designed for software engineers working in companies who need a clean, extensible, and testable approach that can be deployed directly into real SaaS systems.



What Is Multitenancy in SaaS Applications

Multitenancy is a software architecture where a single application instance serves multiple tenants. Each tenant represents a customer or organization, and their data must be isolated from others.

Common SaaS database strategies include shared database with shared schema, shared database with separate schema, and separate database per tenant. This guide focuses on schema-based and database-based multitenancy, which are the most widely adopted approaches in enterprise SaaS platforms.

Why Use Spring Boot Multitenancy

Spring Boot provides seamless integration with Hibernate and JPA, clean abstractions for DataSource and transaction management, and strong testing support. These features make it an ideal framework for building scalable and secure SaaS Database Architecture with robust Tenant Routing.

Architecture Overview

The final architecture consists of a tenant context holder, request filter, tenant identifier resolver, custom routing DataSource, Hibernate multitenancy configuration, REST APIs, and automated test cases. Each incoming request dynamically routes database connections based on the tenant identifier.



Step 1: Create the Spring Boot Project

Create a new Spring Boot project using Maven with Java 17, Spring Web, Spring Data JPA, and H2 database. This setup mirrors enterprise usage while keeping the solution self-contained and executable.

Step 2: Add Maven Dependencies

<dependencies>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

</dependencies>

Step 3: Define the Tenant Context

package com.example.multitenancy.tenant;

public class TenantContext {

    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenantId(String tenantId) {
        CURRENT_TENANT.set(tenantId);
    }

    public static String getTenantId() {
        return CURRENT_TENANT.get();
    }

    public static void clear() {
        CURRENT_TENANT.remove();
    }
}


Step 4: Capture Tenant Information from Requests

package com.example.multitenancy.filter;

import com.example.multitenancy.tenant.TenantContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class TenantFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String tenantId = request.getHeader("X-Tenant-ID");
        if (tenantId != null && !tenantId.isEmpty()) {
            TenantContext.setTenantId(tenantId);
        }

        try {
            filterChain.doFilter(request, response);
        } finally {
            TenantContext.clear();
        }
    }
}

Step 5: Implement Hibernate Tenant Identifier Resolver

package com.example.multitenancy.config;

import com.example.multitenancy.tenant.TenantContext;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.stereotype.Component;

@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver {

    private static final String DEFAULT_TENANT = "tenant1";

    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.getTenantId();
        return tenant != null ? tenant : DEFAULT_TENANT;
    }

    @Override
    public boolean validateExistingCurrentSessions() {
        return true;
    }
}

Step 6: Configure Custom Routing DataSource

package com.example.multitenancy.config;

import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;

public class TenantRoutingDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
        return com.example.multitenancy.tenant.TenantContext.getTenantId();
    }
}


Step 7: Configure DataSources and EntityManager

package com.example.multitenancy.config;

import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;

import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class MultiTenantConfig {

    @Bean
    public DataSource dataSource() {
        TenantRoutingDataSource dataSource = new TenantRoutingDataSource();

        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("tenant1",
                org.springframework.boot.jdbc.DataSourceBuilder.create()
                        .url("jdbc:h2:mem:tenant1;DB_CLOSE_DELAY=-1")
                        .username("sa")
                        .password("")
                        .driverClassName("org.h2.Driver")
                        .build());

        targetDataSources.put("tenant2",
                org.springframework.boot.jdbc.DataSourceBuilder.create()
                        .url("jdbc:h2:mem:tenant2;DB_CLOSE_DELAY=-1")
                        .username("sa")
                        .password("")
                        .driverClassName("org.h2.Driver")
                        .build());

        dataSource.setTargetDataSources(targetDataSources);
        dataSource.setDefaultTargetDataSource(targetDataSources.get("tenant1"));
        return dataSource;
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            EntityManagerFactoryBuilder builder,
            DataSource dataSource,
            TenantIdentifierResolver resolver) {

        Map<String, Object> properties = new HashMap<>();
        properties.put("hibernate.multiTenancy", "DATABASE");
        properties.put("hibernate.tenant_identifier_resolver", resolver);
        properties.put("hibernate.hbm2ddl.auto", "update");

        return builder
                .dataSource(dataSource)
                .packages("com.example.multitenancy")
                .properties(properties)
                .build();
    }
}

Step 8: Create a Sample Entity

package com.example.multitenancy.model;

import jakarta.persistence.*;

@Entity
public class Customer {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    public Customer() {
    }

    public Customer(String name) {
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}

Step 9: Repository and REST Controller

package com.example.multitenancy.repository;

import com.example.multitenancy.model.Customer;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, Long> {
}
package com.example.multitenancy.controller;

import com.example.multitenancy.model.Customer;
import com.example.multitenancy.repository.CustomerRepository;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/customers")
public class CustomerController {

    private final CustomerRepository repository;

    public CustomerController(CustomerRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    public Customer save(@RequestParam String name) {
        return repository.save(new Customer(name));
    }

    @GetMapping
    public List<Customer> findAll() {
        return repository.findAll();
    }
}


Step 10: How to Test the Application

Start the Spring Boot application and send HTTP requests with different tenant headers. For tenant1, use header X-Tenant-ID: tenant1 and create or fetch customers. Repeat the same for tenant2. Each tenant will see only its own data, proving correct Tenant Routing and SaaS Database Architecture isolation.

Step 11: Automated Test Case

package com.example.multitenancy;

import com.example.multitenancy.tenant.TenantContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class TenantContextTest {

    @AfterEach
    void cleanup() {
        TenantContext.clear();
    }

    @Test
    void testTenantContextStorage() {
        TenantContext.setTenantId("tenant1");
        assertThat(TenantContext.getTenantId()).isEqualTo("tenant1");
    }
}

Conclusion

Implementing Spring Boot Multitenancy using schema or database strategies with custom connection management is a proven approach for building scalable SaaS platforms. This production-ready solution demonstrates clean architecture, reliable Tenant Routing, and a flexible SaaS Database Architecture that can be directly applied to enterprise Spring Boot applications.