Configuration
The IDK provides a layered configuration system that lets you read configuration values from multiple sources without worrying about where they come from. Your application code calls configService.getProperty("my.key", String::class) and the system handles resolution from environment variables, property files, YAML, and programmatic sources. The EDK extends this with cloud providers and the VDX platform adds database-backed persistence.
Quick Start
The most common pattern is reading configuration values through the ConfigService:
- Kotlin
- iOS/Swift
// Get ConfigService from your DI component
val configService: AppConfigService = appGraph.appConfigService
// Read a string property
val apiUrl = configService.getProperty("api.base.url", String::class)
// Read with a default value
val timeout = configService.getProperty("http.timeout.ms", Int::class, 30000)
// Read a required property (throws if missing)
val apiKey = configService.getRequiredProperty("api.key", String::class)
// Check if a property exists
if (configService.containsProperty("feature.enabled")) {
// ...
}
// Get ConfigService from your DI component
let configService = appGraph.appConfigService
// Read a string property
let apiUrl = configService.getProperty(key: "api.base.url", targetType: String.self)
// Read with a default value
let timeout = configService.getProperty(key: "http.timeout.ms", targetType: Int.self, defaultValue: 30000)
// Read a required property (throws if missing)
let apiKey = configService.getRequiredProperty(key: "api.key", targetType: String.self)
// Check if a property exists
if configService.containsProperty(key: "feature.enabled") {
// ...
}
ConfigService API
ConfigService extends ConfigEnvironment, which extends PropertyResolver. Together they provide type-safe access to configuration:
| Method | Description |
|---|---|
getProperty(key, type) | Get a property value, returns null if not found |
getProperty(key, type, default) | Get a property with a default fallback |
getRequiredProperty(key, type) | Get a property, throws if not found |
getPropertyAsString(key) | Get a property as a string |
getRequiredPropertyAsString(key) | Get a required property as a string |
containsProperty(key) | Check if a property exists |
getAllProperties() | Get all properties as a map |
getAllPropertiesAsString(redact) | Get all properties as strings, optionally redacting secrets |
getSubProperties(prefixes, stripPrefix) | Get all properties under one or more prefixes |
addPropertySource(source) | Register an additional property source at runtime |
removePropertySource(source) | Remove a previously registered property source |
getActiveProfile() | Get the current configuration profile |
getAppName() | Get the application name |
getPropertySources() | Access the underlying property sources |
Supported Types
The configuration system supports automatic type conversion:
// String
val name = configService.getProperty("app.name", String::class)
// Numbers
val port = configService.getProperty("server.port", Int::class)
val timeout = configService.getProperty("timeout.ms", Long::class)
val ratio = configService.getProperty("cache.ratio", Double::class)
// Boolean
val enabled = configService.getProperty("feature.enabled", Boolean::class)
Configuration Sources
Configuration values are resolved from multiple sources in priority order. The IDK provides the core sources; the EDK and VDX add cloud and database sources.
| Priority | Source | Provided By | Description |
|---|---|---|---|
| 1 (Highest) | Environment Variables | IDK | System environment variables |
| 2 | Cloud Providers | EDK | Azure App Config, REST API servers |
| 3 | Database | VDX | Persisted settings (PostgreSQL, MySQL, SQLite) |
| 4 | YAML Files | IDK | application.yml with profile support |
| 5 | Properties Files | IDK | application-{profile}.properties |
| 6 (Lowest) | Programmatic Maps | IDK | Code-defined defaults |
Higher priority sources override lower priority ones. This means environment variables always win, allowing production overrides without code changes.
Key Normalization
Keys are normalized for consistent lookup across sources. You can use any of these formats and they all resolve to the same value:
| Input | Normalized Form |
|---|---|
api.base.url | api.base.url |
API_BASE_URL | api.base.url |
api.baseUrl (camelCase) | api.base.url |
api.base-url (kebab) | api.base.url |
api_base_url (snake) | api.base.url |
// All of these read the same configuration value
configService.getProperty("api.base.url", String::class)
configService.getProperty("api.baseUrl", String::class)
configService.getProperty("api.base-url", String::class)
Literal Key Segments
Sometimes a key segment contains characters that would normally be normalized. The normalizer converts hyphens, underscores, and camelCase boundaries to dot separators and lowercases everything. This is a problem when a key segment is an external identifier like a credential configuration ID, a client registration name, or a URL that must be preserved exactly.
Wrap such segments in square brackets to skip normalization:
# Without brackets: "My-Provider-Id" would be normalized to "my.provider.id"
# With brackets: the segment is kept exactly as written
credentials.[My-Provider-Id].client-secret=abc123
oid4vci.[https://issuer.example.com].enabled=true
In YAML files, quote the bracketed key:
# OAuth2 client registrations: the key is the client's registration name
oauth2:
clients:
"[portal-web]":
client-id: portal-web
client-type: CONFIDENTIAL
# OID4VCI credential configs: the key is the credential configuration ID
oid4vci:
issuer:
credentials:
"[UniversityDegree]":
format: dc+sd-jwt
scope: degree
Without brackets, UniversityDegree would be normalized to university.degree, portal-web would become portal.web, and lookups would fail because the config system wouldn't find the key.
Everything inside the brackets is treated as a single literal segment. The brackets are part of the normalized key, so lookups must include them: credentials.[My-Provider-Id].client-secret.
Config File Organization
Configuration files follow a scoped directory structure that mirrors the APP/TENANT/PRINCIPAL hierarchy:
config/
application.properties # APP scope, base
application-production.properties # APP scope, production profile
application.yml # APP scope, YAML alternative
application-production.yml # APP scope, YAML production profile
tenant/{tenantId}/
tenant.properties # TENANT scope
tenant-production.properties # TENANT scope, production profile
principal/{principalId}/
principal.properties # PRINCIPAL scope
Key points about file organization:
- Keys use dot notation directly. For example:
api.base.url,http.timeout.ms,feature.enabled. - No multi-app support per file. Each config set belongs to one application. You do not bundle multiple apps into a single config file.
- Profile selection via filename suffix. The active profile determines which file is loaded (e.g.,
application-production.properties). The profile is set during app initialization. - All files are optional. Missing files are silently ignored. You only need to create files for the scopes and profiles you actually use.
- Filesystem first, classpath fallback. The system checks the filesystem for config files first. If not found, it falls back to the classpath.
- Browser fallback. In browser environments, localStorage is used as a fallback when the filesystem is unavailable.
Property Files
Create application-{profile}.properties files in your config directory or classpath:
# API Configuration
api.base.url=https://api.production.example.com
api.timeout.ms=30000
# Feature Flags
feature.new-dashboard.enabled=true
feature.beta-features.enabled=false
# API Configuration
api.base.url=http://localhost:8080
api.timeout.ms=60000
# Feature Flags
feature.new-dashboard.enabled=true
feature.beta-features.enabled=true
The profile is set during application initialization:
- Kotlin
- iOS/Swift
val appGraph = MyAppGraph.init(
application = this,
appId = "my-app",
profile = "production", // Loads application-production.properties
version = "1.0.0"
)
let appGraph = MyAppGraph.companion.doInit(
application: UIApplication.shared,
appId: "my-app",
profile: "production", // Loads application-production.properties
version: "1.0.0"
)
YAML Configuration
YAML support is provided by a separate multiplatform module (lib-conf-yaml) that works on all platforms (JVM, Android, iOS, JS, WASM, Linux) via snakeyaml-engine-kmp. It follows the same patterns as properties files: profile selection via filename suffix and the scoped directory structure described above.
api:
base-url: https://api.example.com
timeout-ms: 30000
feature:
new-dashboard:
enabled: true
YAML keys are flattened to dot notation during loading. For example, api.base-url becomes api.base.url after normalization, exactly as it would from a properties file.
Adding lib-conf-yaml to your dependencies is all that is needed to enable YAML support. No additional configuration or registration is required.
Environment Variables
Environment variables provide the highest priority configuration source. They're ideal for production deployments, container/Kubernetes configurations, and CI/CD pipeline overrides.
# Set configuration via environment variables
export API_BASE_URL=https://api.example.com
export HTTP_TIMEOUT_MS=30000
export FEATURE_NEW_DASHBOARD_ENABLED=true
Conversion Rules
| Property Format | Environment Variable |
|---|---|
api.base.url | API_BASE_URL |
oauth2.client-id | OAUTH2_CLIENT_ID |
kms.providers.aws.region | KMS_PROVIDERS_AWS_REGION |
Adding Configuration Programmatically
You can inject additional configuration at runtime using property sources:
- Kotlin
// Create a map-based property source
val myConfig = MapPropertySource(
name = "my-custom-config",
source = mapOf(
"api.base.url" to "https://api.example.com",
"http.timeout.ms" to 30000,
"feature.enabled" to true
)
)
// Register it with the ConfigService
configService.addPropertySource(myConfig)
For mutable configuration that can be changed after registration:
val mutableConfig = MutableMapPropertySource(name = "runtime-overrides")
configService.addPropertySource(mutableConfig)
// Later, add or change values
mutableConfig.addProperty("api.base.url", "https://api.new-host.example.com")
For configuration that survives application restarts, the EDK provides cloud-backed storage (Azure App Config, REST servers) and the VDX platform provides database persistence.
Scoped Configuration
The configuration system supports a three-level scope hierarchy for multi-tenant applications:
| Scope | ConfigLevel | Level | Description |
|---|---|---|---|
| App | ConfigLevel.APP | 10 | Application-wide settings shared by all tenants |
| Tenant | ConfigLevel.TENANT | 20 | Organization-specific overrides |
| Principal | ConfigLevel.PRINCIPAL | 30 | User-specific overrides |
Each scope inherits from its parent: principal settings can override tenant settings, which in turn can override app settings. When resolving a property, the most specific scope that has a value wins.
- Kotlin
// App-level configuration (shared by all tenants)
val appConfigService: AppConfigService = appGraph.appConfigService
val apiUrl = appConfigService.getProperty("api.base.url", String::class)
// Tenant-level configuration (inherits from app, adds tenant overrides)
val tenantConfigService: TenantConfigService = tenantComponent.tenantConfigService
val tenantApiKey = tenantConfigService.getProperty("api.subscription.key", String::class)
// Principal-level configuration (inherits from tenant)
val principalConfigService: PrincipalConfigService = principalComponent.principalConfigService
val userTheme = principalConfigService.getProperty("ui.theme", String::class)
For more details on multi-tenant configuration, see Multi-Tenancy.
Module, Service, and Command Overrides
The config system supports per-module, per-service, and per-command overrides using the cmd.* prefix convention. This works on top of the APP/TENANT/PRINCIPAL hierarchy. For any config suffix (like http.client or logging.policy), the resolution order from least specific to most specific is:
| Level | Prefix Pattern | Example |
|---|---|---|
| Global | Direct key | http.client.timeout.connect.ms=30000 |
| Module | cmd.<module>.default.default.<suffix> | cmd.kms.default.default.http.client.timeout.connect.ms=10000 |
| Service | cmd.<module>.<service>.default.<suffix> | cmd.kms.keys.default.http.client.timeout.connect.ms=5000 |
| Command | cmd.<module>.<service>.<command>.<suffix> | cmd.kms.keys.get.http.client.timeout.connect.ms=2000 |
More specific levels override less specific ones at the individual property level. This means you can set a global timeout and then override just the timeout for a specific module without repeating all other settings.
HTTP Client Example
# Global HTTP client settings
http.client.timeout.connect.ms=30000
http.client.timeout.request.ms=60000
http.client.logging.enabled=true
# KMS module: shorter timeout (all KMS operations)
cmd.kms.default.default.http.client.timeout.connect.ms=10000
# Specific command: disable logging for key retrieval
cmd.kms.keys.get.http.client.logging.enabled=false
In this example, the kms.keys.get command inherits the global timeout.request.ms of 60000 and the module-level timeout.connect.ms of 10000, but disables logging. You only specify what you want to change at each level.
Logging Example
# Global log level
logging.policy.min.level=INFO
# More verbose logging for the OID4VP module
cmd.oid4vp.default.default.logging.policy.min.level=DEBUG
# Trace-level logging for a specific KMS command
cmd.kms.keys.get.logging.policy.min.level=TRACE
Resolving Command-Scoped Config in Code
CommandScopedConfigBinder handles this resolution. It collects properties from each prefix level and deep-merges them, so you only need to specify the properties you want to override at each level.
// In your service, resolve config for a specific command
val binder = configService.toCommandScopedBinder(
CommandConfigScope.fromCommandId("kms.keys.get")
)
val httpConfig = binder.getConfig<HttpClientProperties>("http.client")
The logging system and HTTP client both use this mechanism. You can use it for any config type that implements @Serializable.
Common Configuration Patterns
Feature Flags
val isNewDashboardEnabled = configService.getProperty(
"feature.new-dashboard.enabled",
Boolean::class
) ?: false
if (isNewDashboardEnabled) {
showNewDashboard()
} else {
showLegacyDashboard()
}
API Client Configuration
data class ApiClientConfig(
val baseUrl: String,
val timeoutMs: Long,
val retryCount: Int
) {
companion object {
fun fromConfigService(config: ConfigService) = ApiClientConfig(
baseUrl = config.getRequiredProperty("api.base.url", String::class),
timeoutMs = config.getProperty("api.timeout.ms", Long::class, 30000L),
retryCount = config.getProperty("api.retry.count", Int::class, 3)
)
}
}
Getting All Properties with a Prefix
// Get all properties under "kms.providers" with the prefix stripped
val kmsProviderProps = configService.getSubProperties(
prefixes = setOf("kms.providers"),
stripPrefix = true // "kms.providers.aws.region" becomes "aws.region"
)
kmsProviderProps.forEach { (key, value) ->
println("$key = $value")
}
Best Practices
Use environment variables for deployment-specific values. Never commit API keys, passwords, or host names to source control. Use environment variables or secret references in production.
Define sensible defaults. Property files should contain reasonable defaults for development. Production values come from environment variables or cloud providers.
Use descriptive key names. Follow dot-notation conventions: component.feature.setting (e.g., http.client.timeout.ms).
Validate at startup. Check that required configuration is present before serving requests:
fun validateConfig(configService: ConfigService) {
val requiredKeys = listOf("api.base.url", "api.key", "database.url")
val missing = requiredKeys.filter { !configService.containsProperty(it) }
if (missing.isNotEmpty()) {
throw IllegalStateException("Missing required configuration: $missing")
}
}
Next Steps
- Property Resolution: How properties are resolved, interpolated, and protected, including module/service/command overrides and the scope hierarchy
- Configuration Providers: Available property sources and how to create your own
- Secrets Management: Handling sensitive configuration values
- Multi-Tenancy: Scoped configuration for multi-tenant applications