DevOps Lesson 29 of 56

Building a Vendor-Neutral Feature Flag Platform with OpenFeature and flagd

In a nutshell

Imagine every light in your house wired back to one universal dimmer panel by the front door. You can dim the kitchen to 20%, flip the garage off from across the room, and — crucially — swap the whole panel for a fancier model later without rewiring a single bulb, because every light just speaks “dimmer,” not “this exact brand of switch.” A feature flag is that dimmer for a piece of software: a runtime switch that decides whether a given user sees a new feature, with no code deploy needed to flip it.

OpenFeature is the universal wiring standard. It is a vendor-neutral specification (a CNCF Incubating project) that gives your code one small, stable way to ask “is this feature on for this user?” — and hides which flag service actually answers. Your application calls getBooleanValue('new-checkout', false, user) and never learns whether the reply came from an open-source daemon you run yourself or a paid SaaS. Swap the backend and not one line of feature code changes. flagd is OpenFeature’s own free, self-hostable answer-engine (the “flag daemon”); this lesson builds a full platform on it.

Why a beginner should care: the moment you separate deploying code from releasing a feature, a whole category of scary Friday-night launches disappears. You ship the code dark (off for everyone), then turn it on for 1% of users, watch the graphs, widen to 10%, 50%, 100% — and if it misbehaves, you flip one switch and it is off in seconds, no rollback deploy. That is progressive delivery, and feature flags are the engine that drives it.

Level: Advanced · Time: ~28 min

Before this lesson, it helps to know: the basic Kubernetes objects (Deployment, Service, ConfigMap), that an SDK is just a library your code imports, and roughly what a JSON config file is. If deployment strategies are new, skim Deployment strategies: rolling, blue-green, canary, flags first; the flag-driven release model here is the natural next step after Trunk-based development with feature flags.

After this lesson you will be able to:

OpenFeature platform architecture: app code calls a vendor-neutral evaluation API, the OpenFeature SDK runs hooks and hands off to a swappable provider, flagd on Kubernetes resolves the flag in-memory from a GitOps flags.json, and the fractional operator buckets the user into a stable percentage variant with a kill switch

Read it left to right: your app calls the vendor-neutral evaluation API, the OpenFeature SDK runs hooks and hands off to whichever provider is wired in, flagd (on Kubernetes) resolves the flag in-memory from a GitOps-managed flags.json, and the fractional rule buckets the user into a stable percentage variant — with a kill switch that flips the flag back to off on the next hot-reload, no deploy.

Most feature-flag adoptions start as a SaaS line item and end as a lock-in problem. The SDK is proprietary, evaluation semantics are undocumented, and three years later you have 400 call sites that all import LDClient. Migrating means a rewrite. OpenFeature breaks that coupling: it is a CNCF Incubating specification that standardizes the evaluation API so application code never names a vendor, and providers plug in underneath. Pair it with flagd – the project’s own reference flag daemon – and you get a fully open stack you can run yourself, with a clean migration path to a managed provider if you ever want one.

This article builds that platform end to end: the spec, the flagd deployment topology, real targeting rules with fractional rollouts, SDK wiring with context propagation, hooks for telemetry and audit, a live provider swap, flag governance, and deterministic CI testing.

1. The OpenFeature spec: API, providers, hooks, context

OpenFeature defines four moving parts, and the whole value proposition is that your code only touches the first.

The flow: your code calls the API with a flag key, a default value, and context. The hooks run, the provider resolves, and you get a value – never null. Defaults are mandatory and returned on any error, so a flag backend outage degrades to a known-safe value rather than an exception.

import { OpenFeature } from '@openfeature/server-sdk';

const client = OpenFeature.getClient();

// The default (false) is what you get if flagd is down, the flag is
// missing, or the type mismatches. Failure is always graceful.
const newCheckout = await client.getBooleanValue('new-checkout', false, {
  targetingKey: user.id,
  plan: user.plan,
  region: user.region,
});

Note that the API never mentions flagd. That is the entire point.

2. Deploying flagd: sidecar vs. centralized, and the sync source

flagd is a daemon that reads flag definitions from one or more sync sources and serves evaluations over a gRPC (and HTTP) interface on port 8013. It holds flags in memory and re-evaluates on every call, so reads are sub-millisecond.

