Tenant Details
Arconia Multitenancy validates the syntax of every tenant identifier that enters the application, and provides an optional mechanism for managing tenant metadata and checking identifiers against a known set of tenants.
Two distinct checks are involved, and they are configured independently:
-
Identifier validation inspects the identifier itself and is always applied. See Tenant Identifier Validation.
-
Tenant verification loads the tenant and checks that it exists and is enabled. It requires a
TenantDetailsService. See Tenant Verification.
Tenant Identifier Validation
Before a tenant is looked up, the syntax of the resolved identifier is validated. The identifier arrives from an untrusted source such as an HTTP header, a path segment, or a subdomain, and it goes on to select tenant-specific resources, so it is checked at the boundary regardless of how tenant details are configured. The same policy applies to every source of tenant identifiers, and to the fixed tenant identifier when the application starts.
The TenantIdentifierValidator interface defines the contract:
public interface TenantIdentifierValidator {
void validate(String tenantIdentifier);
}
Arconia always auto-configures a DefaultTenantIdentifierValidator. It accepts identifiers that match a regular expression in full, up to a maximum length. By default, identifiers are made up of alphanumeric characters, dashes, and underscores, up to 64 characters, which is a set that is safe to use in logs, cache keys, and schema or database names. Anything else is rejected with an InvalidTenantIdentifierException, resulting in an HTTP 400 Bad Request response.
Both the maximum length and the pattern can be configured. A pattern that is not a valid regular expression fails when the application starts.
arconia:
multitenancy:
identifier:
max-length: 128
pattern: "[a-z0-9]+(\\.[a-z0-9]+)*"
| Property | Default | Description |
|---|---|---|
|
|
Maximum number of characters accepted in a tenant identifier. |
|
|
Regular expression a tenant identifier must match in full. |
For rules that a regular expression cannot express, provide your own implementation. It takes precedence over the auto-configured default:
import io.arconia.multitenancy.core.resolution.InvalidTenantIdentifierException;
import io.arconia.multitenancy.core.resolution.TenantIdentifierValidator;
@Bean
TenantIdentifierValidator tenantIdentifierValidator() {
return tenantIdentifier -> {
if (!tenantIdentifier.matches("[a-z0-9]{8}")) {
throw new InvalidTenantIdentifierException("The tenant identifier must be 8 lowercase alphanumeric characters");
}
};
}
Keep a custom validator strict. A tenant identifier that passes validation can be used to select a database, a schema, or a cache partition, and it is recorded in logs and observations. Keep the exception message free of the rejected value too, since the message is logged and returned to the caller. There is no switch to turn validation off: widen the pattern instead.
TenantDetails
The TenantDetails interface represents the core information about a tenant:
public interface TenantDetails {
String identifier();
boolean enabled();
default Map<String, Object> attributes() {
return Map.of();
}
default <T> @Nullable T attribute(String key, Class<T> type) { ... }
}
attribute(key, type) reads a single attribute converted to the requested type, so that attribute("seats", Integer.class) works whether the store keeps the value as a number or as text. It returns null when the attribute is absent, and throws an IllegalStateException when the value cannot be converted.
Arconia provides a default implementation (Tenant record) that supports all these properties and can be constructed using a builder pattern:
var tenant = Tenant.builder()
.identifier("acme")
.enabled(true)
.addAttribute("plan", "premium")
.build();
TenantDetailsService
The TenantDetailsService interface defines the contract for loading tenant information:
public interface TenantDetailsService {
List<? extends TenantDetails> loadAllTenants();
@Nullable
TenantDetails loadTenantByIdentifier(String identifier);
}
Arconia ships three ways to provide it: configuration properties, a relational database, or your own implementation. Whichever one is in place is also used to verify each resolved tenant.
Properties-Based Tenant Details
For simple use cases, you can define tenants directly in your application configuration:
arconia:
multitenancy:
details:
tenants:
- identifier: acme
enabled: true
attributes:
plan: premium
region: eu-north-1
- identifier: beans
enabled: true
- identifier: pixie
enabled: false
attributes:
onboarding: true
As soon as at least one tenant is configured, Arconia auto-configures an InMemoryTenantDetailsService that loads tenant details from the configuration. Requests with a tenant identifier not in the list or for a disabled tenant will be rejected. Identifiers must be unique and must satisfy the tenant identifier validation policy (see Tenant Identifier Validation). The application fails to start when two tenants share an identifier, when a tenant has no identifier, or when an identifier is not valid.
| Property | Default | Description |
|---|---|---|
|
|
List of tenant configurations. |
|
Unique identifier for the tenant. Required. |
|
|
|
Whether the tenant is enabled. |
|
|
Additional metadata for the tenant as key-value pairs. |
JDBC-Based Tenant Details
For dynamic tenant management, tenant details can be stored in a relational database and loaded via JDBC. Add the Arconia Multitenancy Tenant Details JDBC Spring Boot Starter dependency to your project.
-
Gradle
-
Maven
dependencies {
implementation 'io.arconia:arconia-multitenancy-details-jdbc-spring-boot-starter'
}
<dependency>
<groupId>io.arconia</groupId>
<artifactId>arconia-multitenancy-details-jdbc-spring-boot-starter</artifactId>
</dependency>
The dependency is all you need: Arconia auto-configures a JdbcTenantDetailsService that loads tenant details from the DataSource available in the application. No further configuration is required. It queries the database through the JdbcClient auto-configured by Spring Boot, so the spring.jdbc.template.* properties apply to it. Since the tenant is looked up on every request, consider setting spring.jdbc.template.query-timeout so that a database that stops responding fails the request instead of holding its thread.
If tenants are also declared via arconia.multitenancy.details.tenants, the JDBC implementation takes precedence. Set arconia.multitenancy.details.jdbc.enabled to false to use the configured list instead, without removing the dependency.
Tenants are stored in a tenant_details table and their attributes in a tenant_details_attributes table.
For example, this is how the tables are created in a PostgreSQL database:
create table tenant_details
(
id serial primary key,
identifier text default pg_catalog.gen_random_uuid() not null unique,
enabled boolean default true not null,
created_at timestamp default now() not null
);
create table tenant_details_attributes
(
tenant_id integer not null references tenant_details (id) on delete cascade,
attribute_name text not null,
attribute_value text not null,
primary key (tenant_id, attribute_name)
);
Deleting a tenant also deletes its attributes. Attribute values are stored as text, and TenantDetails.attribute(key, type) converts them to the requested type.
Identifiers are compared by the database, so the result follows its collation. PostgreSQL and H2 compare them case-sensitively, like the rest of Arconia Multitenancy, while a case-insensitive collation, such as the MySQL default, would let Acme match a tenant registered as acme.
Schema scripts are bundled for PostgreSQL and H2, together with matching scripts to drop the tables again.
|
The bundled scripts are applied to embedded databases only, following the same convention as the Spring Boot SQL initialization. On PostgreSQL, or any other database that is not embedded, the tables are not created for you. The application still starts, and the first request that resolves a tenant then fails with a message like Either let Arconia create them:
or create them yourself, with the bundled script as a starting point, and set |
For a database without a bundled script, point schema to your own script. The @@platform@@ placeholder in the location is replaced with the identifier of the database driver in use.
| Property | Default | Description |
|---|---|---|
|
|
Whether tenant details are loaded from a relational database. |
|
|
Database schema initialization mode. Options: |
|
|
Path to the SQL file to use to initialize the database schema. |
|
Platform to use in the schema script if the |
|
|
|
Whether initialization should continue when an error occurs when applying the schema script. |
Dedicated Data Source
By default, the tenant details are read from the single DataSource of the application, or from the @Primary one when there are several. To keep them in a database of their own, such as a control-plane database separate from the business data, annotate the DataSource bean holding them with @TenantDetailsDataSource:
import io.arconia.multitenancy.details.jdbc.autoconfigure.TenantDetailsDataSource;
@Configuration(proxyBeanMethods = false)
class TenantDetailsDataSourceConfiguration {
@Bean(defaultCandidate = false)
@TenantDetailsDataSource
DataSource tenantDetailsDataSource() {
return DataSourceBuilder.create().url("jdbc:postgresql://localhost:5432/tenants").build();
}
}
Both the JdbcTenantDetailsService and the schema initialization then use that data source. Declaring it with defaultCandidate = false keeps it out of regular injection points, so Spring Boot still auto-configures the main DataSource from the spring.datasource. properties for the rest of the application. Since Spring Boot’s JdbcClient belongs to the main data source, the spring.jdbc.template. properties do not apply to the dedicated one: configure timeouts on its connection pool or driver instead, or declare your own JdbcTenantDetailsService bean built with JdbcTenantDetailsService.builder().jdbcClient(…).
When the application has several data sources, none of them @Primary and none annotated with @TenantDetailsDataSource, the JDBC tenant details are not configured, and tenants are no longer verified. The web module logs this at startup, as described in Tenant Verification.
|
Custom TenantDetailsService
For dynamic tenant management from another source, implement the TenantDetailsService interface and register it as a Spring bean:
import io.arconia.multitenancy.core.tenantdetails.TenantDetails;
import io.arconia.multitenancy.core.tenantdetails.TenantDetailsService;
@Bean
TenantDetailsService tenantDetailsService(TenantRepository tenantRepository) {
return new TenantDetailsService() {
@Override
public List<? extends TenantDetails> loadAllTenants() {
return tenantRepository.findAll();
}
@Override
public TenantDetails loadTenantByIdentifier(String identifier) {
return tenantRepository.findByIdentifier(identifier);
}
};
}
When a custom TenantDetailsService is registered, it takes precedence over the built-in implementations.
Tenant Verification
The TenantVerifier interface defines the contract for verifying that a resolved tenant identifier belongs to a tenant that exists and is allowed to proceed. When a TenantDetailsService is available, Arconia auto-configures a DefaultTenantVerifier that checks the tenant exists and is enabled. Unlike identifier validation, verification is only applied when a TenantDetailsService is configured. Without a TenantVerifier, the web module logs at startup that tenants are not verified, since every identifier that passes validation is then accepted.
Entry points such as the TenantContextFilter call the verifier directly before establishing the tenant context. If verification fails, a TenantVerificationException is thrown, resulting in an HTTP 400 Bad Request response. An unknown tenant and a disabled tenant are reported with the same message, so that callers cannot find out which tenants exist.
You can provide a custom TenantVerifier bean to implement your own verification logic:
import io.arconia.multitenancy.core.resolution.TenantVerifier;
@Bean
TenantVerifier tenantVerifier() {
return tenantIdentifier -> {
// Custom verification logic (e.g., check quotas, feature flags)
};
}
Caching Tenant Lookups
The verifier looks up the tenant on every request. With the JDBC-based tenant details or any other remote store, that is one query per request. To serve repeated lookups from memory, add Caffeine to your project and set a time to live:
-
Gradle
-
Maven
dependencies {
implementation 'com.github.ben-manes.caffeine:caffeine'
}
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
-
YAML
-
Properties
arconia:
multitenancy:
details:
cache:
time-to-live: 30s
arconia.multitenancy.details.cache.time-to-live=30s
The DefaultTenantVerifier then reads tenants through a CachingTenantDetailsService. Only tenants that exist are cached, including disabled ones. An identifier that matches no tenant is looked up every time, so that arbitrary identifiers sent by clients cannot push known tenants out of the cache and a newly created tenant is accepted right away. The tenants endpoint and your own code keep reading the TenantDetailsService directly.
| A change to a tenant takes effect once its cache entry expires. In particular, a tenant you disable keeps being accepted for up to the time to live, so choose it according to how quickly a disabled tenant must lose access. Each application instance keeps its own cache, so with several replicas every one of them can keep accepting a disabled tenant for up to that time. When a disabled tenant must lose access immediately, leave caching disabled. |
Caching is disabled by default. Setting the time to live without Caffeine on the classpath fails at startup. This cache holds tenant details only; to keep cached application data apart per tenant, see Caching.
| Property | Default | Description |
|---|---|---|
|
Time after which a cached tenant is loaded again. Setting it enables caching, which requires Caffeine on the classpath. A change to a tenant, such as disabling it, takes effect once its entry expires. |
|
|
|
Maximum number of tenants held in the cache. |
Actuator
Tenants Endpoint
When Spring Boot Actuator is present in the classpath and a TenantDetailsService is available, Arconia auto-configures a tenants actuator endpoint. It exposes the tenants known to the application, so you can check at runtime which tenants are registered and which of them are enabled.
By default, Spring Boot exposes only the health endpoint over HTTP. To access the tenants endpoint, include it in the web exposure configuration:
management:
endpoints:
web:
exposure:
include: tenants
| Property | Default | Description |
|---|---|---|
|
|
Permitted level of access for the tenants endpoint. Options: |
|
|
Maximum time that a response can be cached. |
List All Tenants
http :8080/actuator/tenants
Response example:
{
"tenants": [
{
"identifier": "acme",
"enabled": true
},
{
"identifier": "beans",
"enabled": true
},
{
"identifier": "pixie",
"enabled": false
}
]
}
Show a Single Tenant
http :8080/actuator/tenants/acme
Response example:
{
"identifier": "acme",
"enabled": true,
"attributeNames": [
"plan",
"region"
]
}
An unknown tenant identifier returns HTTP 404 Not Found.
|
The endpoint reports attribute names but never attribute values. Tenant attributes are defined by the application and may carry credentials or connection details, which should not be readable from an actuator endpoint. Use the |