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:
- Database per Tenant: Clean separation, secure, scalable.
- Secure Credential Management: We'll pull tenant-specific database credentials from Azure Key Vault. No hardcoding, ever.
- Modern Context Passing: We're ditching
ThreadLocal. It's old and plays terribly with Virtual Threads (Project Loom). We'll useScopedValue(GA in Java 25 but already available), which is designed for this. - 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
DataSourceobjects 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.
TenantContextFiltercatches the request and sets theScopedValue.- When a repository method is called, it asks for a DB connection.
- Spring asks our
TenantDataSourcefor a connection. TenantDataSourcechecks itstenantDataSourcescache.- If the
DataSourceisn't there, it callsAzureKeyVaultCredentialsManager, builds the newDataSourcewith 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.
Member discussion