You have two topologies:

Topology Latency Blast radius Operational cost
Sidecar (one flagd per pod) Lowest (loopback) Per-pod N containers to schedule
Centralized (a flagd Service) Network hop Shared service One Deployment to run

Start centralized for simplicity; move latency-critical services to sidecars later. The provider interface is identical, so it is a connection-string change.

The sync source is where flagd reads flags from. The common choices:

For a GitOps shop, file sync backed by a ConfigMap is the pragmatic default. Here is a centralized Deployment that reads a mounted file:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: flagd
  namespace: platform-flags
spec:
  replicas: 2
  selector:
    matchLabels: { app: flagd }
  template:
    metadata:
      labels: { app: flagd }
    spec:
      containers:
        - name: flagd
          image: ghcr.io/open-feature/flagd:v0.12.8
          args:
            - start
            - --uri
            - file:/etc/flagd/flags.json
          ports:
            - { name: grpc, containerPort: 8013 }
            - { name: ofrep, containerPort: 8016 } # OFREP HTTP
            - { name: metrics, containerPort: 8014 }
          volumeMounts:
            - { name: flags, mountPath: /etc/flagd, readOnly: true }
      volumes:
        - name: flags
          configMap: { name: flagd-flags }
---
apiVersion: v1
kind: Service
metadata:
  name: flagd
  namespace: platform-flags
spec:
  selector: { app: flagd }
  ports:
    - { name: grpc, port: 8013, targetPort: 8013 }
    - { name: ofrep, port: 8016, targetPort: 8016 }

flagd watches the mounted file and hot-reloads on change. With a ConfigMap, the kubelet propagates updates within its sync period (typically up to ~60s), and flagd picks them up automatically – no restart, no redeploy.

3. Authoring targeting rules: segments, rollouts, fractional bucketing

flagd flags are plain JSON validated against a published schema. Each flag has a state, a set of variants, a defaultVariant, and an optional targeting rule. Targeting uses JsonLogic plus flagd-specific operators.

Three building blocks cover most needs:

  1. Segments – ordered if branches on context, returning a variant.
  2. Percentage rollouts – the fractional operator splits traffic by weight.
  3. Stable bucketingfractional hashes a key so the same user always lands in the same bucket.
{
  "flags": {
    "new-checkout": {
      "state": "ENABLED",
      "variants": { "on": true, "off": false },
      "defaultVariant": "off",
      "targeting": {
        "if": [
          { "in": ["beta", { "var": "groups" }] }, "on",
          {
            "fractional": [
              { "cat": [{ "var": "$flagd.flagKey" }, { "var": "targetingKey" }] },
              ["on", 20],
              ["off", 80]
            ]
          }
        ]
      }
    }
  }
}

Two things are deliberate here.

First, internal users (groups contains beta) always get on, short-circuiting the rollout. Segments evaluate top-down.

Second, the fractional operator’s first argument is the bucketing expression. The weights ["on", 20] and ["off", 80] are relative and need not sum to 100 – flagd normalizes them. flagd hashes the expression with MurmurHash3 into the [0, 100) space and assigns a bucket. By concatenating (cat) the flag key with the targetingKey, a given user gets an independent but stable assignment per flag: stable so they do not flicker between page loads, independent so they are not correlated across unrelated flags. If you omit the first argument, flagd defaults to bucketing on the targetingKey alone – correct for a single flag, but it correlates assignment across every flag, which you rarely want.

To advance a rollout, bump 20 to 50 to 100 in Git. Because bucketing is a stable hash of the same key, everyone already in the on 20% stays on as you widen – the rollout is monotonic, with no user flipped back off.

4. Wiring the SDK with context propagation

The default value is your circuit breaker; provide one on every call. The bigger architectural concern is context propagation – targeting context must follow the request through every layer without being threaded as an argument.

OpenFeature solves this with the transaction context propagator. You set the request’s identity once in middleware, and any evaluation deeper in the call stack inherits it.

import { OpenFeature, AsyncLocalStorageTransactionContextPropagator }
  from '@openfeature/server-sdk';
import { FlagdProvider } from '@openfeature/flagd-provider';

OpenFeature.setTransactionContextPropagator(
  new AsyncLocalStorageTransactionContextPropagator(),
);
await OpenFeature.setProviderAndWait(
  new FlagdProvider({ host: 'flagd.platform-flags.svc', port: 8013 }),
);

