Skip to main content
Version: v0.25.0 (Latest)

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:

// 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")) {
// ...
}

ConfigService API

ConfigService extends ConfigEnvironment, which extends PropertyResolver. Together they provide type-safe access to configuration:

MethodDescription
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.

PrioritySourceProvided ByDescription
1 (Highest)Environment VariablesIDKSystem environment variables
2Cloud ProvidersEDKAzure App Config, REST API servers
3DatabaseVDXPersisted settings (PostgreSQL, MySQL, SQLite)
4YAML FilesIDKapplication.yml with profile support
5Properties FilesIDKapplication-{profile}.properties
6 (Lowest)Programmatic MapsIDKCode-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:

InputNormalized Form
api.base.urlapi.base.url
API_BASE_URLapi.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:

application-production.properties
# 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
application-development.properties
# 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:

val appGraph = MyAppGraph.init(
application = this,
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.

application.yml
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 FormatEnvironment Variable
api.base.urlAPI_BASE_URL
oauth2.client-idOAUTH2_CLIENT_ID
kms.providers.aws.regionKMS_PROVIDERS_AWS_REGION

Adding Configuration Programmatically

You can inject additional configuration at runtime using property sources:

// 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")
Persistent Configuration

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:

ScopeConfigLevelLevelDescription
AppConfigLevel.APP10Application-wide settings shared by all tenants
TenantConfigLevel.TENANT20Organization-specific overrides
PrincipalConfigLevel.PRINCIPAL30User-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.

// 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:

LevelPrefix PatternExample
GlobalDirect keyhttp.client.timeout.connect.ms=30000
Modulecmd.<module>.default.default.<suffix>cmd.kms.default.default.http.client.timeout.connect.ms=10000
Servicecmd.<module>.<service>.default.<suffix>cmd.kms.keys.default.http.client.timeout.connect.ms=5000
Commandcmd.<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