Skip to main content
Version: v0.13

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:

Property Resolution Pipeline

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

InputNormalized
DATABASE.HOSTdatabase.host
Database-Hostdatabase.host
database_hostdatabase.host
OAUTH2_CLIENT_IDoauth2.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)