The Binder API and Relaxed Binding Rules
The Binder API and Relaxed Binding Rules
The Binder is the engine underneath @ConfigurationProperties. Most developers never call it directly because the annotation handles everything. But in a multi-tenant SaaS backend, you often need to bind configuration dynamically: loading tenant-specific datasource settings from a map, resolving feature flags per tenant, or reading configuration keys that are only known at runtime. The Binder API gives you that control.
Binder.get(environment).bind()
The entry point is Binder.get(Environment). This creates a Binder backed by the property sources in the current Environment.
@Service
public class TenantDatasourceResolver {
private final Environment environment;
public TenantDatasourceResolver(Environment environment) {
this.environment = environment;
}
public TenantDatasourceConfig resolve(String tenantId) {
Binder binder = Binder.get(environment);
String prefix = "app.tenants." + tenantId + ".datasource";
BindResult<TenantDatasourceConfig> result =
binder.bind(prefix, Bindable.of(TenantDatasourceConfig.class));
return result.orElseThrow(() ->
new TenantConfigurationException(
"No datasource configuration found for tenant: " + tenantId));
}
}
The YAML backing this:
app:
tenants:
acme:
datasource:
url: jdbc:postgresql://db1.internal:5432/acme
username: acme_user
max-pool-size: 20
globex:
datasource:
url: jdbc:postgresql://db2.internal:5432/globex
username: globex_user
max-pool-size: 50
Binder.bind() takes a prefix string and a Bindable<T>. It returns a BindResult<T>, which is either bound (contains the object) or unbound (empty). BindResult is not Optional, but it provides the same API: orElse(), orElseThrow(), map(), isBound().
Bindable
Bindable describes the target of a bind operation. The most common factory methods:
// Bind to a class
Bindable.of(TenantDatasourceConfig.class)
// Bind to a parameterized type (e.g., List<String>)
Bindable.listOf(String.class)
// Bind to a Map
Bindable.mapOf(String.class, TenantDatasourceConfig.class)
// Bind to an existing instance (overlay binding)
Bindable.ofInstance(existingConfig)
When you use Bindable.ofInstance(), the Binder mutates the provided instance rather than creating a new one. This is useful for applying overrides to default configuration.
BindHandler
For advanced control, pass a BindHandler to intercept binding events:
binder.bind(prefix, Bindable.of(TenantDatasourceConfig.class), new BindHandler() {
@Override
public <T> Bindable<T> onStart(ConfigurationPropertyName name,
Bindable<T> target,
BindContext context) {
log.debug("Binding: {}", name);
return target;
}
@Override
public Object onSuccess(ConfigurationPropertyName name,
Bindable<?> target,
BindContext context,
Object result) {
log.debug("Bound {} = {}", name, result);
return result;
}
@Override
public Object onFailure(ConfigurationPropertyName name,
Bindable<?> target,
BindContext context,
Exception error) throws Exception {
log.warn("Failed to bind {}: {}", name, error.getMessage());
throw error;
}
});
BindHandler lets you log, transform, or reject individual property bindings. The NoUnboundElementsBindHandler is a built-in handler that throws when properties exist under the prefix but do not match any field on the target. This catches typos in configuration files.
Relaxed Binding in Detail
Relaxed binding is the name-matching algorithm that lets max-pool-size in YAML map to maxPoolSize in Java. The algorithm converts both sides to a canonical form and compares them.
Canonical Form
ConfigurationPropertyName converts any property name to canonical form using these rules:
- Split on dots (
.) to get path elements. - For each element, convert to lowercase.
- Replace camelCase boundaries with hyphens:
maxPoolSizebecomesmax-pool-size. - Replace underscores with hyphens:
max_pool_sizebecomesmax-pool-size. - Remove any remaining non-alphanumeric characters except hyphens and dots.
The canonical form of maxPoolSize, max-pool-size, max_pool_size, and MAX_POOL_SIZE is the same: max-pool-size.
Source-Specific Rules
Different property sources have different syntactic constraints, so Spring Boot applies source-specific conversion before canonical matching.
YAML and .properties files: Use kebab-case. This is the recommended format because it maps directly to canonical form without conversion.
app:
database:
max-pool-size: 20 # recommended
connection-timeout: 30s
Environment variables: Use uppercase with underscores. Dots become underscores. Hyphens become underscores. The prefix separator is also an underscore.
APP_DATABASE_MAX_POOL_SIZE=20
APP_DATABASE_CONNECTION_TIMEOUT=30s
The environment variable processor knows that APP_DATABASE_MAX_POOL_SIZE maps to app.database.max-pool-size. It performs this mapping:
- Split on underscores.
- The first N elements form the prefix (matched against registered
@ConfigurationPropertiesprefixes). - Within the property name portion, consecutive uppercase letters between underscores form one word.
System properties: Use dot-separated or kebab-case.
-Dapp.database.max-pool-size=20
The Matching Algorithm
When the Binder processes a @ConfigurationProperties class with prefix app.database, it:
- Iterates all
ConfigurationPropertySourceinstances. - For each source, finds properties whose canonical name starts with
app.database.. - For each matching property, strips the prefix to get the property name (e.g.,
max-pool-size). - Converts the target class field names to canonical form (e.g.,
maxPoolSizebecomesmax-pool-size). - Matches the source property name against the target field name in canonical form.
- Invokes the setter or passes the value to the constructor.
Collection and Map Binding
The Binder handles collections and maps as first-class types.
List Binding
@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(
List<String> allowedOrigins,
List<String> allowedMethods
) {}
YAML uses indexed or inline notation:
app:
cors:
allowed-origins:
- "https://acme.example.com"
- "https://globex.example.com"
- "https://admin.example.com"
allowed-methods:
- GET
- POST
- PUT
- DELETE
Environment variables use numeric indices:
APP_CORS_ALLOWED_ORIGINS_0=https://acme.example.com
APP_CORS_ALLOWED_ORIGINS_1=https://globex.example.com
APP_CORS_ALLOWED_ORIGINS_2=https://admin.example.com
Map Binding
Maps are essential for multi-tenant configuration. Each key in the map represents a tenant.
@ConfigurationProperties(prefix = "app")
public record MultiTenantProperties(
Map<String, TenantConfig> tenants
) {}
public record TenantConfig(
String datasourceUrl,
String schema,
int maxPoolSize
) {}
app:
tenants:
acme:
datasource-url: jdbc:postgresql://db1:5432/saas
schema: acme
max-pool-size: 20
globex:
datasource-url: jdbc:postgresql://db2:5432/saas
schema: globex
max-pool-size: 50
The Binder treats the YAML map keys (acme, globex) as String map keys and binds the nested structure to TenantConfig objects.
For environment variables, map keys are separated by underscores:
APP_TENANTS_ACME_DATASOURCE_URL=jdbc:postgresql://db1:5432/saas
APP_TENANTS_ACME_SCHEMA=acme
APP_TENANTS_ACME_MAX_POOL_SIZE=20
Programmatic Map Binding
Using the Binder API directly to load the tenant map:
@Service
public class TenantRegistry {
private final Map<String, TenantConfig> tenants;
public TenantRegistry(Environment environment) {
Binder binder = Binder.get(environment);
this.tenants = binder.bind(
"app.tenants",
Bindable.mapOf(String.class, TenantConfig.class)
).orElse(Map.of());
}
public TenantConfig getTenant(String tenantId) {
TenantConfig config = tenants.get(tenantId);
if (config == null) {
throw new UnknownTenantException(tenantId);
}
return config;
}
}
The Failure Mode
Using camelCase property names in environment variables:
# BROKEN: camelCase in environment variable names does not match
APP_DATABASE_maxPoolSize=20
APP_DATABASE_connectionTimeout=30s
This does not work. Environment variable sources expect uppercase with underscores. The canonical form of maxPoolSize when read as an environment variable element is maxpoolsize (all lowercase, no separator), which does not match the canonical form max-pool-size derived from the Java field name.
Another common mistake:
# BROKEN: inconsistent naming causes silent binding failure
app:
database:
maxPoolSize: 20 # camelCase in YAML
connection_timeout: 30s # snake_case in YAML
This actually works for binding (relaxed binding handles both forms), but it creates confusion when overriding from environment variables. The recommended practice is to use one format consistently.
# CORRECT: kebab-case everywhere in YAML
app:
database:
max-pool-size: 20
connection-timeout: 30s
# CORRECT: SCREAMING_SNAKE_CASE for environment variables
APP_DATABASE_MAX_POOL_SIZE=20
APP_DATABASE_CONNECTION_TIMEOUT=30s
The rule is straightforward: kebab-case in YAML and properties files, SCREAMING_SNAKE_CASE in environment variables. Other formats work through relaxed binding, but mixing them leads to debugging sessions that waste time.
ConfigurationPropertyName Internals
The ConfigurationPropertyName class enforces the canonical form. You can use it directly to verify how a property name will be resolved:
ConfigurationPropertyName name =
ConfigurationPropertyName.of("app.database.max-pool-size");
// Elements: ["app", "database", "max-pool-size"]
for (int i = 0; i < name.getNumberOfElements(); i++) {
System.out.println(name.getElement(i, ConfigurationPropertyName.Form.DASHED));
}
The Form enum has two values:
DASHED: The canonical form with hyphens (max-pool-size).UNIFORM: All lowercase with no separators (maxpoolsize). Used for fast comparison.
ConfigurationPropertyName.isValid() checks whether a string is a valid property name. Names with uppercase letters, consecutive dots, or leading/trailing dots are invalid in canonical form.
When debugging binding issues, printing the ConfigurationPropertyName of both the source property and the target field reveals whether they match. If the canonical forms differ, the binding will not happen, and no error is raised. The property is skipped silently. This is by design: not every property in the environment belongs to your @ConfigurationProperties class. But it means typos in property names are invisible unless you use NoUnboundElementsBindHandler or validation.