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]+)*"
Table 1. Tenant Identifier Configuration Properties
Property Default Description

arconia.multitenancy.identifier.max-length

64

Maximum number of characters accepted in a tenant identifier.

arconia.multitenancy.identifier.pattern

[a-zA-Z0-9_-]+

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.

Table 2. Tenant Details Configuration Properties
Property Default Description

arconia.multitenancy.details.tenants

[]

List of tenant configurations.

arconia.multitenancy.details.tenants[].identifier

Unique identifier for the tenant. Required.

arconia.multitenancy.details.tenants[].enabled

true

Whether the tenant is enabled.

arconia.multitenancy.details.tenants[].attributes

{}

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 relation "tenant_details" does not exist.

Either let Arconia create them:

arconia:
  multitenancy:
    details:
      jdbc:
        initialize-schema: always

or create them yourself, with the bundled script as a starting point, and set initialize-schema to never so that the two do not compete. That is the usual choice when the schema is managed by Flyway or Liquibase.

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.

Table 3. JDBC Tenant Details Configuration Properties
Property Default Description

arconia.multitenancy.details.jdbc.enabled

true

Whether tenant details are loaded from a relational database.

arconia.multitenancy.details.jdbc.initialize-schema

embedded

Database schema initialization mode. Options: always, embedded, never.

arconia.multitenancy.details.jdbc.schema

classpath:io/arconia/multitenancy/details/jdbc/autoconfigure/schema-@@platform@@.sql

Path to the SQL file to use to initialize the database schema.

arconia.multitenancy.details.jdbc.platform

Platform to use in the schema script if the @@platform@@ placeholder is used. Auto-detected by default.

arconia.multitenancy.details.jdbc.continue-on-error

true

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.

Table 4. Tenant Details Cache Configuration Properties
Property Default Description

arconia.multitenancy.details.cache.time-to-live

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.

arconia.multitenancy.details.cache.max-size

1000

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
Table 5. Tenants Endpoint Configuration Properties
Property Default Description

management.endpoint.tenants.access

unrestricted

Permitted level of access for the tenants endpoint. Options: none, read-only, unrestricted.

management.endpoint.tenants.cache.time-to-live

0ms

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 TenantDetailsService directly when you need the values.