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

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:

Settings Scope Hierarchy

Resolution Example:

For property feature.enabled:

  1. Check PRINCIPAL scope for user "alice" → not found
  2. Check TENANT scope for "tenant-acme" → found: true
  3. 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​

TypeDescriptionExample
STRINGPlain text"hello"
INT32-bit integer42
LONG64-bit integer9223372036854775807
FLOAT32-bit floating point3.14
DOUBLE64-bit floating point3.141592653589793
BOOLEANTrue/falsetrue
JSONSerialized JSON{"key": "value"}
NULLExplicit 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.