From 291e2c14fcdc38a94548abb2f960a5e848fd3b8a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 03:11:29 +0000 Subject: [PATCH] content: OpenFeature in NestJS: Migrate Your LaunchDarkly Feature Flags Service by Service Co-Authored-By: Claude Code Claude-Session: https://claude.ai/code/session_01Uv3YaJRSQfRKuPntaoCvr8 --- ...enfeature-nestjs-launchdarkly-migration.md | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 src/content/docs/blog/openfeature-nestjs-launchdarkly-migration.md diff --git a/src/content/docs/blog/openfeature-nestjs-launchdarkly-migration.md b/src/content/docs/blog/openfeature-nestjs-launchdarkly-migration.md new file mode 100644 index 0000000..6806ed3 --- /dev/null +++ b/src/content/docs/blog/openfeature-nestjs-launchdarkly-migration.md @@ -0,0 +1,317 @@ +--- +title: "OpenFeature in NestJS: Migrate Your LaunchDarkly Feature Flags Service by Service" +description: "Migrate NestJS services from the LaunchDarkly SDK to OpenFeature: audit flag debt, rewrite call sites, and enforce the boundary in CI. Free CLI, no API key." +date: 2026-07-30 +authors: + - name: Krishan Sharma + title: Founder and maintainer of FlagLint + url: https://www.linkedin.com/in/krishansha/ +tags: ["launchdarkly", "openfeature", "nestjs", "migration", "typescript"] +--- + +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 + +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. + +```bash +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 | +| `` | 🔴 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](/blog/five-patterns-that-block-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 + +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: + +```bash +npm install @openfeature/server-sdk @launchdarkly/openfeature-node-server +``` + +Create `src/platform/feature-flags.service.ts`: + +```typescript +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: + +```json +{ + "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 + +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: + +```bash +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](/blog/launchdarkly-openfeature-argument-order-bug/) — 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 + +When the dry-run output looks right: + +```bash +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. + +--- + +## 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: + +```bash +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](/blog/enforce-launchdarkly-migration-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 + +For a NestJS application, the full workflow is: + +1. **Audit** — `npx 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](/docs/guides/manual-review-patterns/) +3. **Wire the OpenFeature provider** — add `FeatureFlagsService` to a shared platform module, + import it into each feature module that evaluates flags +4. **Dry-run** — `npx flaglint migrate ./src --dry-run` — review every proposed diff +5. **Apply** — `npx flaglint migrate ./src --apply` — rewrite the safe call sites in one pass +6. **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 + +- [NestJS guide](/docs/guides/nestjs/) — full NestJS setup with `flaglintrc` configuration, + module structure, and wrapper detection +- [Five patterns that block migration](/blog/five-patterns-that-block-migration/) — how to + resolve dynamic keys, detail evaluations, and bulk calls before running migrate +- [Enforcing in GitHub Actions](/blog/enforce-launchdarkly-migration-github-actions/) — wire + the validate gate into CI in two YAML lines +- [`flaglint audit` CLI reference](/docs/cli/audit/) — all options and output formats + (JSON, Markdown, HTML)