// Express middleware: identity set once, inherited everywhere downstream.
app.use((req, _res, next) => {
  OpenFeature.setTransactionContext(
    {
      targetingKey: req.user.id,
      plan: req.user.plan,
      region: req.headers['x-region'] as string,
      groups: req.user.groups,
    },
    () => next(),
  );
});

Now a deeply nested service evaluates flags without ever receiving a user object:

// Three layers down. No user plumbed in -- context is ambient.
async function priceCart(items: Item[]): Promise<number> {
  const client = OpenFeature.getClient();
  const dynamicPricing = await client.getBooleanValue('dynamic-pricing', false);
  return dynamicPricing ? priceDynamically(items) : priceStatically(items);
}

setProviderAndWait blocks until flagd is connected, so you never serve traffic with an unready provider. Use the API surface symmetrically on the client tier – the @openfeature/web-sdk mirrors the same evaluation API in the browser, against the same flagd flags via OFREP.

5. Hooks for telemetry, audit, and tracing

Hooks are where a platform team adds cross-cutting behavior without touching a single feature call site. A hook implements any of before, after, error, finally. Register globally and it runs on every evaluation, everywhere.

This hook emits an OpenTelemetry span event on each resolution and logs an audit record – enough to answer “which variant did user X get for flag Y at time T,” which is exactly the question that surfaces in an incident review.

import { Hook, HookContext, EvaluationDetails, FlagValue }
  from '@openfeature/server-sdk';
import { trace } from '@opentelemetry/api';

export const telemetryHook: Hook = {
  after(ctx: HookContext, details: EvaluationDetails<FlagValue>) {
    const span = trace.getActiveSpan();
    span?.addEvent('feature_flag', {
      // Semantic-convention attribute keys for feature flags.
      'feature_flag.key': ctx.flagKey,
      'feature_flag.provider_name': ctx.providerMetadata.name,
      'feature_flag.result.variant': details.variant ?? 'unknown',
      'feature_flag.result.reason': details.reason ?? 'unknown',
    });
    auditLog.write({
      ts: Date.now(),
      flag: ctx.flagKey,
      targetingKey: ctx.context.targetingKey,
      variant: details.variant,
      reason: details.reason,
    });
  },
  error(ctx: HookContext, err: Error) {
    trace.getActiveSpan()?.recordException(err);
    metrics.increment('flag_eval_error', { flag: ctx.flagKey });
  },
};

// Registered once at boot; every getXxxValue in the codebase is now traced.
OpenFeature.addHooks(telemetryHook);

The attribute keys above follow the OpenTelemetry feature-flag semantic conventions, so any OTel-aware backend renders them as a first-class flag dimension. OpenFeature also ships a maintained @openfeature/open-telemetry-hooks package if you would rather not hand-roll it. Either way, telemetry is now a property of the platform, not a thing 40 teams each remember to add. (If tracing terms like span, attribute, and semantic convention are unfamiliar, see Observability fundamentals: logs, metrics, traces, SLOs.)

6. Swapping providers with zero code changes

The migration test: change the backend, recompile, ship – without editing a single evaluation call. Because the provider is the only vendor-aware seam, this holds.

// flagd today.
await OpenFeature.setProviderAndWait(
  new FlagdProvider({ host: 'flagd.platform-flags.svc', port: 8013 }),
);

// LaunchDarkly tomorrow -- via the OpenFeature LD provider. Same client,
// same getBooleanValue calls, same hooks. Only this line changes.
import { LaunchDarklyProvider } from '@launchdarkly/openfeature-server-node';
await OpenFeature.setProviderAndWait(
  new LaunchDarklyProvider(process.env.LD_SDK_KEY!),
);

// GO Feature Flag instead -- also a drop-in provider.
import { GoFeatureFlagProvider } from '@openfeature/go-feature-flag-provider';
await OpenFeature.setProviderAndWait(
  new GoFeatureFlagProvider({ endpoint: 'https://gofeatureflag.internal' }),
);

OpenFeature also supports named providers bound to domains, so you can migrate incrementally – route the payments domain to LaunchDarkly while everything else stays on flagd:

