Your company runs a Turborepo monorepo with a marketing site, a customer dashboard, a mobile BFF, and a shared component library. A product manager asks for a feature flag to control a new pricing display. Simple enough -- until you realize the pricing component lives in the shared package, the pricing data comes from the BFF, and the feature needs to be controlled independently on the dashboard and the marketing site.
One flag. Four packages. Two different rendering contexts. And when the rollout is complete, someone needs to find and remove every reference across all of them without breaking any of the three apps.
Monorepos amplify both the benefits and the pain of feature flags. The shared code that makes monorepos powerful also makes flag management uniquely complex. A flag in a shared package automatically becomes a cross-app concern, and the cleanup challenges that distributed teams face across separate repositories now exist within a single repository, hidden by the illusion that "it is all one codebase."
The monorepo flag paradox
Monorepos are supposed to make cross-cutting changes easier. A single repository means a single PR can modify the API, the frontend, and the shared library simultaneously. This is a genuine advantage for feature development, but it creates a paradox for feature flag management.
The paradox: Monorepos make it easy to spread a flag across multiple apps (because it is one PR), but they make it hard to track where a flag is used (because the codebase is massive) and even harder to safely remove it (because changes affect multiple apps simultaneously).
Consider the scale of a typical monorepo:
| Monorepo Metric | Small | Medium | Large |
|---|---|---|---|
| Packages/apps | 3-5 | 10-25 | 50+ |
| Total files | 5,000 | 50,000 | 500,000+ |
| Active feature flags | 20-40 | 50-150 | 200+ |
| Cross-package flags | 5-10 | 20-50 | 50+ |
| Teams contributing | 2-3 | 5-10 | 20+ |
In a large monorepo, a code search for a flag key might return results across dozens of packages. Understanding which results are active app code versus test fixtures, documentation, or deprecated packages requires deep knowledge of the repository structure.
The five monorepo-specific flag challenges
1. Shared packages make every flag cross-cutting
The defining feature of a monorepo is shared code. Shared component libraries, utility packages, and configuration modules are consumed by multiple apps. When a feature flag enters a shared package, it automatically affects every consuming app.
monorepo/
├── apps/
│ ├── web/ ← Uses shared components
│ ├── dashboard/ ← Uses shared components
│ └── mobile-bff/ ← Uses shared utilities
├── packages/
│ ├── ui/ ← Shared React components (flag HERE)
│ ├── utils/ ← Shared utilities
│ └── config/ ← Shared configuration
A flag added to a shared UI component:
// packages/ui/src/PricingCard.tsx
import { useFlag } from '@monorepo/flags';
export function PricingCard({ plan }) {
const showNewPricing = useFlag('release_new_pricing_display');
if (showNewPricing) {
return <NewPricingLayout plan={plan} />;
}
return <LegacyPricingLayout plan={plan} />;
}
This single flag reference now affects every app that imports PricingCard. The web app, the dashboard, and any future app that uses this component will all be branching on this flag. The developer who added the flag might not even know which apps consume it -- they just edited a shared package.
The cascading impact:
| Where Flag Lives | Apps Affected | Teams Involved | Cleanup Complexity |
|---|---|---|---|
| App-specific code | 1 | 1 | Low |
| Shared UI component | 2-5 | 1-3 | Medium |
| Shared utility function | 3-10 | 2-5 | High |
| Shared configuration | All apps | All teams | Very High |
2. "Which app still uses this flag?" is hard to answer
In a monorepo with dozens of packages and complex dependency graphs, determining which apps are affected by a flag requires understanding the full import chain. A flag in packages/utils/src/pricing.ts might be imported by packages/ui/src/PricingCard.tsx, which is imported by apps/web/src/pages/pricing.tsx and apps/dashboard/src/components/billing.tsx.
Simple text search finds the direct reference in packages/utils. But does it tell you which apps ultimately execute that code? Not without tracing the dependency graph.
Dependency graph complexity:
Flag in: packages/utils/src/pricing.ts
↓ imported by
packages/ui/src/PricingCard.tsx
↓ imported by
apps/web/src/pages/pricing.tsx ← App 1 affected
apps/dashboard/src/components/billing.tsx ← App 2 affected
Also imported by:
packages/analytics/src/tracking.ts
↓ imported by
apps/web/src/lib/analytics.ts ← App 1 (again, different path)
apps/mobile-bff/src/routes/pricing.ts ← App 3 affected (server-side!)
A flag removal that seems contained to the shared utils package actually affects three apps through two different dependency paths. Missing any path during cleanup means broken functionality in production.
3. Different apps need different flag behavior
The same logical feature often requires different flag behavior in different apps. The web app might need a boolean flag for UI rendering, the BFF might need the flag to control API response shape, and the dashboard might need a different targeting rule entirely.
// apps/web - Client-side evaluation, per-user targeting
const showNewPricing = ldClient.variation('new_pricing', userContext, false);
// apps/mobile-bff - Server-side evaluation, per-request
const showNewPricing = ldClient.variation('new_pricing', requestContext, false);
// apps/dashboard - Different default, admin users always see new pricing
const showNewPricing = ldClient.variation('new_pricing', adminContext, true);
Note the different default values. The dashboard defaults to true because admin users should always see the new pricing. If the flag is removed from LaunchDarkly before code cleanup, the web app falls back to false (old pricing) while the dashboard falls back to true (new pricing). In a monorepo, these inconsistencies are easy to create and hard to detect because the evaluations are in different apps within the same repository.
4. Monorepo tooling creates CI optimization pressure
Turborepo, Nx, and other monorepo tools optimize CI by only building and testing affected packages. This is essential for performance but creates blind spots for flag cleanup.
The optimization trap:
When you remove a flag from packages/utils, Turborepo detects that packages/utils changed and rebuilds its dependents. But what if the flag is also referenced directly in apps/dashboard/src/config/flags.ts? If that file was not modified, Turborepo may not rebuild the dashboard, and the tests that would catch the inconsistency do not run.
Flag removal PR changes:
packages/utils/src/pricing.ts ← Modified (flag removed)
Turborepo rebuilds:
packages/utils ← Rebuilt ✓
packages/ui ← Rebuilt (depends on utils) ✓
apps/web ← Rebuilt (depends on ui) ✓
Turborepo skips:
apps/dashboard ← NOT rebuilt (depends on utils, but
also has its own reference to the flag
in a file that was not modified)
This is a real scenario that causes production issues. The dashboard still evaluates a flag that was conceptually "removed" but only physically removed from the shared package. The dashboard's own direct reference was missed.
5. CODEOWNERS conflicts and review bottlenecks
Monorepos typically use CODEOWNERS to route PR reviews to the right teams. Flag cleanup PRs that touch shared packages trigger reviews from every team that owns a consuming app. This turns a routine cleanup into a multi-team coordination exercise.
# CODEOWNERS
packages/ui/ @frontend-team
packages/utils/ @platform-team
apps/web/ @web-team
apps/dashboard/ @dashboard-team
apps/mobile-bff/ @mobile-team
A flag cleanup PR that modifies packages/ui and both apps requires approval from the frontend team, the web team, and the dashboard team. If any one team is busy with a sprint, the cleanup PR sits open for days or weeks, losing momentum and context.
Review bottleneck data:
| PR Scope | Teams Required | Average Time to Merge |
|---|---|---|
| Single app | 1 | 1-2 days |
| Shared package only | 1-2 | 2-3 days |
| Shared package + 1 app | 2 | 3-5 days |
| Shared package + 2-3 apps | 3-4 | 5-10 days |
| Shared package + all apps | All teams | 10-20 days |
The longer a cleanup PR stays open, the more likely it is to accumulate merge conflicts, lose reviewer context, and ultimately be abandoned.
Strategies for managing monorepo flags
Strategy 1: Create a shared flag definitions package
The single most impactful pattern is centralizing all flag definitions in a dedicated package:
packages/
└── flags/
├── src/
│ ├── index.ts # Re-exports
│ ├── definitions.ts # All flag keys and metadata
│ ├── hooks.ts # React hooks for flag evaluation
│ ├── server.ts # Server-side flag evaluation
│ └── types.ts # TypeScript types
├── package.json
└── tsconfig.json
Flag definitions file:
// packages/flags/src/definitions.ts
export const FLAGS = {
// Release flags
NEW_PRICING_DISPLAY: {
key: 'release_new_pricing_display',
type: 'release' as const,
owner: 'pricing-team',
createdDate: '2025-09-15',
expectedRemovalDate: '2025-11-15',
apps: ['web', 'dashboard'] as const,
description: 'New pricing card layout with annual toggle',
},
EXPRESS_CHECKOUT: {
key: 'release_express_checkout',
type: 'release' as const,
owner: 'checkout-team',
createdDate: '2025-10-01',
expectedRemovalDate: '2025-12-01',
apps: ['web'] as const,
description: 'One-click checkout for returning customers',
},
// Operational flags
PAYMENT_CIRCUIT_BREAKER: {
key: 'ops_payment_circuit_breaker',
type: 'ops' as const,
owner: 'platform-team',
createdDate: '2025-06-01',
expectedRemovalDate: null, // Permanent
apps: ['web', 'dashboard', 'mobile-bff'] as const,
description: 'Kill switch for payment processing',
},
} as const;
export type FlagName = keyof typeof FLAGS;
export type FlagDefinition = typeof FLAGS[FlagName];
React hook for flag evaluation:
// packages/flags/src/hooks.ts
import { useLDClient } from 'launchdarkly-react-client-sdk';
import { FLAGS, type FlagName } from './definitions';
export function useFlag(name: FlagName, defaultValue: boolean = false): boolean {
const client = useLDClient();
const flag = FLAGS[name];
return client?.variation(flag.key, defaultValue) ?? defaultValue;
}
// Type-safe: only valid flag names are accepted
// const enabled = useFlag('NEW_PRICING_DISPLAY'); // ✓
// const enabled = useFlag('TYPO_FLAG'); // ✗ TypeScript error
Benefits of a flag definitions package:
- Every flag is documented in one place with metadata (owner, apps, expected removal date)
- TypeScript prevents typos in flag keys at compile time
- The
appsfield explicitly declares which apps use each flag - Cleanup means removing the definition and fixing all resulting type errors
- The flag package's dependency graph shows exactly which apps consume flags
Strategy 2: Flag isolation with app-specific wrappers
For flags that need different behavior in different apps, create app-specific wrappers that consume the shared definition but customize evaluation:
// apps/web/src/lib/flags.ts
import { FLAGS, useFlag as useSharedFlag } from '@monorepo/flags';
// Web-specific defaults and behavior
export function useNewPricingDisplay(): boolean {
return useSharedFlag('NEW_PRICING_DISPLAY', false);
}
// apps/dashboard/src/lib/flags.ts
import { FLAGS, useFlag as useSharedFlag } from '@monorepo/flags';
// Dashboard always shows new pricing for admin users
export function useNewPricingDisplay(): boolean {
const isAdmin = useAdminContext();
if (isAdmin) return true; // Admins always see new pricing
return useSharedFlag('NEW_PRICING_DISPLAY', false);
}
This pattern means:
- The flag key is defined once in the shared package
- Each app controls its own evaluation logic and defaults
- Removing the flag from one app does not affect others
- App-specific tests can verify app-specific behavior
Strategy 3: Affected app detection for flag changes
Leverage monorepo tooling to automatically determine which apps are affected by a flag change. This is critical for both CI validation and cleanup scoping.
Turborepo affected detection:
# Determine which apps are affected by changes to the flags package
npx turbo run build --filter='...@monorepo/flags' --dry-run=json \
| jq '.tasks[].package'
Nx affected detection:
# Determine affected projects when flags package changes
npx nx affected:apps --base=main --head=HEAD
Custom affected detection for flag cleanup:
// scripts/flag-affected-apps.ts
import { FLAGS } from '@monorepo/flags';
import { execSync } from 'child_process';
function findAffectedApps(flagName: string): string[] {
const flag = FLAGS[flagName];
if (!flag) throw new Error(`Unknown flag: ${flagName}`);
// Start with declared apps
const declaredApps = new Set(flag.apps);
// Verify with actual code search
const grepResult = execSync(
`grep -rl "${flag.key}" apps/ packages/ --include="*.ts" --include="*.tsx"`,
{ encoding: 'utf-8' }
).trim();
const foundApps = new Set<string>();
for (const filePath of grepResult.split('\n')) {
const match = filePath.match(/^apps\/([^/]+)\//);
if (match) foundApps.add(match[1]);
// Also check shared packages for transitive dependencies
const pkgMatch = filePath.match(/^packages\/([^/]+)\//);
if (pkgMatch) {
// Find which apps depend on this package
const consumers = execSync(
`npx turbo run build --filter='...@monorepo/${pkgMatch[1]}' --dry-run=json`,
{ encoding: 'utf-8' }
);
// Parse consumers and add to foundApps
}
}
// Warn if actual usage differs from declared usage
const undeclared = [...foundApps].filter(a => !declaredApps.has(a));
if (undeclared.length > 0) {
console.warn(
`Flag ${flagName} is used in apps not listed in its definition: ${undeclared.join(', ')}`
);
}
return [...new Set([...declaredApps, ...foundApps])];
}
Strategy 4: Flag-aware CI for monorepos
Extend your monorepo CI pipeline to understand flags as a first-class concept. This means not just building affected apps, but running flag-specific checks that account for the monorepo structure.
GitHub Actions example for monorepo flag checks:
name: Flag Health Check
on:
pull_request:
paths:
- 'packages/flags/**'
- 'apps/*/src/**'
- 'packages/*/src/**'
jobs:
flag-consistency:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: bun install
- name: Verify flag definition consistency
run: |
# Check that every flag key in code has a definition
bun run scripts/verify-flag-definitions.ts
- name: Check for orphaned flag references
run: |
# Find flag evaluations that don't use the shared package
bun run scripts/find-orphaned-flags.ts
- name: Run tests for all affected apps
run: |
# Override Turborepo's cache to force full testing
# when flag-related files change
npx turbo run test --force --filter='...[HEAD^1]'
- name: Verify flag removal completeness
if: contains(github.event.pull_request.title, 'flag-cleanup')
run: |
# For cleanup PRs, verify the flag is removed from ALL locations
bun run scripts/verify-complete-removal.ts
The key insight is the --force flag on the Turborepo test command. When flag-related files change, you want to override the cache and test all affected apps, even if Turborepo's heuristics say some apps are not affected. This catches the transitive dependency issues described earlier.
Strategy 5: CODEOWNERS strategy for flag files
Structure your CODEOWNERS to minimize review bottlenecks for flag cleanup while maintaining safety:
# Flag definitions package - owned by platform team
# Platform team can merge flag cleanups without blocking on app teams
packages/flags/ @platform-team
# App-specific flag wrappers - owned by respective app teams
apps/web/src/lib/flags.ts @web-team
apps/dashboard/src/lib/flags.ts @dashboard-team
# Shared components - require both component owner and flag team
# for flag-related changes
packages/ui/src/**/*flag* @frontend-team @platform-team
Cleanup PR review strategy:
| PR Changes | Required Reviewers | Rationale |
|---|---|---|
| Flag definition only (removing from shared package) | Platform team | Definition owner can verify removal |
| Flag definition + app wrapper | Platform team + affected app team | Both sides of the change reviewed |
| Flag definition + shared component | Platform team + component owner | Shared component needs careful review |
| All of the above | Platform team (lead) + one app team rep | Avoid requiring ALL teams; trust platform team to coordinate |
The goal is to avoid the N-team bottleneck by making the platform team the coordinator of flag cleanup, with individual app teams reviewing only their specific changes.
Strategy 6: Testing flag removal in monorepos
Flag removal testing in a monorepo must account for cross-app impacts. A comprehensive testing strategy includes:
Unit tests for the flag definitions package:
// packages/flags/src/__tests__/definitions.test.ts
import { FLAGS } from '../definitions';
describe('Flag definitions', () => {
test('all flags have required metadata', () => {
for (const [name, flag] of Object.entries(FLAGS)) {
expect(flag.key).toBeTruthy();
expect(flag.type).toBeTruthy();
expect(flag.owner).toBeTruthy();
expect(flag.apps.length).toBeGreaterThan(0);
expect(flag.description).toBeTruthy();
}
});
test('release flags have expected removal dates', () => {
for (const [name, flag] of Object.entries(FLAGS)) {
if (flag.type === 'release' || flag.type === 'experiment') {
expect(flag.expectedRemovalDate).toBeTruthy();
}
}
});
test('flag keys follow naming convention', () => {
const validPrefixes = ['release_', 'experiment_', 'ops_', 'permission_'];
for (const [name, flag] of Object.entries(FLAGS)) {
const hasValidPrefix = validPrefixes.some(p => flag.key.startsWith(p));
expect(hasValidPrefix).toBe(true);
}
});
});
Integration tests that span apps:
// e2e/flag-consistency.test.ts
import { FLAGS } from '@monorepo/flags';
import { execSync } from 'child_process';
describe('Flag consistency across apps', () => {
for (const [name, flag] of Object.entries(FLAGS)) {
test(`${name} is only used in declared apps`, () => {
const grepResult = execSync(
`grep -rl "${flag.key}" apps/ --include="*.ts" --include="*.tsx"`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
if (!grepResult) return; // Flag not used in any app (OK for new flags)
const usedInApps = new Set(
grepResult.split('\n')
.map(f => f.match(/^apps\/([^/]+)\//)?.[1])
.filter(Boolean)
);
const undeclared = [...usedInApps].filter(
app => !flag.apps.includes(app as any)
);
expect(undeclared).toEqual([]);
});
}
});
Measuring monorepo flag health
Track these metrics specific to monorepo flag management:
| Metric | Healthy | Warning | Critical |
|---|---|---|---|
Flags in shared packages without apps metadata | 0 | 1-3 | 4+ |
| Cross-app flags (used in 3+ apps) | < 10% of total | 10-25% | > 25% |
| Flag cleanup PR average review time | < 3 days | 3-7 days | > 7 days |
| Orphaned flag references (not using shared package) | 0 | 1-5 | 6+ |
| Apps affected by average flag removal | 1-2 | 3-4 | 5+ |
| Flag definition without removal date | < 10% | 10-30% | > 30% |
Action plan: Monorepo flag management in 6 weeks
Week 1-2: Foundation
- Create the
packages/flagsshared definitions package - Move all existing flag key strings into centralized definitions
- Add TypeScript types and a
useFlaghook - Set up basic CI flag detection for the monorepo
Week 3-4: Governance
- Implement CODEOWNERS for flag-related files
- Add the
appsmetadata field to all flag definitions - Create app-specific flag wrapper modules where needed
- Build the affected-apps detection script
Week 5-6: Automation
- Implement flag-aware CI checks (consistency, orphan detection, stale warnings)
- Override Turborepo/Nx caching for flag-related changes
- Set up automated cleanup PR generation using tools like FlagShark
- Establish the flag cleanup review process with platform team as coordinator
Monorepos make feature flags simultaneously more powerful and more dangerous. The shared code that enables rapid cross-app development also ensures that every flag has the potential to become a cross-cutting concern that touches multiple apps, multiple teams, and multiple deployment contexts. The organizations that manage monorepo flags successfully are the ones that treat flags as a first-class architectural concept, with dedicated packages, explicit metadata, affected-app awareness, and CI enforcement that accounts for the unique dependency dynamics of a monorepo. Without these practices, the monorepo's greatest strength -- shared code -- becomes the primary vector for flag sprawl and technical debt accumulation.