Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 317 additions & 0 deletions src/content/docs/blog/openfeature-nestjs-launchdarkly-migration.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- excerpt -->

---

## 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 |
| `<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](/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)
Loading