Concepts
Initialization
During initialization the Feature Toggles want to synchronize with the central state. Another goal is to make sure that current validation rules are respected in all cases and retroactively applied to the central state.
Initialization broadly has this workflow:
- read and process the configuration
- validate fallback values and warn about invalid fallback values
- if Redis cannot be reached:
- use fallback values as local state and stop
- if Redis is reachable:
- read state and filter out values inconsistent with validation rules
- use validated Redis values if possible or, if none exist, fallback values as local state
- subscribe to future updates from Redis
After initialization, usage code can rely on always getting at least the fallback values (including invalid values) or, if possible, validated values from Redis.
Single Key Approach
Single Key |
The current implementation uses a single Redis key of type hash
to store the state of all toggles for one unique
name. A unique name is usually associated with a single app, but the library also supports the case where multiple apps
with the same configuration use the same unique name.
In the diagram you can see both examples, app 1 has a partner app, that uses the same unique key and all instances of both apps, will synchronize with Redis. On the other hand app 2 is alone, which is the most common use-case.
Scoping
In their easiest use-cases, the Feature Toggles describe server-level state, which is independent of any runtime context. Meaning the feature toggle’s value will be the same for any request, any tenant, any user, any code component, or any other abstraction layer. In practice this is often insufficient.
Scoping is our concept to allow discriminating the feature toggle values based on runtime context information.
Let’s take a very common example, where both user
and tenant
scopes are used.
User and tenant scopes for a feature toggle |
To realize the distinction, runtime scope information is passed to the library as a Map<string, string>
, which results
in a corresponding value check order of descending specificity, e.g.:
getFeatureValue(key)
- root scope, fallback
getFeatureValue(key, { tenant: cds.context.tenant })
tenant
scope, root scope, fallback
getFeatureValue(key, { user: cds.context.user.id, tenant: cds.context.tenant })
user+tenant
scope,user
scope,tenant
scope, root scope, fallback
getFeatureValue(key, { tenant: cds.context.tenant, user: cds.context.user.id })
user+tenant
scope,tenant
scope,user
scope, root scope, fallback
The root scope is always the least specific or broadest scope and corresponds to not specifying any particular scope information. Now, the framework will go through these potential values in this order and check if any of them have been set. The first value that has been set stops the chain and is returned to the caller.
With this setup, we can change the resulting value for anyone with tenant t1
, and no other, more specific scopes,
by using
changeFeatureValue(key, "new value for t1", { tenant: "t1" })
And we could change the behavior again with for the more specific user john
and tenant t1
, by using
changeFeatureValue(key, "new value just for john within t1", { user: "john", tenant: "t1" })
As we can see in the precedence check order, if we had just set changeFeatureValue(key, "new value for john", { user: "john" })
,
then it depends on the order used in the getFeatureValue
call, whether the user
scope is evaluated before
the tenant
scope.
Note that the scoping concept is not opinionated about which particular information you use to discriminate a feature
toggle’s value at runtime. There is a practical limit that only 4 scopes can be used at once, because the precedence
checks grow exponentially in the number of scopes. But other than that, you can use any 4 strings. In other words, the
scopes need not be tenant
or user
, they could be code component information, like the __filename
, or whatever
seems natural for you to use and is easy to predict for the user-group where changes should occur.