Configure an application dynamically in runtime. The API.
A problem
Have you ever had a situation where application logging should be enabled immediately without redeploying an application to investigate a problem in production? Or perhaps there is a requirement to raise the thread pool's capacity to handle the increased load during evening spikes in traffic? In mobile applications, such problems are more critical because a release might take several weeks.
Besides that, runtime configuration can be used for things like A/B testing, business "constants" tuning, feature flags, etc. Also, these configuration values usually depend on an application environment. As a particular example, the same application might be deployed on production by different teams in separate clusters (using k8s for instance).
Whatever the case might be, I believe that being able to change behavior on the fly may be a great advantage for many projects to keep up with modern challenges.
In most cases, such a runtime configuration system can be represented as a (env_key, property_key) -> property value
storage with API access.
As I know all Big Tech companies already implemented such systems a while ago. For example, Twitter has a blog post describing this approach. Unfortunately, the dynamic configuration technique is not widespread in the industry of medium-sized companies.
Let's dive deep into one efficient implementation that can be expressed in a programming language of your choice.
API
It's not very convenient to query a configuration database each time we want to receive a configuration update, right? It should be something readable and easy to use.
The basic requirements are:
- Access each property individually
- Dynamic resolution
- Converters to support a mapping between
property value
text and domain-specific object in a programming language - A method to subscribe for property changes
In the code snippet below we can tune the card.newAlgorithmEnabled
property in runtime and expect behavior changes in an application:
class CardPaymentProcessor(private configRegistry: Configuration) {
private val newAlgorithmEnabled: ConfProperty<Boolean> = configRegistry.getConfProperty(
name = "card.newAlgorithmEnabled",
converter = Converters.BOOLEAN,
defaultValue = false)
fun processPayment(cardDetails: CardDetails): Result {
return if (newAlgorithmEnabled.getValue()) {
processV2(cardDetails)
} else {
processV1(cardDetails)
}
}
}
And logger level changes:
class LoggerConfigurer(configRegistry: Configuration) {
private val logLevel: ConfProperty<String> = configRegistry.getConfProperty(
name = "bank-app",
converter = Converters.STRING,
defaultValue = "INFO")
init {
logLevel.subscribe { logLevel ->
changeLogLevel(logLevel)
}
changeLogLevel(logLevel.getValue()) // initial value
}
}
As we can see in these snippets, it's easy to represent almost anything by using a ConfProperty
interface:
interface ConfProperty<T> {
fun getValue(): T
fun subscribe(listener: Subscriber<T>): Subscription
}
interface Subscription {
fun unsubscribe()
}
Challenges and conclusions
This approach is beneficial but it might increase the complexity of your application dramatically. For instance, feature flags can hide dead code branches and also combinatorial complexity of test cases. To prevent this, obsolete feature flags should be removed as soon as possible.
As with any tool, a runtime configuration should be used wisely.
The backend implementation details will be covered in the next part.