Feature flags
A production-grade flag system living under utils/featureflag: typed variants, targeting rules, reusable segments, percentage rollouts, an admin API with an audit trail, and a client bootstrap endpoint. It is a deletable battery — nothing in the rest of the app requires it until you reach for it.
The domain is deliberately tight and well-typed; evaluation is OpenFeature-shaped (a value plus a reason), and reads never throw — a missing flag, a type mismatch, or a backend hiccup all fall back to the caller’s default.
Domain model
Section titled “Domain model”A FeatureFlag is a key, a typed default, an enabled switch, a clientExposed flag, and an ordered list of rules:
final case class FeatureFlag( id: FlagId, key: FlagKey, description: FlagDescription, enabled: Boolean, defaultValue: FlagVariant, clientExposed: Boolean, rules: List[Rule], createdAt: Instant, updatedAt: Instant)
enum FlagVariant: // the value a flag resolves to; the type is fixed per flag case BoolVariant(value: Boolean) case StringVariant(value: String) case IntVariant(value: Int) case JsonVariant(value: Json)The vocabulary, smallest to largest:
- Variant (
FlagVariant) — the value a flag resolves to, in one of four types: boolean, string, int, or JSON. A flag’s type is fixed: its default and every rule outcome must produce the same variant type. - Condition (
RuleCondition) — a single predicate evaluated against the context. Either an attribute comparison (StringEquals/In/Contains/StartsWith,IntEquals/GreaterThan/LessThan,BoolEquals) orSegmentMatch(name). - Segment — a named, reusable list of conditions (e.g.
premium-wines,verified-users). A segment matches when all its conditions hold. Rules reference it by name viaSegmentMatch, so one audience definition is maintained in one place and reused across many flags. - Outcome (
RuleOutcome) — what a matched rule yields: aFixedValue(variant), or aPercentageRollout(percentage, seed, onMatch)that hashes the targeting key into a bucket and returnsonMatchfor the chosen fraction (and the flag’s default for everyone else). - Rule — a
(conditions, outcome)pair with aposition. A flag’s rules are evaluated inpositionorder and the first whose conditions all match wins; its conditions are AND-ed. - Feature flag (
FeatureFlag) — the aggregate above: a typeddefaultValue, anenabledmaster switch, aclientExposedflag (does it ride the client bootstrap?), and the orderedrules. Evaluation returns the first matching rule’s outcome; if the flag is disabled or no rule matches, thedefaultValue.
Two more types describe a single evaluation rather than the flag’s configuration:
- Evaluation context (
EvaluationContext) — the input: atargetingKeyandattributes(see below). - Evaluation detail (
EvaluationDetail) — the output: the resolved value plus areason(and, on failure, anerrorCode). The plainevaluateXcalls hand back just the value;evaluateXDetailreturns the whole record.
Every variant-type mismatch is caught at write time: a rule whose outcome variant doesn’t match the flag’s default type is rejected (400), as are duplicate rule positions.
Evaluating a flag
Section titled “Evaluating a flag”Inject FeatureFlagService and use the typed API. Each call takes a caller default, returned when the flag is missing, the call’s type doesn’t match the flag’s variant, or evaluation errors. (A disabled flag is different: it returns the flag’s own configured defaultValue — see the kill-switch note below.)
val ctx = EvaluationContext(TargetingKey(user.id.toString))featureFlagService.evaluateBoolean(FlagKey("new-checkout"), ctx, default = false)// or the detail form, when you want the reason:featureFlagService.evaluateBooleanDetail(FlagKey("new-checkout"), ctx, default = false)// => EvaluationDetail(value, reason, errorCode, errorMessage)evaluateString, evaluateInt, evaluateJson (and their …Detail siblings) round out the surface. reason is one of Disabled, TargetingMatch, Split (a percentage rollout decided the value), Default, or Error; on Error, errorCode is FlagNotFound, TypeMismatch, or General.
Targeting key vs. attributes
Section titled “Targeting key vs. attributes”EvaluationContext has two parts that do different jobs — keep them straight:
targetingKey— a stable identity. It is the only input to percentage bucketing: aPercentageRollouthashesseed + ":" + targetingKey(MurmurHash3) into[0,100), so a given key lands in the same bucket every time and a flag ramps monotonically as the percentage rises. Use the user id; compose it (s"$userId:$tenantId") only when you want independent bucketing per resource.attributes— the facts rules and segments match on (plan,region,email-verified, …). An empty attribute map means segments and attribute conditions have nothing to match, so they never fire. This is where “more than just an id” lives.
EvaluationContext( TargetingKey(user.id.toString), Map(AttributeName("plan") -> AttributeValue("enterprise")))Caching and invalidation
Section titled “Caching and invalidation”Evaluation is fronted by three per-JVM caches (60s TTL): per-key flags, the segment set, and the client-exposed flag list. Writes invalidate synchronously on the instance that made the change and publish a FeatureFlagEvent on the event bus; peers pick it up — through a boot-time bus subscription started as a module LifecycleProvider — and invalidate their own caches (falling back to the TTL if a publish is lost). The subscription registers asynchronously as the node boots, so a freshly started instance may briefly miss a concurrent peer update and serve a cached value until its TTL; this is fail-open by design, not a readiness gate. Cache writes are epoch-guarded so a slow load can’t resurrect a value that was invalidated mid-flight. See cache.md for the runtime.
Admin API
Section titled “Admin API”Behind the basic-auth admin gate, under /admin:
| Method & path | Purpose |
|---|---|
GET/POST /admin/feature-flags |
list / create (409 on key conflict, 400 on validation) |
GET/PUT/DELETE /admin/feature-flags/{key} |
fetch / replace / delete (id, key, createdAt are immutable on update) |
POST /admin/feature-flags/{key}/toggle |
enable/disable without touching rules |
GET /admin/feature-flags/{key}/audit |
paginated audit trail |
POST /admin/feature-flags/{key}/evaluate |
dry-run against an arbitrary context, bypassing caches |
GET/POST /admin/feature-flag-segments, PUT/DELETE …/{name} |
segment CRUD |
Every flag mutation is written in one transaction with its audit entry — actor (the authenticated admin), action, and full before/after snapshots. Deleting a flag detaches its audit rows (ON DELETE SET NULL) rather than dropping them, so the trail survives and stays queryable by key.
Client bootstrap
Section titled “Client bootstrap”GET /v1/feature-flags (authenticated) evaluates every clientExposed flag for the current user and returns a { key: value } map — the frontend fetches it once at startup. The evaluation context keys on the user id, and every scalar field of the AuthContext is flattened into an attribute (so emailVerified is available to segments out of the box); add a field to AuthContext and it shows up automatically.
Worked example: the auction module
Section titled “Worked example: the auction module”Two flags wire feature flagging into the auction domain. Both ship inert — until you create them via the admin API they don’t exist, so evaluation falls back to the caller default and behaviour is unchanged. They are not seeded.
auction.min-bid-increment-pct (IntVariant, default 0) — resolved on every bid in AuctionService.placeBid and passed into the pure Auction.placeBid, which raises the minimum acceptable bid to floor × (100 + pct) / 100. The context carries the auction’s attributes (wine-region, wine-color, wine-vintage, bottle-size), so a premium-wines segment can demand a larger jump on high-end lots — a demonstration that context isn’t always about who is asking.
# require a 5% increment on Bordeaux lotscurl -u admin:admin -X POST localhost:9000/admin/feature-flag-segments -H 'content-type: application/json' -d '{ "name": "premium-wines", "description": "high-end lots", "conditions": [{"StringEquals": {"attribute": "wine-region", "value": "Bordeaux"}}]}'curl -u admin:admin -X POST localhost:9000/admin/feature-flags -H 'content-type: application/json' -d '{ "key": "auction.min-bid-increment-pct", "description": "min bid increment %", "enabled": true, "defaultValue": {"IntVariant": {"value": 0}}, "clientExposed": false, "rules": [{"position": 0, "description": "premium lots step 5%", "conditions": [{"SegmentMatch": {"name": "premium-wines"}}], "outcome": {"FixedValue": {"value": {"IntVariant": {"value": 5}}}}}]}'auction.show-wine-ratings (BoolVariant, clientExposed) — gates the Vivino gateway call in getAuction, which evaluates the flag with a caller default of true, so ratings show out of the box and you create the flag to turn them off. The flag is client-exposed, so it also rides the bootstrap payload for the frontend’s ratings widget. getAuction is unauthenticated, so it evaluates with an anonymous context — a reminder that not every flag is personalised.
Turning a flag “off” means evaluating to
false, not disabling it. Set the flag’s value tofalse(e.g.defaultValueor a rule). Theenabledswitch is not a kill switch: a disabled flag returns its configureddefaultValue, so disablingauction.show-wine-ratingswhile its default istrueleaves ratings on. This holds for every flag —enabled = falsemeans “stop evaluating rules, fall back to the default”, which is only “off” when the default itself is off.
Adding your own flag
Section titled “Adding your own flag”- Pick a key (
[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*— lowercase, with.for hierarchy, e.g.billing.new-dunning). - Evaluate it where the decision is made, with a sensible caller default, building the context from whatever the surrounding code already holds.
- Create it through the admin API (or leave it absent to keep the default). Mark it
clientExposedonly if the frontend needs it.
See testing-guide.md for how the showcase flags are exercised — FeatureFlagServiceSpec, AuctionServiceSpec, and the bootstrap targeting test in FeatureFlagRouterSpec.