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")
}