← All posts

Designing Feature Flags That Don't Require Redeployments to Change

Feature flags sound simple until you design them seriously. A boolean switch — feature on or off — is the most obvious implementation, but it’s almost immediately insufficient. Teams want string variants for A/B tests, numeric values for percentage rollouts, JSON payloads for complex configurations. They want per-environment state, targeting rules that evaluate based on user attributes, and gradual rollouts that don’t require code changes.

Smpl Flags is our design for a flag system that starts simple and scales to these requirements. This post covers the core decisions: flag types, rule evaluation, the data model, and a few things we’d do differently.

Four Flag Types

We support four flag types: BOOLEAN, STRING, NUMERIC, and JSON.

BOOLEAN is the obvious one — it represents “is this feature on or off.” STRING and NUMERIC add the ability to return a value, not just a state, which is what A/B testing and experiment infrastructure needs. JSON lets you return structured configuration as a flag value — useful when a feature’s behavior is parameterized by more than a single scalar.

Each type can be either constrained or unconstrained:

  • A constrained flag defines an explicit set of valid values (variants). A constrained STRING flag with variants ["control", "treatment-a", "treatment-b"] can only return one of those three values. The console validates that targeting rules only produce defined variants.

  • An unconstrained flag can return any value of its type. Useful when the value space is open-ended — a numeric flag for a timeout threshold, a JSON flag for experiment configuration that evolves during the experiment.

The constrained/unconstrained distinction came from real experience with flags systems. Unconstrained string flags are prone to typos in targeting rules — a rule that’s supposed to return "treatment-a" but returns "treatement-a" (note the typo) is a silent bug. Constrained flags catch this at configuration time.

JSON Logic for Rules

Targeting rules determine what value a flag returns for a given evaluation context. Our rule format is JSON Logic.

JSON Logic is an open specification for expressing logical conditions and computations as JSON data. A rule that says “return ‘treatment’ if the user’s plan is ‘enterprise’ or if the user ID is in a specific list” looks like:

{
  "or": [
    {"==": [{"var": "user.plan"}, "enterprise"]},
    {"in": [{"var": "user.id"}, ["user-123", "user-456", "user-789"]]}
  ]
}

Why JSON Logic over a custom DSL? A few reasons.

Serializable. Rules are JSON data, not code. They can be stored in a database column, transmitted over HTTP, and evaluated without a parser for a custom grammar. This matters because rules need to travel from the server to SDK clients for local evaluation.

Evaluators exist in every language. We support six SDK languages. A custom DSL would require implementing a parser and evaluator in each. JSON Logic evaluators already exist for Python, TypeScript, Go, Java, C#, and Ruby. We use the existing implementations, vet them for correctness, and move on.

Familiar to operators. JSON Logic is a known quantity. Operators who’ve used it elsewhere recognize it immediately. The alternative — a proprietary expression language — requires building documentation, training, and debugging tooling from scratch.

The trade-off: JSON Logic has a learning curve for non-technical users. The console provides a UI rule builder that generates JSON Logic without requiring operators to write it by hand. The raw JSON Logic is accessible for power users who need to express complex conditions the UI doesn’t expose.

The Data Model

Flags are stored in a single table. Each row is a flag; its full configuration — targeting rules, per-environment state, type information — is stored in a JSONB data column.

Why not a relational model with separate tables for rules, environments, and values? Two reasons.

First, a flag’s configuration is read and written as a unit. When you update a targeting rule, you’re updating the whole flag. There’s no scenario where you want to update a single rule without reading the rest of the flag’s state — you need the current state to validate that the update is consistent. A JSONB column with the full state maps naturally to this access pattern.

Second, the structure of a flag’s data evolves with the product. Adding a new field (flag tags, last-modified-by, evaluation analytics) means adding a key to the JSONB blob, not adding a column and running a migration. For a product that’s still maturing, this flexibility is valuable. We can add features without touching the schema.

The JSONB column’s structure is validated by the service’s Pydantic models. The database stores freeform JSONB; the application enforces structure at the application layer. This is the right division: databases are good at storing data reliably; application code is good at expressing complex validation rules.

Auto-Discovery

Flags are declared in code but managed in the console. When an application calls client.flags.get("checkout-v2") and no flag with that key exists in the account, the SDK reports the access to the flags service. The service creates a flag record in “discovered” state — not yet managed, but visible in the console.

The auto-discovery pattern means you don’t need to manually register flags before deploying code. You write the code, deploy it, and the console shows you what flags were accessed and their current state. From there, you can “manage” the flag (give it explicit targeting rules) or leave it in discovered state (where it returns the default value defined in the calling code).

This reduces the ceremony around flag adoption. The barrier to adding a flag to your code is nearly zero.

Per-Environment State

Flags have per-environment state. A flag that’s true in staging and false in production is the common case during a gradual rollout. The flags service stores the state for each environment the account has configured, as a map within the flag’s JSONB data.

When an SDK evaluates a flag, it includes the environment name in the evaluation context. The service returns the flag’s state for that specific environment. SDKs receive real-time updates for the environments they’re interested in — a WebSocket connection notifies connected clients when any flag in their environment changes.

What We’d Revisit

Audit history for flag changes. Right now, if a flag value changes and something breaks, you can see the current state but not who changed it or what it was before. We’re building audit history as a platform capability (see our post on Smpl Audit) — flags will be among the first resources that get full version history.

Percentage rollout. We support targeting rules based on user attributes, but we don’t yet have a native “roll out to 10% of users” control that uses consistent hashing to ensure the same user always sees the same variant. This requires the evaluator to have access to a stable user ID and implement the hash bucketing. It’s on the roadmap.

Dependency tracking. A production outage caused by a flag state change would be easier to debug if we tracked which flag evaluations coincided with which application errors. We don’t currently instrument flag evaluations beyond the raw evaluation count.

Smpl Flags supports BOOLEAN, STRING, NUMERIC, and JSON flag types with optional value constraints, JSON Logic targeting rules, and per-environment state. SDKs evaluate flags locally with real-time updates over WebSocket.