OpenFeature in NestJS: Migrate Your LaunchDarkly Feature Flags Service by Service
The problem with migrating LaunchDarkly feature flags in a NestJS codebase is the same as every other migration problem: you don’t know what you’re dealing with until you’ve already touched something.
NestJS services are injectable, which means direct LaunchDarkly SDK calls end up scattered
across services, controllers, and guards — each one evaluated inside an injected class with
its own lifecycle. You can grep for ldClient and get a rough count, but grep won’t tell
you which call sites use dynamic flag keys that block automation, which ones call
variationDetail and need a different OpenFeature API, or what fraction of your call sites
can be rewritten without any manual review. You need a measurement before you make a plan.
This guide covers the complete cycle: audit the LaunchDarkly flag debt in your NestJS source, wire in the OpenFeature provider as a shared NestJS service, run a safe automated rewrite, and add a CI gate that blocks new direct LaunchDarkly SDK calls going forward.
Step 1: Audit your flag debt
Section titled “Step 1: Audit your flag debt”Start by running flaglint audit against your NestJS src directory. FlagLint uses
AST-based static analysis — not regex — so it correctly identifies call sites through
class methods, chained property access, and constructor assignments.
npx flaglint audit ./src --exclude-testsThe --exclude-tests flag prevents test fixtures from inflating the flag debt count.
Here is the real output from a three-service NestJS application with checkout, pricing,
and analytics modules:
- Auditing ./src...# FlagLint Audit Report
**Files scanned:** 3**Duration:** 62ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages ||-------------|-----------|-------------|--------------|| 8 | 2 | 6 | 8 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review ||--------------|--------------|------------|---------------|-------------------|---------------|| 1 | 1 | 0 | 0 | 6 | 2 |
## Migration Readiness
Migration readiness: **75/100** · moderate
[███████████████████░░░░░░] 75%
6 safely automatable · 2 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons ||----------|------|--------|-------|------------|---------|| `tracking-consent` | 🔴 High | 1 | 1 | variationDetail | detail evaluation || `<dynamic key>` | 🔴 High | 1 | 1 | boolVariation | dynamic key || `analytics-v2` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable || `new-checkout-flow` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable || `payment-provider` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable || `checkout-currency` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable || `discount-banner` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable || `pricing-tier` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
✓ Audit complete: 8 flags — 2 high risk, 6 medium risk (62ms, 3 files)
Migration readiness: 75/100 · moderate[███████████████████░░░░░░] 75%6 safely automatable · 2 require manual reviewThe readiness score tells you what fraction of your LaunchDarkly SDK call sites FlagLint can rewrite automatically. A score of 75 — moderate — means 6 of 8 call sites can proceed without manual intervention.
The Stale Signals column is 0 here, meaning no flag keys carry a staleness signal
(names containing keywords like old, deprecated, legacy, or tmp). Git-history-based
staleness detection — which checks last-evaluation date against git history — is outside
the scope of a static scan.
The two high-risk call types block automatic rewriting:
- Dynamic flag key (
pricing.service.ts): the flag key is constructed at runtime (e.g.`pricing-${process.env.REGION}`). FlagLint can’t statically verify which flag is being evaluated, so it reports it for manual review and skips it during migration. - Detail evaluation (
analytics.service.ts):variationDetailreturns anLDEvaluationDetailstruct with evaluation reason metadata. OpenFeature’s detail API exists but result parity requires manual review.
See Five LaunchDarkly SDK Patterns That Block Automatic Migration
for what each call type looks like and how to resolve it before running --apply.
Step 2: Set up a shared OpenFeature provider in NestJS
Section titled “Step 2: Set up a shared OpenFeature provider in NestJS”Before the automated rewrite can run, you need to wire in the OpenFeature provider. In
NestJS, the right place for this is a shared FeatureFlagsService in a platform module —
injected once at startup, then available to any module that needs flag evaluation.
Install the required packages:
npm install @openfeature/server-sdk @launchdarkly/openfeature-node-serverCreate src/platform/feature-flags.service.ts:
import { Injectable, OnModuleInit } from "@nestjs/common";import { OpenFeature, Client } from "@openfeature/server-sdk";import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
@Injectable()export class FeatureFlagsService implements OnModuleInit { client!: Client;
async onModuleInit() { await OpenFeature.setProviderAndWait( new LaunchDarklyProvider(process.env.LD_SDK_KEY!) ); this.client = OpenFeature.getClient(); }}setProviderAndWait blocks until the LaunchDarkly SDK has received the flag ruleset from
the service. Flag calls evaluated before this resolves return fallback values — the same
behaviour as an uninitialized ldClient.
Export this service from a FeatureFlagsModule and import that module into every NestJS
module that currently calls boolVariation or its siblings. The LaunchDarkly SDK packages
stay installed: the OpenFeature provider depends on them at runtime. You are replacing the
evaluation API your application code calls, not the backend that serves flag rules.
Tell FlagLint about the client binding by adding a flaglint.config.json at your project
root:
{ "openFeatureClientBindings": [ { "importName": "openFeatureClient", "modulePatterns": ["**/platform/feature-flags.service"] } ]}This lets the migrate step resolve openFeatureClient references in the generated diffs
back to the correct injected service property.
Step 3: Preview the migration plan
Section titled “Step 3: Preview the migration plan”With the OpenFeature provider wired in, run the migration in dry-run mode. FlagLint generates a reviewable diff for each call site it can safely rewrite — no files are changed:
npx flaglint migrate ./src --dry-run --exclude-testsReal output from the same three-service application:
- Scanning ./src...LaunchDarkly usages found: 8Safely automatable: 6 · Manual review: 2
## Diffs
diff --git a/analytics.service.ts b/analytics.service.ts- const analyticsEnabled = await ldClient.boolVariation("analytics-v2", ctx, false);+ const analyticsEnabled = await openFeatureClient.getBooleanValue("analytics-v2", false, ctx);
diff --git a/checkout.service.ts b/checkout.service.ts- const newCheckout = await ldClient.boolVariation("new-checkout-flow", ctx, false);+ const newCheckout = await openFeatureClient.getBooleanValue("new-checkout-flow", false, ctx);- const paymentProvider = await ldClient.stringVariation("payment-provider", ctx, "stripe");+ const paymentProvider = await openFeatureClient.getStringValue("payment-provider", "stripe", ctx);- const checkoutCurrency = await ldClient.stringVariation("checkout-currency", ctx, "USD");+ const checkoutCurrency = await openFeatureClient.getStringValue("checkout-currency", "USD", ctx);
diff --git a/pricing.service.ts b/pricing.service.ts- const discountEnabled = await ldClient.boolVariation("discount-banner", ctx, false);+ const discountEnabled = await openFeatureClient.getBooleanValue("discount-banner", false, ctx);- const pricingTier = await ldClient.stringVariation("pricing-tier", ctx, "standard");+ const pricingTier = await openFeatureClient.getStringValue("pricing-tier", "standard", ctx);
## Skipped Usages- analytics.service.ts:11:25 — `tracking-consent` via `variationDetail`: detail methods skipped- pricing.service.ts:11:34 — `flagKey` via `boolVariation`: dynamic key requires manual reviewNotice the argument-order flip in every diff: boolVariation("new-checkout-flow", ctx, false)
becomes getBooleanValue("new-checkout-flow", false, ctx). The flag key stays first; the
fallback value and evaluation context swap positions. This is the swap that breaks naive
find-and-replace migrations — FlagLint
handles it correctly at every automatable call site.
Review every diff. If a proposed rewrite looks wrong, that call site belongs in manual review.
Step 4: Apply the rewrites
Section titled “Step 4: Apply the rewrites”When the dry-run output looks right:
npx flaglint migrate ./src --apply --exclude-testsFlagLint rewrites only call sites with proven static flag keys and known fallback types. The two high-risk call sites — the dynamic key and the detail evaluation — are left untouched, and the skipped list tells you exactly which files still need attention.
After applying, resolve the manual call sites:
Dynamic flag key (pricing.service.ts): if REGION takes a fixed set of values, refactor
to a switch statement with explicit static flag keys. If the set is unbounded at build time,
keep the LaunchDarkly SDK call in a wrapper function and add it to the wrappers config so
FlagLint surfaces it for manual review without attempting an automated rewrite.
Detail evaluation (analytics.service.ts): OpenFeature’s getBooleanDetails method
returns reason metadata, but the reason codes differ from LaunchDarkly’s
LDEvaluationDetail. Review what your code does with the reason field and map accordingly
before rewriting by hand.
Step 5: Enforce the boundary in CI
Section titled “Step 5: Enforce the boundary in CI”Add a validate gate to CI once the migration is applied. It fails the build if any new direct LaunchDarkly SDK call lands in the codebase:
npx flaglint validate ./src --no-direct-launchdarkly --exclude-testsBefore the migration, this exits non-zero:
✗ validate --no-direct-launchdarkly: 8 direct LaunchDarkly evaluation call(s) found.
analytics.service.ts:10:35 — boolVariation("analytics-v2") analytics.service.ts:11:25 — variationDetail("tracking-consent") checkout.service.ts:10:30 — boolVariation("new-checkout-flow") checkout.service.ts:11:34 — stringVariation("payment-provider") checkout.service.ts:12:35 — stringVariation("checkout-currency") pricing.service.ts:11:34 — boolVariation("(dynamic key)") pricing.service.ts:12:34 — boolVariation("discount-banner") pricing.service.ts:13:30 — stringVariation("pricing-tier")
These files must migrate to OpenFeature before this rule passes.Run `flaglint migrate --dry-run` to review the migration plan.After the migration passes, it exits 0. Add it to your GitHub Actions workflow alongside existing linters — see Enforcing Your LaunchDarkly to OpenFeature Migration in GitHub Actions for the two-line YAML that wires this in.
Without this gate, migration drift is the default outcome: a teammate adds a new flag under deadline pressure using the LaunchDarkly SDK directly, and the flag debt starts growing again.
The complete OpenFeature NestJS migration plan
Section titled “The complete OpenFeature NestJS migration plan”For a NestJS application, the full workflow is:
- Audit —
npx flaglint audit ./src --exclude-tests— get the readiness score and flag debt inventory before touching anything - Resolve high-risk call sites — fix dynamic flag keys and detail evaluations manually using the manual review patterns guide
- Wire the OpenFeature provider — add
FeatureFlagsServiceto a shared platform module, import it into each feature module that evaluates flags - Dry-run —
npx flaglint migrate ./src --dry-run— review every proposed diff - Apply —
npx flaglint migrate ./src --apply— rewrite the safe call sites in one pass - Enforce —
npx flaglint validate ./src --no-direct-launchdarkly— add the CI gate
The readiness score determines how much of the flag debt is automatable. At 75/100 — the
score from the example above — six of eight call sites rewrite in a single --apply run.
At 50/100, more manual work is needed before the automation runs cleanly. Either way, the
migration plan starts from a measurement rather than a guess.
Next steps
Section titled “Next steps”- NestJS guide — full NestJS setup with
flaglintrcconfiguration, module structure, and wrapper detection - Five patterns that block migration — how to resolve dynamic keys, detail evaluations, and bulk calls before running migrate
- Enforcing in GitHub Actions — wire the validate gate into CI in two YAML lines
flaglint auditCLI reference — all options and output formats (JSON, Markdown, HTML)