6 min read

A Practical Guide to Spring Boot Multi-Tenancy: My Notes

As a software engineer, I find myself solving the same complex problems multiple times. Multi-tenancy in Spring Boot is one of them. Every time I look up guides, even good ones, they seem... all over the place. Some parts are good, some are outdated, and none pull everything together into a single, practical, "this-is-how-you-actually-do-it-in-production" guide.

The main reason I'm writing this is for me. It's a "copy" for my future self to refresh my memory. This is the practical approach I've landed on.

A key piece of context: Our services operate behind an upstream service (like an API gateway or load balancer). That upstream layer handles all authentication and authorization, determines which tenant the request belongs to, and then forwards the request to us, passing the validated tenantId in an HTTP header.

This simplifies our job immensely, as we don't need to include any auth logic in this service. Our TenantContextFilter can simply trust the header it's given.

With that in mind, here's our strategy:

  1. Database per Tenant: Clean separation, secure, scalable.
  2. Secure Credential Management: We'll pull tenant-specific database credentials from Azure Key Vault. No hardcoding, ever.
  3. Modern Context Passing: We're ditching ThreadLocal. It's old and plays terribly with Virtual Threads (Project Loom). We'll use ScopedValue (GA in Java 25 but already available), which is designed for this.
  4. On-the-fly DataSource Resolution: We're not initializing connections for 1,000 tenants at startup. That's a recipe for disaster. We'll create and cache DataSource objects as they are first requested.

The codebase is in Kotlin, because... of course it is.

Let's get to it.


1. The Context Holder: ScopedValue

First, we need a way to hold the tenantId for the duration of a request. As mentioned, ThreadLocal is out. ScopedValue is the modern, virtual-thread-friendly solution. It's immutable within its scope, which is exactly what we want.

Our context holder is a simple object:

package com.mycompany.myapp.shared.kernel.contexts

import java.util.NoSuchElementException

object TenantContext {
    // The ScopedValue. newInstance() creates a new, unbound ScopedValue.
    val context: ScopedValue<String> = ScopedValue.newInstance()

    /**
     * Tries to get the tenantId from the current scope.
     * Returns null if not bound, instead of throwing an exception.
     */
    fun getTenantId(): String? =
        try {
            context.get()
        } catch (_: NoSuchElementException) {
            null
        }
}

2. The Filter: Populating the Context

Now, we need to get the tenantId (e.g., from an HTTP header) and "bind" it to our ScopedValue for the entire request. A OncePerRequestFilter is perfect for this.

The key method here is ScopedValue.where(TenantContext.context, tenantId).run { ... }. This executes the rest of the filter chain (chain.doFilter) within a new scope where TenantContext.context is bound to the tenantId.

package com.mycompany.myapp.interfaces.rest.filters

import com.mycompany.myapp.shared.kernel.contexts.TenantContext
import com.mycompany.myapp.shared.sanitizeTenantId // A hypothetical sanitizer function
import io.opentracing.util.GlobalTracer
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.web.filter.OncePerRequestFilter

class TenantContextFilter(
    private val headerName: String, // e.g., "X-Tenant-ID"
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
    ) {
        val tenantId = request.getHeader(headerName)

        if (tenantId.isNullOrBlank()) {
            // Fail fast if tenant ID is missing
            response.apply {
                status = HttpServletResponse.SC_BAD_REQUEST
                contentType = MediaType.TEXT_PLAIN_VALUE
                writer.write("Missing $headerName header")
            }
            return
        }

        // Bind the tenantId to our ScopedValue and run the rest of the chain
        ScopedValue.where(TenantContext.context, sanitizeTenantId(tenantId)).run {
            // Optional: Tag our trace span with the tenantId
            GlobalTracer.get().activeSpan()?.setTag("tenant.id", tenantId)
            chain.doFilter(request, response)
        }
    }
}

3. The Credentials Manager

This is where we fetch tenant-specific DB credentials. We'll implement a simple interface, which makes it incredibly easy to mock this for tests.

First, a simple data class to hold the credentials and a helper to convert them into Spring's DataSourceProperties.

package com.mycompany.myapp.infrastructure.persistence.mysql

import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties

data class Credentials(
    @NotBlank
    val host: String,
    @Min(1)
    @Max(65535)
    val port: Int? = 3306,
    @NotBlank
    val user: String,
    @NotBlank
    val pass: String,
    @NotBlank
    var schema: String? = null,
) {
    fun toDataSourceProperties() =
        DataSourceProperties().also {
            val port = this.port ?: 3306

            // We can build the JDBC URL directly
            it.url = "jdbc:mysql://${this.host}:$port/${this.schema}?sslMode=PREFERRED&autoReconnect=true"
            it.username = this.user
            it.password = this.pass
            it.driverClassName = "com.mysql.cj.jdbc.Driver"
        }
}

Now, the actual CredentialsManager that talks to Azure Key Vault. It uses one client for "common" secrets and another for tenant-specific (MySQL) secrets.

package com.mycompany.myapp.infrastructure.persistence.mysql

import com.azure.core.exception.ResourceNotFoundException
import com.azure.security.keyvault.secrets.SecretClient
import com.mycompany.myapp.infrastructure.persistence.mysql.contracts.CredentialsManager
import datadog.trace.api.Trace
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Component
import tools.jackson.databind.ObjectMapper
import tools.jackson.module.kotlin.readValue

// This is the contract for testability
interface CredentialsManager {
    fun getCommonDatabaseCredentials(): Credentials
    fun getTenantDatabaseCredentials(tenantId: String): Credentials?
}

