Featurevisor
See all posts

Featurevisor v4: everything connects, finally

By Fahad Heylaal on

From the beginning, I wanted Featurevisor to express the behaviour of an entire app, or a whole product, as one graph where everything can connect to everything else. With Featurevisor v4.0, I feel I have finally cracked it.

Features, segments and variables can now refer to each other, and you can test how they all fit together before a change ever reaches your users. That is what turns Featurevisor into a product decision engine: a single living, breathing graph that evolves along with you and your teams.

Why connections matter

A feature rarely stands alone. For example:

  • File upload in chat needs chat itself.
  • Payment methods in a checkout depend on the market and on the plan.

In v3, some of these relationships had their own fields and the rest lived in application code. V4 puts them in your definitions, where they are reviewed, linted and tested together.

This post follows one small project with four users:

  • user-1, Dutch, on the free plan
  • user-2, Dutch, on the paid plan
  • user-3 and user-4, German, on the free plan

What's new?

Gate a feature on configuration

chatFileUpload should appear only where liveChat does, and never during maintenance. Put both requirements in one conditions list at the root of the feature:

features/chatFileUpload.yml
description: File upload inside chat
bucketBy: userId
conditions:
- feature: liveChat
- variable: maintenanceMode
operator: equals
value: false
rules:
- key: everyone
conditions: "*"
percentage: 100

Every item must match. liveChat is on for everyone, so all four users get file upload. Switch maintenanceMode to true and file upload goes for every user, without touching the feature.

In v3, requiredFeatures could ask for liveChat, nothing could read the maintenance switch, and rules picked their audience through a separate segments field. V4 uses conditions wherever you select or gate:

  • rules and force entries
  • variable overrides
  • the roots of features and global variables

If your team prefers v3's stricter shape, where rules only name segments, allowedConditions keeps it.

A condition has four kinds of leaf:

  • Attribute leaves read the context.
  • Bare strings are segment keys, such as netherlands.
  • Feature leaves check a flag or a variation.
  • Variable leaves compare a value, optionally at a nested path.

Segments accept the same leaves, so an audience can depend on a feature too. The conditions guide covers every form.

Build a value in layers

Dutch users can pay with iDEAL, and paying accounts can pay by invoice. A Dutch paying account should get both, so add continue: true to the first override. The next matching override then builds on its result:

variables/checkoutSettings.yml
description: Checkout settings
type: object
properties:
currency:
type: string
paymentMethods:
type: array
items:
type: string
defaultValue:
currency: EUR
paymentMethods: [card]
overrides:
- key: netherlands
conditions: netherlands
continue: true
mutate:
paymentMethods:appendUnique: ideal
- key: paying
conditions: payingAccounts
mutate:
paymentMethods:appendUnique: invoice

The four users get these payment methods:

  • user-1: [card, ideal]
  • user-2: [card, ideal, invoice]
  • user-3 and user-4: [card]

Without continue: true, the first match wins and user-2 stops at [card, ideal]. A paid account outside the Netherlands gets [card, invoice], because only paying matches.

appendUnique is one of the mutations that change part of a value instead of repeating all of it. Overrides can also nest, so a child refines its parent's value for a narrower audience.

Hand your app one object

The checkout page reads its flag, its pricing variation and these settings together. Collect them into one global variable with compose:

variables/checkoutView.yml
description: Values for the checkout view
compose:
enabled: { feature: checkout }
pricing: { feature: pricing, variation: true }
settings: { variable: checkoutSettings }

Each property reads its source with that source's own targeting, bucketing and conditions. Read the result like any other global variable:

your-app/checkout.js
const context = {
userId: 'user-2',
country: 'nl',
plan: 'paid',
}
const view = f.getVariable('checkoutView', context)

As for the users:

  • user-2 gets { enabled: true, pricing: 'treatment', settings: { currency: 'EUR', paymentMethods: ['card', 'ideal', 'invoice'] } }.
  • user-4 gets enabled: false and pricing: 'control', with paymentMethods: ['card']. The false flag does not hide the properties next to it.

Good to know:

  • A string makes an alias instead. compose: checkoutSettings returns the same value under another key, which keeps old consumers working while you rename a variable.
  • Compositions and aliases can have their own root conditions and overrides.
  • Every property is evaluated on each read. Composing an experiment can expose users to it even when the page never shows that property, so read the composition guidance first.
  • Older SDKs read a composition as null, so upgrade every consumer before publishing one.

Test how it fits together

The checkout relies on three things agreeing for user-2:

  • the new checkout is on
  • the account counts as paying
  • invoice is offered

No single definition owns that, so write a scenario:

tests/scenarios/checkout.spec.yml
description: Paying Dutch accounts can pay by invoice
context:
userId: user-2
country: nl
plan: paid
checks:
- feature: checkout
enabled: true
- segment: payingAccounts
matches: true
- variable: checkoutSettings
path: paymentMethods
operator: includes
value: invoice

Run it with the rest of your tests, or on its own:

Command
npx featurevisor test --entity-type=scenario

