Settings Persistence
The EDK provides database-backed settings persistence with hierarchical scope inheritance. Configuration values can be stored at application, tenant, or principal (user) levels, with automatic inheritance from parent scopes.
Overview
The settings persistence system provides:
- Hierarchical scopes - APP > TENANT > PRINCIPAL inheritance
- Type-safe storage - Typed values with automatic serialization
- Multi-dialect support - PostgreSQL, MySQL, SQLite
- Cache layer - TTL-based caching for performance
- Soft delete - Audit trail with deletedAt/deletedBy
Scope Hierarchy
Settings are organized in a three-level hierarchy. More specific scopes override less specific ones:
Resolution Example:
For property feature.enabled:
- Check PRINCIPAL scope for user "alice" → not found
- Check TENANT scope for "tenant-acme" → found:
true - Return
true
If not found at any level, returns null or default.
Data Model
SettingEntity
Each setting is stored as an entity:
data class SettingEntity(
val id: String, // Unique setting ID
val tenantId: String, // Tenant context
val scope: ConfigLevel, // APP, TENANT, or PRINCIPAL
val scopeIdentifier: String?, // null for APP, tenantId for TENANT, principalId for PRINCIPAL
val propertyKey: String, // Normalized key (e.g., "feature.enabled")
val propertyValue: String, // Serialized value
val valueType: String, // STRING, INT, LONG, FLOAT, DOUBLE, BOOLEAN, JSON, NULL
val createdAt: Instant,
val createdBy: String?,
val updatedAt: Instant,
val updatedBy: String?,
val deletedAt: Instant?, // Soft delete
val deletedBy: String?
)
Value Types
| Type | Description | Example |
|---|---|---|
STRING | Plain text | "hello" |
INT | 32-bit integer | 42 |
LONG | 64-bit integer | 9223372036854775807 |
FLOAT | 32-bit floating point | 3.14 |
DOUBLE | 64-bit floating point | 3.141592653589793 |
BOOLEAN | True/false | true |
JSON | Serialized JSON | {"key": "value"} |
NULL | Explicit null value | — |
Repository Interface
SettingsRepository
The core repository interface for settings operations:
interface SettingsRepository {
// Read operations
suspend fun findSetting(
tenantId: String,
scope: ConfigLevel,
scopeIdentifier: String?,
key: String
): SettingEntity?
suspend fun findAllByScope(
tenantId: String,
scope: ConfigLevel,
scopeIdentifier: String?
): List<SettingEntity>
suspend fun getAllKeys(
tenantId: String,
scope: ConfigLevel,
scopeIdentifier: String?
): List<String>
suspend fun exists(
tenantId: String,
scope: ConfigLevel,
scopeIdentifier: String?,
key: String
): Boolean
// Write operations
suspend fun upsertSetting(setting: SettingEntity)
suspend fun deleteSetting(
tenantId: String,
scope: ConfigLevel,
scopeIdentifier: String?,
key: String,
deletedBy: String?
)
}
Property Sources
The settings persistence integrates with the IDK's property source abstraction through three scope-specific implementations:
DatabasePropertySource
Base interface extending IDK's PropertySource:
interface DatabasePropertySource : PropertySource<SettingsRepository> {
fun hasProperty(name: String): Boolean
fun <T : Any> getProperty(name: String, targetType: KClass<T>): T?
fun <T : Any> setProperty(key: String, targetType: KClass<T>, value: T?)
fun removeProperty(name: String)
fun getAllPropertyNames(): List<String>
}
Scope-Specific Sources
DatabaseAppPropertySource - Application-wide settings:
@SingleIn(AppScope::class)
class DatabaseAppPropertySource @Inject constructor(
repository: SettingsRepository,
cache: SettingsCache
) : AbstractDatabasePropertySource(
name = "database-app",
configLevel = ConfigLevel.APP,
tenantId = "system", // System-wide
scopeIdentifier = null, // No specific scope
repository = repository,
cache = cache
)
DatabaseTenantPropertySource - Tenant-level settings:
@SingleIn(UserScope::class)
class DatabaseTenantPropertySource @Inject constructor(
@Named("tenantId") tenantId: String,
repository: SettingsRepository,
cache: SettingsCache
) : AbstractDatabasePropertySource(
name = "database-tenant",
configLevel = ConfigLevel.TENANT,
tenantId = tenantId,
scopeIdentifier = tenantId,
repository = repository,
cache = cache
)
DatabasePrincipalPropertySource - User-level settings:
@SingleIn(UserScope::class)
class DatabasePrincipalPropertySource @Inject constructor(
@Named("tenantId") tenantId: String,
@Named("principalId") principalId: String,
repository: SettingsRepository,
cache: SettingsCache
) : AbstractDatabasePropertySource(
name = "database-principal",
configLevel = ConfigLevel.PRINCIPAL,
tenantId = tenantId,
scopeIdentifier = principalId,
principalId = principalId,
repository = repository,
cache = cache
)
Caching
SettingsCache
TTL-based cache for settings to reduce database load:
interface SettingsCache {
fun get(scope: ConfigLevel, scopeIdentifier: String?, key: String): CachedSetting?
fun put(scope: ConfigLevel, scopeIdentifier: String?, key: String, setting: CachedSetting)
fun invalidate(scope: ConfigLevel, scopeIdentifier: String?, key: String)
fun invalidateScope(scope: ConfigLevel, scopeIdentifier: String?)
fun invalidateAll()
}
CachedSetting
Wrapper for cached values with TTL:
data class CachedSetting(
val value: Any?,
val valueType: String,
val cachedAt: Instant,
val exists: Boolean = true // Enables negative caching
)
KacheSettingsCache
Default implementation using the Kache library:
@SingleIn(AppScope::class)
class KacheSettingsCache @Inject constructor() : SettingsCache {
private val cache = Kache<SettingsCacheKey, CachedSetting>(
maxSize = 10_000,
strategy = LruStrategy()
)
private val ttl = 5.minutes // Default TTL
override fun get(scope: ConfigLevel, scopeIdentifier: String?, key: String): CachedSetting? {
val cacheKey = SettingsCacheKey(scope, scopeIdentifier, key)
val cached = cache.get(cacheKey) ?: return null
// Check TTL
if (Clock.System.now() - cached.cachedAt > ttl) {
cache.remove(cacheKey)
return null
}
return cached
}
}
Usage Examples
Reading Settings
@Singleton
class FeatureService @Inject constructor(
private val appSettings: DatabaseAppPropertySource,
private val tenantSettings: DatabaseTenantPropertySource,
private val userSettings: DatabasePrincipalPropertySource
) {
fun isFeatureEnabled(feature: String): Boolean {
// Check user preference first
userSettings.getProperty("feature.$feature.enabled", Boolean::class)?.let { return it }
// Fall back to tenant setting
tenantSettings.getProperty("feature.$feature.enabled", Boolean::class)?.let { return it }
// Fall back to app default
return appSettings.getProperty("feature.$feature.enabled", Boolean::class) ?: false
}
}
Writing Settings
@Singleton
class PreferencesService @Inject constructor(
private val userSettings: DatabasePrincipalPropertySource
) {
fun setUserPreference(key: String, value: String) {
userSettings.setProperty(key, String::class, value)
}
fun setUserTheme(theme: Theme) {
// Store complex object as JSON
userSettings.setProperty("ui.theme", Theme::class, theme)
}
}
Managing Tenant Configuration
@Singleton
class TenantConfigService @Inject constructor(
private val repository: SettingsRepository
) {
suspend fun configureTenant(tenantId: String, config: TenantConfig) {
// Store tenant-level settings
repository.upsertSetting(SettingEntity(
id = UUID.randomUUID().toString(),
tenantId = tenantId,
scope = ConfigLevel.TENANT,
scopeIdentifier = tenantId,
propertyKey = "branding.logo-url",
propertyValue = config.logoUrl,
valueType = "STRING",
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
))
repository.upsertSetting(SettingEntity(
id = UUID.randomUUID().toString(),
tenantId = tenantId,
scope = ConfigLevel.TENANT,
scopeIdentifier = tenantId,
propertyKey = "limits.max-credentials",
propertyValue = config.maxCredentials.toString(),
valueType = "INT",
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
))
}
suspend fun getTenantConfig(tenantId: String): TenantConfig {
val settings = repository.findAllByScope(tenantId, ConfigLevel.TENANT, tenantId)
return TenantConfig(
logoUrl = settings.find { it.propertyKey == "branding.logo-url" }?.propertyValue,
maxCredentials = settings.find { it.propertyKey == "limits.max-credentials" }
?.propertyValue?.toIntOrNull() ?: 100
)
}
}
Database Schema
PostgreSQL
CREATE TABLE config_setting (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
scope TEXT NOT NULL, -- 'APP', 'TENANT', 'PRINCIPAL'
scope_identifier TEXT,
property_key TEXT NOT NULL,
property_value TEXT,
value_type TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_by TEXT,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_by TEXT,
deleted_at TIMESTAMP WITH TIME ZONE,
deleted_by TEXT,
UNIQUE (tenant_id, scope, scope_identifier, property_key)
);
CREATE INDEX idx_config_setting_scope ON config_setting(tenant_id, scope, scope_identifier);
CREATE INDEX idx_config_setting_key ON config_setting(property_key);
MySQL
Uses DATETIME(6) for timestamps instead of TIMESTAMP WITH TIME ZONE.
SQLite
Uses TEXT for timestamps in ISO8601 format.
Configuration
Properties
Configure the settings persistence via application properties:
sphereon:
persistence:
settings:
dialect: postgresql # postgresql, mysql, sqlite
cache:
enabled: true
ttl-seconds: 300 # 5 minutes
max-size: 10000
Database Connection
The settings repository uses the same database routing as other persistence modules:
@Singleton
class SettingsRepositoryProvider @Inject constructor(
private val router: TenantDatabaseRouter
) {
suspend fun getRepository(tenantId: String): SettingsRepository {
val database = router.getDatabaseForTenant(tenantId)
return when (database.dialect) {
DatabaseDialect.POSTGRESQL -> PostgresSettingsRepository(database)
DatabaseDialect.MYSQL -> MysqlSettingsRepository(database)
DatabaseDialect.SQLITE -> SqliteSettingsRepository(database)
}
}
}
Best Practices
Use appropriate scopes - Store settings at the most appropriate level. User preferences at PRINCIPAL, organization settings at TENANT, system defaults at APP.
Cache invalidation - Call invalidate() or invalidateScope() when settings change to ensure consistency across instances.
Key naming conventions - Use dotted notation for hierarchical keys: feature.oauth.enabled, branding.colors.primary.
Type consistency - Always use the same type for a key. Changing types can cause deserialization errors.
Audit trail - Populate createdBy, updatedBy, and deletedBy for compliance and debugging.
Default values - Always provide sensible defaults in your application code; don't rely on database values existing.