Skip to content

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.


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.

Terminal window
npx flaglint audit ./src --exclude-tests

The --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 review

The 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): variationDetail returns an LDEvaluationDetail struct 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:

Terminal window
npm install @openfeature/server-sdk @launchdarkly/openfeature-node-server

Create 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.


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:

Terminal window
npx flaglint migrate ./src --dry-run --exclude-tests

Real output from the same three-service application:

- Scanning ./src...
LaunchDarkly usages found: 8
Safely 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 review

Notice 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.


When the dry-run output looks right:

Terminal window
npx flaglint migrate ./src --apply --exclude-tests

FlagLint 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.


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:

Terminal window
npx flaglint validate ./src --no-direct-launchdarkly --exclude-tests

Before 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:

  1. Auditnpx flaglint audit ./src --exclude-tests — get the readiness score and flag debt inventory before touching anything
  2. Resolve high-risk call sites — fix dynamic flag keys and detail evaluations manually using the manual review patterns guide
  3. Wire the OpenFeature provider — add FeatureFlagsService to a shared platform module, import it into each feature module that evaluates flags
  4. Dry-runnpx flaglint migrate ./src --dry-run — review every proposed diff
  5. Applynpx flaglint migrate ./src --apply — rewrite the safe call sites in one pass
  6. Enforcenpx 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.