Checks:

  • compare variable values, feature results and segment matches, with a literal or with another evaluated value
  • work inside feature and variable specs too
  • take a key and description, so a failure is easy to find
  • run through the Go, Python, PHP, Ruby, Java, Swift, Rust and Elixir SDKs with their native SDK runners

A feature spec can test the maintenance gate on its own. stickyVariables fixes the switch for one assertion, as setStickyVariables() would in your application:

tests/features/chatFileUpload.spec.yml
feature: chatFileUpload
assertions:
- key: maintenance
description: Maintenance switches off file upload
at: 50
context:
userId: user-1
stickyVariables:
maintenanceMode: true
expectedToBeEnabled: false

stickyFeatures fixes feature results the same way, in every kind of spec.

When a value surprises you, ask for the reason:

Command
npx featurevisor evaluate \
--variable=checkoutSettings \
--context='{"userId":"user-2","country":"nl","plan":"paid"}' \
--explain

The explanation names every override that changed the value:

Output
Explanation: Global variable overrides supplied the value.
Last override: paying
Applied overrides: netherlands; paying

Catalog shows the same relationships while you browse: what each definition depends on, what uses it, and its tests.

Catch mistakes before they ship

A relationship can break in a way no single file shows. Lint checks the whole graph:

  • A cycle across features, segments and variables is an error, including one through a segment.
  • A chain of more than 256 dependency evaluations is an error, because every SDK fails it. Lint warns above 32. See cycles and depth.
  • A regular expression that can take exponential time on short input is an error.
  • Writing - liveChat instead of - feature: liveChat fails, because a bare string is always a segment key.
  • A reference to an archived definition is an error, unless the definition referring to it is archived too. Archived definitions are left out of datafiles, so the reference would stop matching, and inside not it would match everyone.
  • A reference to a deprecated feature or variable is a warning, so you can move off it before it is archived.

The bare string mistake reads like this:

Output
features/chatFileUpload.yml
=> Error: Unknown segment "liveChat". "liveChat" is a feature: use { feature: liveChat }.
Path: conditions.0

Before you archive liveChat, find what still needs it:

Command
npx featurevisor find-usage --feature=liveChat

It lists chatFileUpload, and lint rejects archiving liveChat until that condition is removed or chatFileUpload is archived too. See issue codes for everything lint reports.

Same answer in every language

Everything above works in the JavaScript SDK and in the Go, Python, PHP, Ruby, Java, Swift, Rust and Elixir SDKs, with an OpenFeature provider for each. Supported SDK versions lists the first release of each with v4 support.

  • All SDKs share one public runtime contract for conditions, compositions and dependency failures, so a Go service and a Swift app give a user the same result.
  • A cycle or a chain that is too deep fails the whole call with an error, never a partial value, and reports one evaluation_error diagnostic.
  • V4 SDKs also read v3 datafiles, so you can upgrade your applications first, then the project.

Groups are removed

Groups were meant to keep features in different slots from sharing users, but they could not guarantee it:

  • Each feature was bucketed on its own, so experiments meant to be exclusive could overlap.
  • A positive rule percentage inside a slot could be ignored.

V4 removes them. Two patterns built on conditions replace them:

Convert every Group on the latest v3 CLI, before upgrading SDKs. Converting can change who is in each experiment, even at the same percentages, and an old rule key can keep its slot allocation. Follow Groups removed step by step, and schedule the conversion between experiments.

Upgrade to v4

V4 is a breaking release, including for applications that do not adopt the new configuration features. Before upgrading:

  • Ensure the project CLI can run on Node.js 24 or later.
  • Plan the required conditions migration. Selectors and gates move to conditions when you upgrade the CLI, with no automatic conversion.
  • Replace every Group, as described above.
  • Replace the double, date and semver types. Authoring types follow JSON Schema, so fractional numbers are number, and timestamps and versions are strings with format: date-time or format: semver.
  • Rename sticky to stickyFeatures in feature specs, and remove references to archived definitions.
  • Review the other CLI changes, such as attribute directories, custom parsers and generated variable types, and the JavaScript SDK changes, such as getVariableNumber() replacing getVariableDouble().

Follow the v4 migration guide in this order:

  1. Update existing consumers to the latest v3 JavaScript SDK, or a native SDK release that understands requiredFeatures.
  2. Replace legacy feature required definitions with requiredFeatures, then rebuild and publish with the latest v3 CLI, including bundled, cached and on demand datafiles.
  3. Upgrade every application SDK and integration while still serving the prepared v3 datafiles.
  4. Upgrade the project CLI and migrate selectors and gates to conditions. Run lint, project tests and application tests before deploying.
  5. Publish v4 datafiles only after every consumer is upgraded, including bundled, cached, persisted and on demand copies.

SDK versions are independent across languages. Supported SDK versions and each language's migration guide tell you which release each application needs.

Keep configuration in your workflow

V4 builds on the idea behind the first stable release: configuration belongs in your repository, reviewed and tested before it reaches your applications. With conditions, compositions and scenarios, that review now covers how your configuration fits together, not only each file on its own.

Explore the documentation, follow the roadmap, and share your feedback on GitHub.