Skip to main content
Version: v0.25.0 (Latest)

Property Resolution Pipeline

When you call configService.getProperty("my.key", String::class), the value passes through a multi-stage pipeline: key normalization, source lookup by priority, value interpolation, protection enforcement, type conversion, and optional caching. Understanding this pipeline helps you design effective configuration strategies and debug resolution issues.

Pipeline Stages

Property Resolution Pipeline

Key Normalization

All property keys are normalized before lookup, so you can use any naming convention and still match the same value:

// All of these resolve to the same property
configService.getProperty("database.host", String::class)
configService.getProperty("DATABASE_HOST", String::class)
configService.getProperty("database-host", String::class)
configService.getProperty("databaseHost", String::class)

The PropertyKeyNormalizer applies these rules:

InputNormalized
DATABASE_HOSTdatabase.host
databaseHost (camelCase)database.host
database-host (kebab)database.host
database_host (snake)database.host
OAUTH2_CLIENT_IDoauth2.client.id

Underscores, hyphens, and camelCase boundaries are all converted to dot separators. The result is always lowercase.

Literal Segments with Brackets

Wrap a key segment in square brackets to skip normalization for that segment. The content inside the brackets is preserved exactly as written:

InputNormalized
credentials.[My-Provider-Id].secretcredentials.[My-Provider-Id].secret
issuers.[https://example.com].enabledissuers.[https://example.com].enabled

This is important when keys contain external identifiers (provider names, URLs, credential IDs) that must not be lowercased or split on hyphens. In YAML files, quote the bracketed key:

credentials:
"[My-Provider-Id]":
secret: abc123

The brackets are part of the normalized key, so lookups must include them.

Environment Variable Mapping

Environment variables are normalized the same way, so setting DATABASE_HOST=localhost is equivalent to database.host=localhost in a properties file:

export DATABASE_HOST=localhost
export DATABASE_PORT=5432
export OAUTH2_CLIENT_ID=my-client
val host = configService.getProperty("database.host", String::class)      // "localhost"
val port = configService.getProperty("database.port", Int::class) // 5432
val clientId = configService.getProperty("oauth2.client.id", String::class) // "my-client"

Source Resolution Order

The PropertySourcesPropertyResolver iterates all registered property sources sorted by order value (ascending). The first source that has the requested key wins.

Default ordering:

PrioritySource typeOrder constant
1 (Highest)Environment VariablesHIGHEST
2Cloud Providers (EDK)HIGH
3YAML / Programmatic MapsMEDIUM
4Database Providers (VDX)MEDIUM / LOW
5Properties FilesLOW
6 (Lowest)Fallback DefaultsLOWEST
// Given:
// ENV: API_BASE_URL=https://env.example.com
// Properties file: api.base.url=https://file.example.com

val url = configService.getProperty("api.base.url", String::class)
// Returns: "https://env.example.com" (environment wins)

When scope-aware resolution is active (e.g., reading from a TenantConfigService), sources are additionally sorted by scope: PRINCIPAL sources are checked before TENANT sources, which are checked before APP sources.

Command Scope Resolution

The cmd.* prefix system adds another dimension of resolution on top of the source priority order described above. When you use a CommandScopedConfigBinder, the resolver checks multiple prefix levels for each property, from most specific to least specific:

  1. Command: cmd.<module>.<service>.<command>.http.client.timeout.connect.ms
  2. Service: cmd.<module>.<service>.default.http.client.timeout.connect.ms
  3. Module: cmd.<module>.default.default.http.client.timeout.connect.ms
  4. Global: http.client.timeout.connect.ms

Properties from more-specific levels override less-specific ones. This prefix-based layering works on top of the APP/TENANT/PRINCIPAL scope hierarchy; the underlying PropertyResolver still cascades through PRINCIPAL, TENANT, and APP sources at each prefix level.

Resolution Example

Given these properties:
http.client.timeout.connect.ms = 30000 (global)
http.client.logging.enabled = true (global)
cmd.kms.default.default.http.client.timeout.connect.ms = 10000 (module)
cmd.kms.keys.get.http.client.logging.enabled = false (command)

For command "kms.keys.get":
timeout.connect.ms = 10000 (module override wins over global)
logging.enabled = false (command override wins over global)

In this example, the command-level override for logging.enabled takes precedence because cmd.kms.keys.get.* is the most specific match. The module-level override for timeout.connect.ms wins over the global default because no service-level or command-level value was defined, so the resolver falls back to the module prefix.

Value Interpolation

Properties can reference other properties or external sources using ${...} placeholders. The DefaultPropertyInterpolator handles four placeholder types:

Simple Placeholders

Reference another property by key, with an optional default value:

base.url=https://api.example.com
api.users.endpoint=${base.url}/users
api.orders.endpoint=${base.url}/orders

# With default value (used when the referenced property is not found)
api.timeout=${custom.timeout:5000}
log.level=${LOG_LEVEL:INFO}
val usersEndpoint = configService.getProperty("api.users.endpoint", String::class)
// Returns: "https://api.example.com/users"

Environment Placeholders

Explicitly reference an environment variable with ${env:...}:

# Read from the HOME environment variable
home.dir=${env:HOME}

# With default
temp.dir=${env:TEMP_DIR:/tmp}

Scope Placeholders

Read from a specific scope level with ${app:...}, ${tenant:...}, or ${principal:...}:

# Always read from app scope, even when resolving at tenant level
shared.url=${app:api.base.url}/shared

# Read from tenant scope
tenant.feature=${tenant:feature.enabled}

Scope placeholders are protection-aware. A PROTECTED property at APP scope cannot be read via ${app:key} from a lower scope.

Secret Placeholders

Resolve a secret from a registered secret provider with ${secret:provider:path} or ${secret:provider:path:key}:

db.password=${secret:env:DB_PASSWORD}
vault.token=${secret:vault:app/database:password}
k8s.secret=${secret:file:/run/secrets/api-key}

See Secret Management for the full list of available providers.

Recursive Interpolation

Placeholders can be nested and are resolved recursively (innermost first):

env=production
region=us-east-1
api.url=https://api-${env}.${region}.example.com
val apiUrl = configService.getProperty("api.url", String::class)
// Returns: "https://api-production.us-east-1.example.com"

The maximum recursion depth is configurable (default: 10) and circular references are detected automatically.

Property Protection

Properties can be marked as FINAL or PROTECTED to control how they interact with the scope hierarchy. This prevents lower scopes from accidentally (or maliciously) overriding critical configuration.

FINAL

A FINAL property at a given scope cannot be overridden by a source at a lower scope. For example, a FINAL property set at APP scope cannot be changed at TENANT or PRINCIPAL scope.

PROTECTED (Interpolation Protection)

A PROTECTED property at a given scope cannot be interpolated from a lower scope. This prevents tenant-level configuration from reading app-level secrets via ${app:secret.key}.

Setting Protection

Protection is set via key prefixes in property sources:

# FINAL - cannot be overridden at lower scopes
final.database.host=prod-db.example.com

# PROTECTED - cannot be interpolated from lower scopes
protected.api.master.key=sensitive-value

# Both FINAL and PROTECTED
final.protected.encryption.key=secret

The prefix is stripped after parsing, so the property is stored as database.host, not final.database.host.

Protection and Scope Access Control

Secret placeholders also enforce scope-based access control:

Secret path prefixMinimum required scope
system/*, app/*APP
tenant/*, tenants/*TENANT or higher
principal/*, principals/*, user/*, users/*PRINCIPAL or higher

Type Conversion

The IDK automatically converts string values to the requested target type:

val port: Int = configService.getProperty("server.port", Int::class, 5432)
val enabled: Boolean = configService.getProperty("feature.enabled", Boolean::class, false)
val timeout: Long = configService.getProperty("http.timeout", Long::class, 30000L)
val ratio: Double = configService.getProperty("sample.ratio", Double::class, 1.0)

Boolean Conversion

These string values convert to true (case-insensitive):

  • "true", "yes", "1", "on"

All other values convert to false.

Caching

The resolution pipeline includes optional caching to avoid repeated source lookups and interpolation.

Snapshot Cache

The CachingPropertySourcesPropertyResolver caches the results of prefix-based queries. Each cache entry is keyed by (scope, tenantId, principalId, prefix) so different tenants get isolated caches.

Cache configuration is loaded from environment variables at startup:

SPHEREON_CONFIG_CACHE_ENABLED=true
SPHEREON_CONFIG_CACHE_MAX_ENTRIES=1000
SPHEREON_CONFIG_CACHE_EVICTION_POLICY=LRU

SPHEREON_CONFIG_CACHE_SNAPSHOT_ENABLED=true
SPHEREON_CONFIG_CACHE_SNAPSHOT_MAX_ENTRIES=200
SPHEREON_CONFIG_CACHE_SNAPSHOT_TTL=1800 # seconds (default: 30 minutes)

Cache Invalidation

The PropertySources container tracks a revision counter that increments whenever a source is added or removed. The resolver uses this revision to detect when its cached ordering is stale and re-sorts automatically.

For database-backed sources (VDX), writing or deleting a setting invalidates the relevant cache entries. You can also invalidate programmatically:

// Invalidate all cached values for a tenant
cache.invalidateTenant("acme")

// Invalidate by key prefix
cache.invalidateByPrefix("api.")

Scope-Based Resolution

When using a scoped ConfigService (e.g., TenantConfigService), the resolver checks sources in scope order, most specific first:

PRINCIPAL sources → TENANT sources → APP sources

Within each scope, sources are sorted by priority. This means a PRINCIPAL-scoped properties file is checked before an APP-scoped environment variable only if the PRINCIPAL source has a matching key at that scope level.

Scope-Aware Property Lookup

You can also query a specific scope explicitly via ScopeAwarePropertyResolver:

val resolver: ScopeAwarePropertyResolver = // ...

// Read from APP scope only, ignoring tenant/principal overrides
val appValue = resolver.getPropertyAsStringAtScope("api.base.url", ConfigLevel.APP)

// Read from TENANT scope only
val tenantValue = resolver.getPropertyAsStringAtScope("api.subscription.key", ConfigLevel.TENANT)

PropertySource Interface

The core interface that all configuration sources implement:

interface PropertySource<T> : HasOrder, Comparable<PropertySource<*>> {
fun hasProperty(name: String): Boolean
fun <T : Any> getProperty(name: String, targetType: KClass<T>): T?
fun getPropertyAsString(name: String): String?
fun removeProperty(name: String)
fun getName(): String
fun getSource(): T
fun getAllPropertyNames(): Set<String>
val isPlatformSupported: Boolean
}

ScopedPropertySource adds a configLevel property to associate the source with a specific scope:

interface ScopedPropertySource<T> : PropertySource<T> {
val configLevel: ConfigLevel
}

Redaction

When you call getAllPropertiesAsString(redact = true), the DefaultSecretRedactionPolicy masks values whose keys match common sensitive patterns:

  • Keys containing password, secret, token, key, credential, or auth (case-insensitive)
  • Values that were resolved from a secret reference

Redacted values are replaced with ***REDACTED***.

Best Practices

Use interpolation for DRY configuration. Define a base URL once and reference it everywhere:

api.base=https://api.example.com
api.users=${api.base}/users
api.orders=${api.base}/orders

Provide defaults for optional configuration. Always supply a sensible default so the application works out of the box:

val timeout = configService.getProperty("http.timeout", Int::class, 30000)

Use FINAL for infrastructure settings. Mark database hosts, encryption keys, and other infrastructure values as FINAL at APP scope to prevent accidental tenant overrides:

export FINAL_DATABASE_HOST=prod-db.example.com

Validate required configuration at startup. Fail fast rather than discovering missing configuration at runtime:

val required = listOf("database.url", "api.key", "oauth2.client-id")
val missing = required.filter { !configService.containsProperty(it) }
if (missing.isNotEmpty()) {
throw ConfigurationException("Missing required config: $missing")
}