FeatureFlag, FlagEvaluator, TargetingRule, and FlagRepository as the core classes, implementing percentage-rollout and user-targeted evaluation, and the local-caching-vs-central-query latency/freshness trade-off.
Published September 23, 2026
class FeatureFlag { String key; boolean enabled; List<TargetingRule> rules; int rolloutPercentage; }
interface TargetingRule { boolean matches(User user); }
interface FlagEvaluator { boolean isEnabled(String flagKey, User user); }
interface FlagRepository { FeatureFlag getFlag(String key); }
Separating TargetingRule (WHO a flag applies to) from the flag's simple enabled/rolloutPercentage fields lets targeting logic grow in sophistication (a specific user list, an account-tier rule, a geographic rule) without changing FeatureFlag's own structure — each rule type is its own small implementation behind one interface.
class PercentageRolloutEvaluator implements FlagEvaluator {
public boolean isEnabled(String flagKey, User user) {
FeatureFlag flag = repository.getFlag(flagKey);
if (!flag.enabled) return false;
int bucket = Math.abs((flagKey + user.getId()).hashCode()) % 100; // deterministic per user+flag
return bucket < flag.rolloutPercentage;
}
}
The critical correctness property here: hashing flagKey + userId (not a random number generated fresh each time) is what makes the SAME user consistently get the SAME rollout decision for a given flag across repeated evaluations — a user who's "in" the 20% rollout stays in it on every subsequent request, rather than flickering between enabled/disabled randomly, which would produce a genuinely broken, inconsistent user experience.
class CompositeFlagEvaluator implements FlagEvaluator {
public boolean isEnabled(String flagKey, User user) {
FeatureFlag flag = repository.getFlag(flagKey);
if (!flag.enabled) return false;
if (flag.rules.stream().anyMatch(rule -> rule.matches(user))) return true; // explicit override
return percentageRolloutEvaluator.isEnabled(flagKey, user); // fall through to percentage logic
}
}
A common real requirement: an explicit targeting rule ("always on for internal employees, regardless of the rollout percentage") should OVERRIDE the percentage-based decision — checking explicit rules first, and only falling through to percentage-rollout logic if no rule matches, gives predictable, layered precedence rather than an ambiguous combination of both mechanisms.
Always query central service: every evaluation is always up-to-date, but adds a
network round-trip (and a new failure mode) to EVERY flag check
Local cache, periodic refresh: fast (in-memory check), but a flag TOGGLED centrally
takes up to the refresh interval to actually take effect everywhere
This is a genuine latency-vs-freshness trade-off, directly connecting to Health Checks' broader essential-dependency reasoning: a feature flag check happening on a hot request path (evaluated on every single request) cannot reasonably afford a network call per evaluation — local caching with periodic background refresh (or a push-based update via the Observer pattern used in Design a Config Management Client) is the standard, practical answer, accepting a bounded propagation delay for a toggle change in exchange for evaluation staying fast and not adding a new failure dependency to every single request.
Q: What happens to flag evaluation if the local cache hasn't been populated yet (a fresh service instance just started)? A: A sensible DEFAULT VALUE per flag (baked into the flag's own definition, defaulting to 'off' for a new feature) is needed for exactly this gap — evaluating against a not-yet-populated cache should fail safely to the flag's stated default, not throw an error or silently treat every flag as enabled.
Q: How would you support an emergency 'kill switch' that needs to take effect immediately, bypassing the normal refresh interval? A: A push-based invalidation mechanism (the flag service notifying subscribed instances immediately on a critical change, rather than waiting for the next periodic poll) is the standard answer for this specific case — most feature flag platforms support exactly this dual mode: periodic refresh for normal changes, immediate push for emergency ones.
Q: Does hashing flagKey+userId for percentage rollout have any weaknesses?
A: A weak or poorly-distributed hash function could produce uneven bucketing (some percentage ranges getting disproportionately more users than others) — a well-distributed hash (like a standard cryptographic or MurmurHash-style function, rather than relying purely on Java's default hashCode(), which isn't guaranteed to distribute evenly) is the more correct production choice.
Q: How does this relate to Design a Config Management Client, covered next? A: A feature flag system IS, structurally, a specialized config management client — flags are a specific KIND of dynamically-updatable configuration value, and the caching/freshness trade-off and Observer-based update propagation discussed there apply directly to feature flags too; many real systems build feature flagging as a thin layer on top of a more general config-management foundation rather than as an entirely separate system.