@Component
@Profile("development", "integration", "preproduction", "production") // Not for local tests
class AzureKeyVaultCredentialsManager(
    @Qualifier("commonKeyVaultSecretClient")
    private val commonKeyVaultSecretClient: SecretClient,
    @Qualifier("tenantKeyVaultSecretClient")
    private val tenantKeyVaultSecretClient: SecretClient,
    private val objectMapper: ObjectMapper,
) : CredentialsManager {
    @Trace
    override fun getCommonDatabaseCredentials(): Credentials =
        objectMapper.readValue<Credentials>(commonKeyVaultSecretClient.getSecret("mysql").value).apply {
            schema = "common" // Or some common schema
        }

    @Trace
    override fun getTenantDatabaseCredentials(tenantId: String): Credentials? =
        try {
            // The secret name in Key Vault is the tenantId
            val secretValue = tenantKeyVaultSecretClient.getSecret(tenantId).value
            objectMapper.readValue<Credentials>(secretValue).apply {
                schema = tenantId // The schema name is also the tenantId
            }
        } catch (_: ResourceNotFoundException) {
            null // Tenant config not found
        }
}

4. The TenantDataSource Router

This is the core of the multi-tenancy logic. We extend AbstractRoutingDataSource.

Many guides tell you to populate the targetDataSources map at startup. Don't do that. You'll kill your app's boot time and connect to databases you don't even need.

Instead, we'll override determineTargetDataSource(). This lets us resolve the DataSource on-demand. We'll use a ConcurrentHashMap to cache the DataSource instances we create.

package com.mycompany.myapp.infrastructure.persistence.mysql

import com.mycompany.myapp.infrastructure.persistence.mysql.contracts.CredentialsManager
import com.mycompany.myapp.shared.kernel.contexts.TenantContext
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
import javax.sql.DataSource

@Component
class TenantDataSource(
    private val credentialsManager: CredentialsManager,
) : AbstractRoutingDataSource() {
    
    // Our cache of resolved DataSources
    private val tenantDataSources = ConcurrentHashMap<String, DataSource>()

    init {
        // We don't set target data sources here. We resolve them on the fly.
        setTargetDataSources(mapOf())
    }

    /**
     * This key is used by the default determineTargetDataSource,
     * but we are overriding that. We'll call this ourselves.
     */
    override fun determineCurrentLookupKey(): String? = TenantContext.getTenantId()

    /**
     * This is the magic. We override this to provide on-demand
     * DataSource creation and caching.
     */
    override fun determineTargetDataSource(): DataSource {
        val tenantId = determineCurrentLookupKey()
            ?: throw IllegalStateException("Tenant ID not found in context. This should not happen if TenantContextFilter is configured.")

        // Check our cache first
        return tenantDataSources.computeIfAbsent(tenantId) {
            // Not in cache. Let's create it.
            val config =
                credentialsManager.getTenantDatabaseCredentials(tenantId)
                    ?: throw IllegalStateException("Database config not found for tenant $tenantId")

            // Use our Credentials helper to build the DataSource
            config.toDataSourceProperties().initializeDataSourceBuilder().build()
        }
    }
}

A quick note: This ConcurrentHashMap is a basic cache. It will grow indefinitely. A production-grade solution should use an LRU (Least Recently Used) cache (like Caffeine) to evict old, unused DataSource instances and prevent a memory leak. For my notes, this is good enough to illustrate the concept.

5. Wiring It All Together

Finally, we need to tell Spring Data JPA to use our new TenantDataSource. We create a separate Configuration file to define the EntityManagerFactory and TransactionManager for our tenant-specific repositories.

This is crucial: it lets us have different JPA setups. We might have a "common" DataSource for shared tables and this TenantDataSource for all tenant-specific data.

package com.mycompany.myapp.infrastructure.persistence.mysql.config

import jakarta.persistence.EntityManagerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.jpa.EntityManagerFactoryBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import org.springframework.transaction.PlatformTransactionManager
import javax.sql.DataSource

@Configuration
@EnableJpaRepositories(
    // Point to our tenant-specific repositories
    basePackages = ["com.mycompany.myapp.domain.jpa.repositories.tenant"],
    // Use the beans defined below
    entityManagerFactoryRef = "tenantEntityManagerFactory",
    transactionManagerRef = "tenantTransactionManager",
)
class TenantDataSourceConfig {
    
    @Bean
    fun tenantEntityManagerFactory(
        builder: EntityManagerFactoryBuilder,
        @Qualifier("tenantDataSource") dataSource: DataSource, // Inject our routing DataSource
    ): LocalContainerEntityManagerFactoryBean =
        builder
            .dataSource(dataSource)
            // Tell JPA where to find tenant-specific @Entity classes
            .packages("com.mycompany.myapp.domain.jpa.entities.tenant")
            .persistenceUnit("tenant")
            .build()

    @Bean
    fun tenantTransactionManager(
        @Qualifier("tenantEntityManagerFactory") entityManagerFactory: EntityManagerFactory,
    ): PlatformTransactionManager = JpaTransactionManager(entityManagerFactory)
}

And that's it. That's the whole setup.

  • TenantContextFilter catches the request and sets the ScopedValue.
  • When a repository method is called, it asks for a DB connection.
  • Spring asks our TenantDataSource for a connection.
  • TenantDataSource checks its tenantDataSources cache.
  • If the DataSource isn't there, it calls AzureKeyVaultCredentialsManager, builds the new DataSource with the tenant's credentials, caches it, and returns the connection.

It's clean, testable, secure, and uses modern Java features. This is the guide I'll be coming back to.

References

https://www.baeldung.com/multitenancy-with-spring-data-jpa