Anıl Şenocak Anıl Şenocak
Yazılım Mühendisi
Cover image
2026-07-09 0 toplam yorum
Supercharge Your Spring Boot Apps: Dynamic Configuration with Redis and Kotlin

🚀 What Is RedisConfig?

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.

🛠️ Key Features

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.

⚙️ How Does It Work?

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.

🏗️ Technologies Used

  • Redis server (local or remote)
  • JDK 21 or later
  • Kotlin 1.9.25
  • Spring Boot 3.4.4
  • Spring Data Redis

🚦 Getting Started

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

🧑‍💻 Code Practices

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:

  • Uses Kotlin 1.9.25 and Spring Boot 3.4.4 for modern language and framework features.
  • Includes dependencies for web, Redis, AOP, and reflection support.
  • Sets Java toolchain to version 21 for the latest language features and performance.
  • Configures testing using JUnit5, Mockito, and Testcontainers.
  • testImplementation dependencies are only needed for testing, including Spring Boot test starter, Kotlin test with JUnit5, Mockito for mocking, and Testcontainers for integration tests with containers (including Redis).
  • testRuntimeOnly is for dependencies only needed at test runtime, here the JUnit platform launcher. This build file ensures your project is ready for production-grade Spring Boot development with Kotlin and Redis.

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

🛠️ Redis and Dynamic Config Code Explained

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:

  • connectionFactoryCluster(): Configures a cluster connection factory for Spring Data Redis.
  • redisTemplate(): Template for Redis operations.
  • redisContainer(): Enables listening to Redis events (like key changes).

RefreshScope Configuration

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.

  • It scans beans for fields annotated with @Value and registers them for refresh.
  • It listens to Redis key set events (keyevent@0:set) and updates the registered fields with new values from Redis.
  • Field values are converted to the correct type before being set.
  • Uses a ConcurrentHashMap to keep track of fields and their beans for refresh.
  • The FieldInfo class holds metadata about each registered field.
  • This allows properties injected via @Value to be updated at runtime without restarting the application, based on Redis events.
  • Following is the code for the RefreshScopeConfiguration class, which is a key part of this project. Enables dynamic configuration refresh by scanning for @Value fields and updating them when Redis keys change using Java Reflection API.
/**
 * 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?
)

Explanation:

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.

🤔 When Should You Use Dynamic Config?

Dynamic configuration is ideal for:

  • Feature toggling without redeployments
  • Changing thresholds, limits, or messages on the fly
  • Multi-tenant or SaaS applications with per-tenant settings
  • Blue/green or canary deployments
  • Emergency switches (kill switches)

🧩 Alternatives & Trade-offs

  • Spring Cloud Config: Great for large, distributed systems but adds operational complexity.
  • Consul/etcd/ZooKeeper: Powerful, but may be overkill for simple use cases.
  • Environment variables: Simple, but require restarts and lack real-time updates.
  • Database-backed config: Flexible, but may introduce latency and complexity.
  • This Redis-based approach is best for lightweight, fast-changing config needs.

📚 Further Reading & Resources

  • Spring Boot Externalized Configuration: https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html
  • Spring Data Redis Documentation: https://docs.spring.io/spring-data/redis/docs/current/reference/html/
  • Redis Keyspace Notifications: https://redis.io/docs/manual/keyspace-notifications/
  • Kotlin Reflection: https://kotlinlang.org/docs/reflection.html

💡 Why Use This Approach?

  • Simplicity: No need for a full config server.
  • Performance: Redis is fast and lightweight.
  • Flexibility: Works in any environment — cloud or on-prem.

📝 Final Thoughts

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.

🔮 What’s Next?

  • Add support for more property sources: Integrate with other config stores (e.g., Consul, etcd) for even more flexibility.
  • Fine-grained refresh: Enable selective refresh for specific beans or properties.
  • Security: Add authentication/authorization for config changes via the REST API.
  • Monitoring: Expose metrics for config refresh events and Redis connectivity.
  • Production hardening: Add tests, error handling, and documentation for real-world deployments.
  • Custom annotation for dynamic config: Create a custom annotation (e.g., @DynamicConfigValue) to replace direct use of @Value, making dynamic config fields more explicit and easier to manage.
  • DB support to capture changes using change data capture (CDC): Integrates with Debezium and Kafka to listen for database changes in real time.

Full code example can be found in github

Yorumlar
Henüz yorum yok. İlk yorumu sen yaz.
Yorum Bırak
Adınız (isteğe bağlı)
Yorumunuzu yazın...
0/500