Anıl Şenocak

This project demonstrates how to build a Spring Boot application (using Kotlin) that supports dynamic configuration powered by Redis. It features a custom refresh mechanism that automatically updates @Value annotated fields in your beans whenever their corresponding values change in Redis.
Dynamic config refresh: No need to restart your app when configs change. Redis keyspace notifications: Real-time updates as soon as values change in Redis. No Spring Cloud Config: Lightweight, no extra dependencies. Automatic detection: Scans all beans for @Value fields and keeps them in sync. REST API: Easily get/set config values. Advance Reflection API: Uses Reflection API to dynamically update fields at runtime.
Scan fields: On startup, the app finds all fields annotated with @Value. Register for refresh: These fields are registered for updates. Listen to Redis: The app subscribes to Redis keyspace notifications. Auto-update: When a value changes in Redis, the app updates the corresponding field in real time.
Run Redis Cluster locally (or use Docker):
version: '3.7'
services:
redis-single-node-cluster:
image: docker.io/bitnami/redis-cluster:7.0
environment:
- 'ALLOW_EMPTY_PASSWORD=yes'
- 'REDIS_CLUSTER_REPLICAS=0'
- 'REDIS_NODES=127.0.0.1 127.0.0.1 127.0.0.1'
- 'REDIS_CLUSTER_CREATOR=yes'
- 'REDIS_CLUSTER_DYNAMIC_IPS=no'
- 'REDIS_CLUSTER_ANNOUNCE_IP=127.0.0.1'
ports:
- '6379:6379' # could not change it
redis-stack:
image: redis/redis-stack-server:7.2.0-v6
ports:
- "6382:6379"
healthcheck:
test: [ "CMD", "redis-cli", "--raw", "incr", "ping" ]
docker-compose up -d
Make sure you enabled Redis keyspace notifications by running the following command in your Redis CLI:
redis-cli config set notify-keyspace-events KEA
Configure Redis in application.yml
spring:
data:
redis:
cluster:
nodes: localhost:6379 # Update with your Redis server address
database: 0
timeout: 60000
Configuration default values: The application can be configured through the application.yml file:
app:
message: message1 # Default value for app.message
Start the app:
./gradlew bootRun
Project Build File: Below is the dependencies section of the build.gradle.kts file for this project.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("org.jetbrains.kotlin:kotlin-reflect")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1")
testImplementation("org.mockito:mockito-inline:5.2.0")
testImplementation("org.testcontainers:testcontainers")
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("com.redis:testcontainers-redis")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
Explanation:
Use @Value for Config Injection
@Value("\${app.message:default}")
private lateinit var message: String
The application exposes 3 endpoints:
@RestController
class RedisController(
private val redisTemplate: RedisTemplate<String, String>
) {
@Value("\${app.message:default}")
private lateinit var message: String
@GetMapping
fun get(): String = message
@PostMapping
fun set(): String {
val value = Random().nextInt(100).toString() + "Hello World"
redisTemplate.opsForValue().set("app.message", value)
return value
}
@DeleteMapping
fun del(): Boolean = redisTemplate.delete("app.message")
}
Try the REST API (with JetBrains HTTP Client)
### Returns the current value of the `app.message` configuration property
GET http://localhost:8082
### Sets a new random value for the `app.message` property in Redis
POST http://localhost:8082
### Deletes the `app.message` property from Redis
DELETE http://localhost:8082
Connection Factory and Redis Listener Implement connection to Redis and set up a listener for keyspace notifications. This allows the application to react to changes in Redis data. Default implementation uses Lettuce for Spring Data Redis integration.
Following code block configures the Redis connection and sets up the beans needed for Redis operations and event listening for Lettuce.
@Configuration
class RedisConfig(
private val redisProperties: RedisProperties
){
@Bean
@Profile("!integration-test")
fun connectionFactoryCluster(): LettuceConnectionFactory {
val clusterNodes = redisProperties.cluster?.nodes ?: emptyList()
val clusterConfig = org.springframework.data.redis.connection.RedisClusterConfiguration(clusterNodes)
if (!redisProperties.password.isNullOrEmpty())
clusterConfig.password = RedisPassword.of(redisProperties.password)
return LettuceConnectionFactory(clusterConfig)
}
@Bean
fun redisTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, Any> =
RedisTemplate<String, Any>()
.apply { this.connectionFactory = redisConnectionFactory }
@Bean
fun redisContainer(connectionFactory: RedisConnectionFactory): RedisMessageListenerContainer {
val container = RedisMessageListenerContainer()
container.setConnectionFactory(connectionFactory)
return container
}
}
Explanation:
Enable dynamic refresh of @Value annotated fields in beans when their corresponding values in Redis change that enables dynamic refresh of @Value annotated fields in beans when their corresponding values in Redis change.
/**
* Configuration class that enables dynamic refresh of bean fields annotated with @Value
* when their corresponding values in Redis change. It registers a BeanPostProcessor to
* track such fields, listens for Redis key set events, and updates the fields in real time.
*/
@Configuration
class RefreshScopeConfiguration(
private val redisContainer: RedisMessageListenerContainer,
private val redisTemplate: RedisTemplate<String, String>,
private val environment: ConfigurableEnvironment
){
private val log: Logger = LoggerFactory.getLogger(javaClass)
private val fieldRegistry: MutableMap<String, FieldInfo> = ConcurrentHashMap<String, FieldInfo>()
/**
* Creates a BeanPostProcessor that scans beans for fields annotated with @Value after initialization.
* Registers these fields for dynamic refresh when their corresponding values in Redis change.
*/
@Bean
fun valueAnnotationBeanPostProcessor(): BeanPostProcessor =
object : BeanPostProcessor {
override fun postProcessAfterInitialization(bean: Any, beanName: String): Any {
log.info("Scanning bean: $beanName for @Value annotations")
scanValueAnnotations(bean = bean, beanName = beanName)
return bean
}
}
/**
* Registers a Redis message listener bean that listens for key set and delete events.
* When a relevant event is received, it triggers a refresh of the corresponding bean field.
* Returns the created MessageListener instance.
*/
@Bean
fun redisValueRefreshListener(): MessageListener? {
val messageListener = MessageListener { message: Message, pattern: ByteArray? ->
val body: String = String(message.body)
log.info("Received Redis key set event: $body")
refreshField(key = body)
}
redisContainer.addMessageListener(messageListener, listOf(
ChannelTopic("__keyevent@0__:set"),
ChannelTopic("__keyevent@0__:del"))
)
return messageListener
}
/**
* Scans the given bean for fields annotated with @Value, extracts the property key and default value,
* registers the field for dynamic refresh, and immediately refreshes its value from Redis or environment.
* Logs a message when a field is registered for refresh.
*/
private fun scanValueAnnotations(bean: Any, beanName: String?) {
val clazz: Class<*> = bean.javaClass
for (field: Field in clazz.getDeclaredFields()) {
val valueAnnotation: Value? = field.getAnnotation(Value::class.java)
if (valueAnnotation != null) {
val expression: String = valueAnnotation.value
if (expression.startsWith(prefix = "\${") && expression.endsWith(suffix = "}")) {
// Extract a property key from ${key:defaultValue}
var propertyKey: String = expression.substring(2, expression.length - 1)
var defaultValue: String? = null
if (propertyKey.contains(other = ":")) {
val strings: Array<String> = propertyKey
.split(regex = ":".toRegex())
.dropLastWhile { it.isEmpty() }
.toTypedArray()
propertyKey = strings[0]
defaultValue = environment.getProperty(propertyKey, strings[1])
}
// Register field for refresh
fieldRegistry.put(key = propertyKey, value = FieldInfo(field, bean, defaultValue))
refreshField(key = propertyKey)
log.info("Registered field for refresh: ${field.name} in bean $beanName")
}
}
}
}
/**
* Refreshes the value of a registered field for the given property key.
* Retrieves the latest value from Redis or uses the default if not present,
* converts it to the appropriate type, sets it on the bean's field, and updates the environment property source.
* Logs an error if the refresh fails.
*/
private fun refreshField(key: String) {
val fieldInfo: FieldInfo? = fieldRegistry.get(key)
if (fieldInfo != null) {
try {
val field: Field = fieldInfo.field
val bean = fieldInfo.bean
field.setAccessible(true)
val s: String = (redisTemplate.opsForValue().get(key) ?: fieldInfo.defaultValue) ?: "default"
val convertedValue = convertValue(s, field.type)
field.set(bean, convertedValue)
val overrideSource = MapPropertySource("overrideProps", mapOf(key to convertedValue.toString()))
environment.propertySources.addFirst(overrideSource)
log.info("Refreshed field: ${field.name} with value: $convertedValue for key: $key")
} catch (e: Exception) {
log.error("Error refreshing field for key: $key, error: ${e.localizedMessage}")
}
}
}
/**
* Converts the given string value to the specified target type.
* Supports String, Int, Long, Boolean, and Double types.
* Throws IllegalArgumentException for unsupported types.
*/
private fun convertValue(value: String, targetType: Class<*>?): Any? =
when (targetType) {
String::class.java -> value
Int::class.java, Int::class.javaPrimitiveType -> value.toInt()
Long::class.java, Long::class.javaPrimitiveType -> value.toLong()
Boolean::class.java, Boolean::class.javaPrimitiveType -> value.toBoolean()
Double::class.java, Double::class.javaPrimitiveType -> value.toDouble()
else -> throw IllegalArgumentException("Unsupported type: $targetType")
}
}
/**
* Holds metadata for a field annotated with @Value, including the field reference,
* the bean instance containing the field, and the default value if specified.
*/
private data class FieldInfo(
val field: Field,
val bean: Any?,
val defaultValue: String?
)
Scans all beans for @Value fields and registers them for refresh. Listens for Redis key changes and updates the corresponding bean fields in real time. Handles type conversion and default values.
Dynamic configuration is ideal for:
If you’re looking for a simple, robust way to manage dynamic configuration in your Spring Boot apps, give this project a try! Contributions and feedback are welcome.
Full code example can be found in github