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
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:
| Input | Normalized |
|---|---|
DATABASE_HOST | database.host |
databaseHost (camelCase) | database.host |
database-host (kebab) | database.host |
database_host (snake) | database.host |
OAUTH2_CLIENT_ID | oauth2.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:
| Input | Normalized |
|---|---|
credentials.[My-Provider-Id].secret | credentials.[My-Provider-Id].secret |
issuers.[https://example.com].enabled | issuers.[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:
| Priority | Source type | Order constant |
|---|---|---|
| 1 (Highest) | Environment Variables | HIGHEST |
| 2 | Cloud Providers (EDK) | HIGH |
| 3 | YAML / Programmatic Maps | MEDIUM |
| 4 | Database Providers (VDX) | MEDIUM / LOW |
| 5 | Properties Files | LOW |
| 6 (Lowest) | Fallback Defaults | LOWEST |
// 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:
- Command:
cmd.<module>.<service>.<command>.http.client.timeout.connect.ms - Service:
cmd.<module>.<service>.default.http.client.timeout.connect.ms - Module:
cmd.<module>.default.default.http.client.timeout.connect.ms - 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:
- Properties File
- Environment Variable
# 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
# Same prefixes in uppercase/underscore format
export FINAL_DATABASE_HOST=prod-db.example.com
export PROTECTED_API_MASTER_KEY=sensitive-value
export 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 prefix | Minimum 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, orauth(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")
}