OpenFeature.setProvider('payments', new LaunchDarklyProvider(key));
const paymentsClient = OpenFeature.getClient('payments'); // bound to LD

The one caveat worth stating plainly: the API contract is portable, but rule authoring is not. flagd’s JsonLogic targeting and LaunchDarkly’s rule builder are different surfaces. The SDK swap is free; you still re-create targeting rules in the new backend. That is a config migration, not a code migration – and it is the difference between a sprint and a quarter.

7. Governance: lifecycle, ownership, stale-flag cleanup

Flags are debt the moment they merge. Without governance you accrue hundreds of permanently-on flags whose removal nobody dares attempt. Encode ownership and intent in the flag definition itself.

flagd ignores unknown keys, so attach metadata at the flag level:

{
  "flags": {
    "new-checkout": {
      "state": "ENABLED",
      "variants": { "on": true, "off": false },
      "defaultVariant": "off",
      "metadata": {
        "owner": "team-checkout",
        "jiraEpic": "CHK-1184",
        "type": "release",
        "createdAt": "2026-05-02",
        "expiresAt": "2026-07-01"
      }
    }
  }
}

Then enforce it in CI. A scheduled job opens a ticket (or fails the build) for any release flag past expiresAt:

#!/usr/bin/env bash
# stale-flag-check.sh -- fail if any release flag is past its expiry.
set -euo pipefail
today=$(date +%F)

jq -r '
  .flags | to_entries[]
  | select(.value.metadata.type == "release")
  | select(.value.metadata.expiresAt < "'"$today"'")
  | "STALE: \(.key) owner=\(.value.metadata.owner) expired=\(.value.metadata.expiresAt)"
' flags.json | tee stale.txt

[ -s stale.txt ] && { echo "Stale release flags found"; exit 1; } || echo "No stale flags"

Distinguish flag types: release flags are temporary and must expire; ops (kill switches) and experiment flags are longer-lived. Only release flags should trip the staleness gate – a permanent kill switch is a feature, not debt. Pair this with a linter that flags evaluation call sites whose flag key no longer exists in flags.json, catching the inverse rot: dead code branching on a deleted flag.

Verify

Confirm the stack end to end before trusting it in production.

# 1. flagd is healthy and serving.
kubectl -n platform-flags get pods -l app=flagd
kubectl -n platform-flags port-forward svc/flagd 8016:8016 &

# 2. Resolve a flag over the OFREP HTTP endpoint -- expect a variant + reason.
curl -s -X POST localhost:8016/ofrep/v1/evaluate/flags/new-checkout \
  -H 'Content-Type: application/json' \
  -d '{"context":{"targetingKey":"user-123","groups":["beta"]}}' | jq .

# 3. Prove stable bucketing: same key, repeated calls, identical variant.
for i in 1 2 3; do
  curl -s -X POST localhost:8016/ofrep/v1/evaluate/flags/new-checkout \
    -H 'Content-Type: application/json' \
    -d '{"context":{"targetingKey":"user-987"}}' | jq -r .variant
done   # -> three identical lines

# 4. Hot reload works: edit the ConfigMap, confirm flagd picks it up.
kubectl -n platform-flags logs -l app=flagd | grep -i "configuration updated"

Expected: step 2 returns {"value": true, "variant": "on", "reason": "TARGETING_MATCH"} for the beta user; step 3 prints the same variant three times (stable hash); step 4 shows a reload log line with no pod restart.

Implementing deterministic CI tests

Flag-driven branches must be testable without a live flagd. OpenFeature ships an in-memory provider for exactly this – you assert both branches deterministically, with zero network.

import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';

describe('checkout flow', () => {
  it('uses the new path when the flag is on', async () => {
    await OpenFeature.setProviderAndWait(new InMemoryProvider({
      'new-checkout': {
        disabled: false,
        variants: { on: true, off: false },
        defaultVariant: 'on',       // force ON for this test
      },
    }));
    expect(await runCheckout(cart)).toEqual(expectedNewBehavior);
  });

  it('falls back to the old path when off', async () => {
    await OpenFeature.setProviderAndWait(new InMemoryProvider({
      'new-checkout': {
        disabled: false,
        variants: { on: true, off: false },
        defaultVariant: 'off',      // force OFF
      },
    }));
    expect(await runCheckout(cart)).toEqual(expectedOldBehavior);
  });
});

