Skip to main content
spring internals

The Binder API and Relaxed Binding Rules

7 min read Chapter 20 of 78

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:

  1. Split on dots (.) to get path elements.
  2. For each element, convert to lowercase.
  3. Replace camelCase boundaries with hyphens: maxPoolSize becomes max-pool-size.
  4. Replace underscores with hyphens: max_pool_size becomes max-pool-size.
  5. 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 @ConfigurationProperties prefixes).
  • 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:

  1. Iterates all ConfigurationPropertySource instances.
  2. For each source, finds properties whose canonical name starts with app.database..
  3. For each matching property, strips the prefix to get the property name (e.g., max-pool-size).
  4. Converts the target class field names to canonical form (e.g., maxPoolSize becomes max-pool-size).
  5. Matches the source property name against the target field name in canonical form.
  6. 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.