Property Resolution Pipeline
The IDK configuration system uses a sophisticated resolution pipeline that handles property lookup, value interpolation, type conversion, and caching. Understanding this pipeline helps you design effective configuration strategies.
Resolution Pipeline Overview
When you request a configuration value, it passes through several stages:
Key Normalization
All property keys are normalized before lookup:
// These all resolve to the same property
configProvider.getProperty("database.host")
configProvider.getProperty("DATABASE.HOST")
configProvider.getProperty("Database.Host")
configProvider.getProperty("database-host") // Kebab case
configProvider.getProperty("database_host") // Snake case
Normalization Rules
| Input | Normalized |
|---|---|
DATABASE.HOST | database.host |
Database-Host | database.host |
database_host | database.host |
OAUTH2_CLIENT_ID | oauth2.client.id |
Environment Variable Mapping
Environment variables are mapped to property keys:
# Environment variable
export DATABASE_HOST=localhost
export DATABASE_PORT=5432
export OAUTH2_CLIENT_ID=my-client
// These properties resolve from the environment variables
val host = configProvider.getProperty("database.host") // "localhost"
val port = configProvider.getProperty("database.port") // "5432"
val clientId = configProvider.getProperty("oauth2.client.id") // "my-client"
Source Resolution Order
Properties are resolved from sources in priority order:
1. Environment Variables (Highest Priority)
export API_BASE_URL=https://api.production.com
2. System Properties
java -Dapi.base.url=https://api.staging.com -jar myapp.jar
3. Programmatic Configuration
configProvider.setProperty(
scope = ConfigScope.APP,
key = "api.base.url",
value = "https://api.override.com"
)
4. Properties Files
# application-production.properties
api.base.url=https://api.default.com
5. Default Providers (Lowest Priority)
Built-in defaults provided by IDK modules.
Resolution Example
// Given:
// - ENV: API_BASE_URL=https://env.com
// - System property: api.base.url=https://system.com
// - Properties file: api.base.url=https://file.com
val url = configProvider.getProperty("api.base.url")
// Returns: "https://env.com" (environment variable wins)
Value Interpolation
Properties can reference other properties using ${...} syntax:
# application.properties
base.url=https://api.example.com
api.users.endpoint=${base.url}/users
api.orders.endpoint=${base.url}/orders
val usersEndpoint = configProvider.getProperty("api.users.endpoint")
// Returns: "https://api.example.com/users"
Nested Interpolation
env=production
region=us-east-1
api.url=https://api-${env}.${region}.example.com
val apiUrl = configProvider.getProperty("api.url")
// Returns: "https://api-production.us-east-1.example.com"
Default Values in Interpolation
# Use default if property not found
api.timeout=${custom.timeout:5000}
log.level=${LOG_LEVEL:INFO}
Recursive Interpolation
service.name=my-app
instance.id=${HOSTNAME:localhost}
full.service.id=${service.name}-${instance.id}
Escaping Interpolation
Use $$ to escape interpolation:
# Literal ${variable} in output
template.syntax=Use $${variable} for templates
Type Conversion
The IDK automatically converts string values to requested types:
// String → Int
val port: Int = configProvider.getProperty("database.port", Int::class, 5432)
// String → Boolean
val enabled: Boolean = configProvider.getProperty("feature.enabled", Boolean::class, false)
// String → Long
val timeout: Long = configProvider.getProperty("http.timeout", Long::class, 30000L)
// String → Double
val ratio: Double = configProvider.getProperty("sample.ratio", Double::class, 1.0)
// String → Duration
val ttl: Duration = configProvider.getProperty("cache.ttl", Duration::class, 5.minutes)
Boolean Conversion
These string values convert to true:
"true","TRUE","True""yes","YES","Yes""1""on","ON","On"
All other values convert to false.
Collection Conversion
# Comma-separated lists
allowed.origins=https://app.com,https://admin.com,https://api.com
val origins: List<String> = configProvider.getPropertyList("allowed.origins")
// Returns: ["https://app.com", "https://admin.com", "https://api.com"]
Caching
The resolution pipeline includes caching for performance:
Cache Configuration
data class ConfigCacheConfig(
val enabled: Boolean = true,
val maxSize: Long = 1000,
val expireAfterWrite: Duration = 5.minutes,
val expireAfterAccess: Duration = 1.minutes
)
Cache Behavior
// First call: full resolution pipeline
val value1 = configProvider.getProperty("database.host") // Cache miss
// Subsequent calls: cache hit
val value2 = configProvider.getProperty("database.host") // Cache hit (instant)
// After programmatic change: cache invalidated
configProvider.setProperty(ConfigScope.APP, "database.host", "new-host")
val value3 = configProvider.getProperty("database.host") // Cache miss (re-resolved)
Manual Cache Control
// Invalidate specific key
configProvider.invalidate("database.host")
// Invalidate all cached values
configProvider.invalidateAll()
Scope-Based Resolution
Properties can be scoped to different levels:
enum class ConfigScope {
SESSION, // Most specific - current session
PRINCIPAL, // Current user
TENANT, // Current organization
APP // Least specific - application-wide
}
Resolution with Scopes
// Set at different scopes
configProvider.setProperty(ConfigScope.APP, "timeout", "5000") // Default
configProvider.setProperty(ConfigScope.TENANT, "timeout", "10000") // Org override
configProvider.setProperty(ConfigScope.PRINCIPAL, "timeout", "15000") // User override
// Resolution checks scopes in order: SESSION → PRINCIPAL → TENANT → APP
val timeout = configProvider.getProperty("timeout")
// Returns: "15000" (most specific scope wins)
PropertySource Interface
The core interface for configuration sources:
interface PropertySource<T> {
val name: String
val source: T
fun containsProperty(key: String): Boolean
fun <V : Any> getProperty(key: String, type: KClass<V>): V?
fun <V : Any> getProperty(key: String, type: KClass<V>, defaultValue: V): V
fun getPropertyNames(): Set<String>
}
Implementing Custom Sources
class RemoteConfigPropertySource(
private val remoteClient: RemoteConfigClient
) : PropertySource<Map<String, Any>> {
override val name = "remote-config"
override val source: Map<String, Any>
get() = cachedConfig
private var cachedConfig: Map<String, Any> = emptyMap()
override fun containsProperty(key: String): Boolean =
source.containsKey(normalizeKey(key))
override fun <V : Any> getProperty(key: String, type: KClass<V>): V? {
val value = source[normalizeKey(key)] ?: return null
return convertValue(value, type)
}
override fun <V : Any> getProperty(key: String, type: KClass<V>, defaultValue: V): V =
getProperty(key, type) ?: defaultValue
override fun getPropertyNames(): Set<String> = source.keys
suspend fun refresh() {
cachedConfig = remoteClient.fetchConfig()
}
}
ConfigEnvironment
The ConfigEnvironment aggregates multiple property sources:
interface ConfigEnvironment {
val propertySources: List<PropertySource<*>>
val activeProfiles: Set<String>
fun <T : Any> getProperty(key: String, type: KClass<T>): T?
fun <T : Any> getProperty(key: String, type: KClass<T>, defaultValue: T): T
fun containsProperty(key: String): Boolean
fun getPropertyNames(): Set<String>
}
Adding Custom Sources
// DI configuration
@ContributesBinding(AppScope::class)
class CustomConfigEnvironment @Inject constructor(
private val systemPropertySource: SystemPropertySource,
private val envPropertySource: EnvironmentPropertySource,
private val filePropertySource: FilePropertySource,
private val remotePropertySource: RemoteConfigPropertySource
) : ConfigEnvironment {
override val propertySources = listOf(
envPropertySource, // Highest priority
systemPropertySource,
remotePropertySource,
filePropertySource // Lowest priority
)
// Resolution iterates sources in order
override fun <T : Any> getProperty(key: String, type: KClass<T>): T? {
for (source in propertySources) {
val value = source.getProperty(key, type)
if (value != null) return value
}
return null
}
}
Best Practices
1. Use Meaningful Key Names
# Good - hierarchical, descriptive
database.connection.pool.max-size=10
oauth2.client.token-endpoint=https://auth.example.com/token
# Avoid - flat, ambiguous
maxPoolSize=10
tokenUrl=https://auth.example.com/token
2. Provide Defaults for Optional Config
// Always provide sensible defaults
val timeout = configProvider.getProperty("http.timeout", Int::class, 30000)
val retries = configProvider.getProperty("http.max-retries", Int::class, 3)
3. Validate Required Configuration
fun validateConfig() {
val required = listOf("database.url", "api.key", "oauth2.client-id")
val missing = required.filter { !configProvider.containsProperty(it) }
if (missing.isNotEmpty()) {
throw ConfigurationException("Missing required config: $missing")
}
}
4. Use Interpolation for DRY Configuration
# DRY - single source of truth
api.base=https://api.example.com
api.users=${api.base}/users
api.orders=${api.base}/orders
api.products=${api.base}/products
5. Document Configuration Options
/**
* HTTP client timeout in milliseconds.
*
* Property: `http.timeout`
* Environment: `HTTP_TIMEOUT`
* Default: 30000
*/
val httpTimeout = configProvider.getProperty("http.timeout", Int::class, 30000)