Because the same evaluation API resolves against the in-memory provider, the code under test is byte-identical to production – only the provider differs. Run both branches in CI on every PR and a half-rolled-out flag can never hide a broken code path.

Enterprise scenario

A payments platform team at a mid-size fintech ran LaunchDarkly across ~60 services. After an acquisition, a data-residency mandate landed: EU customer evaluations could not transit a US-hosted SaaS, and the audit trail had to live in their own SIEM. Ripping out LaunchDarkly meant touching every call site – months of regression risk on a payments path – so the proposal kept stalling.

The constraint that broke the deadlock: they did not need to leave LaunchDarkly everywhere, only for EU traffic, and only without a code rewrite.

They adopted OpenFeature as a refactor – mechanically replacing ldClient.variation(...) with client.getBooleanValue(...), no behavior change – then used named providers to route by region. EU services bound to a self-hosted flagd; everything else stayed on the LaunchDarkly provider during a phased cutover. The audit requirement was satisfied by a single global hook streaming every resolution to the SIEM, identical across both providers.

// Region decides the backend; the 60 services' evaluation code is untouched.
const region = process.env.DEPLOY_REGION;
const provider = region === 'eu'
  ? new FlagdProvider({ host: 'flagd.eu.internal', port: 8013 })
  : new LaunchDarklyProvider(process.env.LD_SDK_KEY!);

await OpenFeature.setProviderAndWait(provider);
OpenFeature.addHooks(siemAuditHook); // same audit trail, both backends

The migration shipped in six weeks instead of two quarters. The decisive insight: once the vendor lives behind the OpenFeature seam, “which backend” becomes a deployment variable, and residency, audit, and cost become operational knobs rather than rewrites.

Going deeper

The sections above get a platform running. What follows is the material that separates “we use flags” from “we operate a flag platform”: provider lifecycle, the two ways flagd can resolve, the protocol that makes even the provider swappable, experimentation, backend-migration safety nets, and the security and scale edges.

Provider status and events — don’t serve traffic on a cold provider

A provider is not a boolean “connected or not.” OpenFeature models a lifecycle: NOT_READY → READY, and from there STALE, ERROR, or FATAL. setProviderAndWait blocks until READY, which is why it belongs at boot before you accept traffic. But flagd can also change its mind at runtime — a config push, a dropped stream — and the SDK emits events you should handle rather than ignore.

import { OpenFeature, ProviderEvents } from '@openfeature/server-sdk';

// Bust any per-request memoization when the ruleset changes underfoot.
OpenFeature.addHandler(ProviderEvents.ConfigurationChanged, (details) => {
  cache.invalidate();                 // your caches, not the SDK's
  log.info('flag config changed', { flagsChanged: details?.flagsChanged });
});

// STALE means "serving last-known-good"; ERROR means defaults are flowing.
OpenFeature.addHandler(ProviderEvents.Stale, () => metrics.gauge('flags.stale', 1));
OpenFeature.addHandler(ProviderEvents.Error, () => alertOnCall('flag provider ERROR'));

The ConfigurationChanged event carries the set of changed flag keys, which is what the browser @openfeature/web-sdk uses to re-evaluate and re-render only the affected UI. On the server this is your signal to invalidate any cache you layered on top of evaluations. Treat STALE as a warning (still safe, using last-known-good) and ERROR/FATAL as “defaults are flowing” — the exact moment your mandatory default earns its place in every call.

In-process vs RPC: two resolvers, very different failure modes

The flagd provider can resolve flags two ways, and the choice changes both latency and blast radius.

Resolver How it evaluates Latency If flagd blips
RPC (default) Each eval is a gRPC/OFREP call to flagd; flagd is the single evaluator Sub-millisecond (network hop) Provider goes STALE/ERROR, defaults flow
In-process Provider streams the whole ruleset via flagd’s flag-sync gRPC (port 8015) and evaluates locally in your process Sub-microsecond (no hop) Keeps evaluating on last-synced rules; survives the blip
import { FlagdProvider } from '@openfeature/flagd-provider';

// In-process: pull the ruleset over the sync service, evaluate in-app.
await OpenFeature.setProviderAndWait(
  new FlagdProvider({ resolverType: 'in-process', host: 'flagd', port: 8015 }),
);

In-process trades memory (every pod holds the full ruleset) for latency and resilience — evaluation cannot fail on a network partition because there is no network in the hot path. When one flagd would become a fan-out bottleneck for hundreds of in-process pods, put flagd-proxy in front: it multiplexes a single upstream sync into many downstream subscribers. Rule of thumb: RPC for a handful of services and centralized simplicity; in-process (with flagd-proxy) when you need microsecond evaluation at high pod counts.

OFREP — the escape hatch that makes even the provider generic

Section 4 mentioned OFREP in passing; it deserves its own beat. OFREP (the OpenFeature Remote Evaluation Protocol) is a standardized REST contract for remote flag evaluation. Because it is a spec, any OFREP-compliant server can be hit by the generic @openfeature/ofrep-provider (or @openfeature/ofrep-web-provider in the browser) — no vendor-specific provider package at all. flagd serves OFREP on 8016; a growing list of flag vendors do too.

That is portability one level deeper than the provider seam: with the flagd provider you can swap flagd for another provider; with OFREP you can swap the flagd server for any OFREP server while keeping the same generic provider. It is also how the curl calls in the Verify section resolve a flag with no SDK on the wire at all.

Experimentation: pairing exposure with outcome via track()

A percentage rollout tells you who saw a variant; an experiment needs to correlate that exposure with a business outcome (conversion, revenue, latency). OpenFeature’s tracking API closes that loop. Your after hook already logs the exposure (which targetingKey got which variant); client.track(...) records the downstream event against the same identity.

const client = OpenFeature.getClient();

// Exposure is logged by the global after-hook when this resolves.
const newCheckout = await client.getBooleanValue('new-checkout', false);

// ... user completes the purchase ...
client.track('checkout-completed', undefined, { value: 79.9, currency: 'USD' });

An experimentation backend (warehouse-native, GrowthBook, Statsig, or your own) joins the two streams on targetingKey and computes per-variant conversion with statistical significance. The platform contribution is that both signals — exposure and outcome — flow through the same vendor-neutral SDK, so switching experimentation tools is a backend change, not a re-instrumentation.

Multi-provider: dark-launching the flag backend itself

Migrating backends (Section 6) has a hidden risk: does the new backend return the same variant the old one did? @openfeature/multi-provider evaluates across several providers under a strategy and lets you verify that before you cut over.

import { MultiProvider } from '@openfeature/multi-provider';
import { ComparisonStrategy } from '@openfeature/multi-provider';

// Serve flagd's answer, but evaluate LaunchDarkly in parallel and log any
// disagreement — a shadow launch of the migration, with no user impact.
await OpenFeature.setProviderAndWait(
  new MultiProvider(
    [
      { provider: new FlagdProvider({ host: 'flagd', port: 8013 }) },
      { provider: new LaunchDarklyProvider(process.env.LD_SDK_KEY!) },
    ],
    new ComparisonStrategy(/* fallback = first provider */),
  ),
);

Strategies include FirstMatchStrategy (first non-error wins — a resilience pattern), FirstSuccessfulStrategy, and ComparisonStrategy (evaluate all, serve one, flag mismatches). The comparison mode is the safest possible migration: you get a mismatch report before trusting the new rules, which is exactly the “re-author the rules” risk called out in Section 6, made observable.

Security and scale edges

{
  "flags": {
    "hardware-accel": {
      "state": "ENABLED",
      "variants": { "on": true, "off": false },
      "defaultVariant": "off",
      "targeting": {
        "if": [
          {
            "and": [
              { "sem_ver": [{ "var": "appVersion" }, ">=", "2.4.0"] },
              { "starts_with": [{ "var": "region" }, "eu-"] }
            ]
          },
          "on",
          "off"
        ]
      }
    }
  }
}

Version and API caveats

If you are wiring flags into an automated release pipeline (canary analysis, Argo Rollouts, progressive delivery gates), the companion lesson GitHub Actions + Argo CD progressive delivery with policy gates shows the deployment side of the same story.

Practice challenges

Work these in order — each builds on the last, beginner to advanced. Try before opening the solution.

1. (Beginner) Make a call fail safe. A teammate wrote await client.getBooleanValue('new-nav') and it will not compile. Fix it so a flagd outage cannot enable the new navigation.

<details><summary>Solution</summary>

const showNewNav = await client.getBooleanValue('new-nav', false);

The signature requires a default: getBooleanValue(key, defaultValue, context?). Choosing false (the old, safe behavior) means any error — outage, missing flag, type mismatch — degrades to the known-good path. Why: the default is the circuit breaker; the compiler forces you to declare your safe fallback. </details>

2. (Beginner) Resolve a flag with no SDK. Prove flagd works using only curl — no application code — for new-checkout, as a beta user.

<details><summary>Solution</summary>

curl -s -X POST localhost:8016/ofrep/v1/evaluate/flags/new-checkout \
  -H 'Content-Type: application/json' \
  -d '{"context":{"targetingKey":"user-123","groups":["beta"]}}' | jq .
# -> {"value": true, "variant": "on", "reason": "TARGETING_MATCH"}

Why: OFREP is a plain REST contract on port 8016, so any HTTP client can evaluate a flag. This is your fastest smoke test that flagd is serving and your targeting rule matches. </details>

3. (Intermediate) Author a sticky 10% rollout with a beta bypass. Write the targeting for new-checkout: users whose groups include beta always get on; everyone else gets a stable 10% rollout that will not flicker between page loads.

<details><summary>Solution</summary>

{
  "if": [
    { "in": ["beta", { "var": "groups" }] }, "on",
    {
      "fractional": [
        { "cat": [{ "var": "$flagd.flagKey" }, { "var": "targetingKey" }] },
        ["on", 10],
        ["off", 90]
      ]
    }
  ]
}

Why: the segment (in beta) short-circuits top-down; fractional over cat(flagKey, targetingKey) gives a MurmurHash3 bucket that is stable per user and independent per flag. </details>

4. (Intermediate) Widen the rollout without flipping anyone back. The 10% above looks healthy. Take it to 50% such that every user already seeing on keeps seeing it. What changes, and why is it safe?

<details><summary>Solution</summary>

Change only the weights: ["on", 10], ["off", 90]["on", 50], ["off", 50]. The bucketing key (cat(flagKey, targetingKey)) is unchanged, so each user’s hash lands in the same spot on [0,100); widening the on band can only add users, never remove them. Why: stable-hash bucketing makes the rollout monotonic — no user is flipped back off as you widen. </details>

5. (Advanced) Test both branches deterministically. Write a CI test that exercises the on and off paths of runCheckout with no live flagd and no network.

<details><summary>Solution</summary>

import { OpenFeature, InMemoryProvider } from '@openfeature/server-sdk';

for (const [variant, expected] of [['on', expectedNew], ['off', expectedOld]] as const) {
  it(`checkout: ${variant} path`, async () => {
    await OpenFeature.setProviderAndWait(new InMemoryProvider({
      'new-checkout': { disabled: false, variants: { on: true, off: false }, defaultVariant: variant },
    }));
    expect(await runCheckout(cart)).toEqual(expected);
  });
}

Why: the InMemoryProvider resolves through the same evaluation API, so the code under test is byte-identical to production — only the provider differs. A half-rolled-out flag can never hide a broken branch. </details>

6. (Advanced) Migrate one domain without touching the rest. 60 services run on flagd. Move only the payments domain onto LaunchDarkly, leaving the other 59 services’ code and backend untouched. Show the wiring.

<details><summary>Solution</summary>

// Default provider stays flagd for the 59 services — no change there.
await OpenFeature.setProviderAndWait(new FlagdProvider({ host: 'flagd', port: 8013 }));

// Bind ONLY the payments domain to LaunchDarkly.
OpenFeature.setProvider('payments', new LaunchDarklyProvider(process.env.LD_SDK_KEY!));

// Payments code fetches its domain-bound client; everything else is unchanged.
const paymentsClient = OpenFeature.getClient('payments');
const instantPayout = await paymentsClient.getBooleanValue('instant-payout', false);

Why: named providers bound to domains make “which backend” a per-domain wiring decision, so a migration is incremental and reversible rather than a big-bang cutover. Remember: you still re-author the payments targeting rules in LaunchDarkly — the API is portable, the rule surface is not. </details>

Common beginner mistakes

Glossary

Checklist

openfeaturefeature-flagsprogressive-deliveryflagdplatform-engineering
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments