@nestarc/feature-flag
v0.4.0
Published
DB-backed feature flags for NestJS + Prisma + PostgreSQL with tenant-aware overrides
Maintainers
Readme
@nestarc/feature-flag
DB-backed feature flags for NestJS + Prisma + PostgreSQL -- attribute-targeted overrides, percentage rollouts, and zero external dependencies.
Features
- Database-backed -- flags stored in PostgreSQL via Prisma, no external service required
- Attribute-targeted overrides -- exact-match targeting for tenants, users, environments, plans, regions, or custom dimensions
- Percentage rollouts -- deterministic hashing (murmurhash3) with explicit
targetingKey/bucketBy - Guard decorator --
@FeatureFlag()automatically gates routes and controllers - Bypass decorator --
@BypassFeatureFlag()exempts health checks and public endpoints - Programmatic evaluation --
isEnabled(),evaluateBoolean(), andevaluateAll()for service-layer logic - Type-safe registry helpers -- define flag keys, defaults, rollout bucket keys, exposure tracking, and lifecycle metadata in code
- Built-in caching -- configurable TTL with manual invalidation; Redis Pub/Sub for multi-instance
- Pluggable persistence --
FeatureFlagRepositoryinterface for custom backends (Prisma default) - Pluggable tenancy --
TenantContextProviderinterface for custom tenant resolution - Admin REST API -- opt-in
FeatureFlagAdminModulewith guard injection and proper error responses - Event system -- optional integration with
@nestjs/event-emitterfor audit and observability - OpenFeature adapter -- optional boolean-only provider at
@nestarc/feature-flag/openfeature - Testing utilities -- drop-in
TestFeatureFlagModulefor unit and integration tests
Installation
npm install @nestarc/feature-flagPeer dependencies
npm install @nestjs/common @nestjs/core @prisma/client class-transformer class-validator rxjs reflect-metadataOptional
# Required only if you enable emitEvents
npm install @nestjs/event-emitter
# Required only if you use RedisCacheAdapter
npm install ioredis
# Required only if you use the OpenFeature adapter with the SDK
npm install @openfeature/server-sdkRedis Cache (Multi-Instance)
For production deployments with multiple instances, use RedisCacheAdapter for shared caching and real-time invalidation via Redis Pub/Sub:
import { FeatureFlagModule, RedisCacheAdapter } from '@nestarc/feature-flag';
import { Redis } from 'ioredis';
const redisClient = new Redis({ host: 'localhost', port: 6379 });
FeatureFlagModule.forRoot({
environment: 'production',
prisma,
cacheAdapter: new RedisCacheAdapter({
client: redisClient,
// subscriber is auto-created via client.duplicate()
// keyPrefix: 'feature-flag:', // default
// channel: 'feature-flag:invalidate', // default
}),
})When a flag is updated on any instance, all other instances are notified via Pub/Sub and invalidate their cache immediately — eliminating the stale-cache window.
Prisma Schema
Add the following models to your schema.prisma:
model FeatureFlag {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
key String @unique
description String?
enabled Boolean @default(false)
percentage Int @default(0)
metadata Json @default("{}")
archivedAt DateTime? @map("archived_at") @db.Timestamptz()
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
overrides FeatureFlagOverride[]
@@map("feature_flags")
}
model FeatureFlagOverride {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
flagId String @map("flag_id") @db.Uuid
attributes Json
priority Int @default(0)
enabled Boolean
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
flag FeatureFlag @relation(fields: [flagId], references: [id], onDelete: Cascade)
@@index([flagId], map: "idx_override_flag_id")
@@map("feature_flag_overrides")
}The v0.3.0 migration uses an {} default only while backfilling legacy rows, then drops that default. It also creates a unique index on (flag_id, attributes) and a check constraint requiring override attributes to be a non-empty JSON object. If you copy this schema into a greenfield app instead of running the included migrations, add an equivalent raw SQL migration because Prisma schema cannot express these PostgreSQL constraints:
CREATE UNIQUE INDEX "uq_feature_flag_override_attributes"
ON "feature_flag_overrides"("flag_id", "attributes");
ALTER TABLE "feature_flag_overrides"
ADD CONSTRAINT "chk_feature_flag_override_attributes_non_empty"
CHECK (jsonb_typeof("attributes") = 'object' AND "attributes" <> '{}'::jsonb);Migration from 0.2.0 to 0.3.0
v0.3.0 changes override storage from fixed tenant_id, user_id, and environment columns to an attributes jsonb object plus priority.
Run your Prisma migrations during deployment:
npx prisma migrate deployThe migration maps legacy override columns into attributes:
| v0.2.0 column | v0.3.0 attribute |
| ------------- | ---------------- |
| tenant_id | attributes.tenantId |
| user_id | attributes.userId |
| environment | attributes.environment |
Rows with all three legacy columns set to NULL are deleted because empty override attributes are not valid in v0.3.0. If multiple legacy rows backfill to the same (flag_id, attributes), the migration keeps the row with the latest updated_at, then latest created_at, then highest id, and deletes the other duplicates before creating the unique index.
Legacy Admin API bodies are rejected:
{ "tenantId": "tenant-1", "enabled": true }Use an attributes object instead:
{ "attributes": { "tenantId": "tenant-1" }, "enabled": true }Module Registration
forRoot (synchronous)
import { FeatureFlagModule } from '@nestarc/feature-flag';
@Module({
imports: [
FeatureFlagModule.forRoot({
environment: 'production',
prisma: prismaService,
userIdExtractor: (req) => req.headers['x-user-id'] as string,
emitEvents: true,
cacheTtlMs: 30_000,
}),
],
})
export class AppModule {}forRootAsync (with useFactory)
import { FeatureFlagModule } from '@nestarc/feature-flag';
@Module({
imports: [
FeatureFlagModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService, PrismaService],
useFactory: (config: ConfigService, prisma: PrismaService) => ({
environment: config.get('NODE_ENV'),
prisma,
userIdExtractor: (req) => req.headers['x-user-id'] as string,
}),
}),
],
})
export class AppModule {}forRootAsync (with useClass)
@Injectable()
class FeatureFlagConfigService implements FeatureFlagModuleOptionsFactory {
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {}
createFeatureFlagOptions() {
return {
environment: this.config.get('NODE_ENV'),
prisma: this.prisma,
};
}
}
@Module({
imports: [
FeatureFlagModule.forRootAsync({
imports: [ConfigModule, PrismaModule],
useClass: FeatureFlagConfigService,
}),
],
})
export class AppModule {}forRootAsync (with useExisting)
@Module({
imports: [
FeatureFlagModule.forRootAsync({
useExisting: FeatureFlagConfigService,
}),
],
})
export class AppModule {}Feature Flag Guard
The @FeatureFlag() decorator automatically applies UseGuards(FeatureFlagGuard), so you do not need to add @UseGuards() yourself.
Method-level
import { FeatureFlag } from '@nestarc/feature-flag';
@Controller('dashboard')
export class DashboardController {
@FeatureFlag('NEW_DASHBOARD')
@Get()
getDashboard() {
return { message: 'Welcome to the new dashboard' };
}
}Class-level
@FeatureFlag('BETA_API')
@Controller('beta')
export class BetaController {
@Get('feature-a')
featureA() { /* guarded */ }
@Get('feature-b')
featureB() { /* guarded */ }
}Custom status code and fallback
@FeatureFlag('PREMIUM_FEATURE', {
statusCode: 402,
fallback: { message: 'Upgrade required' },
})
@Get('premium')
getPremiumContent() { ... }When the flag is disabled, the guard responds with the given statusCode (default 403) and optional fallback body.
Use defaultValue when a route should choose an invocation-specific fallback if a flag is missing or evaluation fails:
@FeatureFlag('OPTIONAL_PREVIEW', { defaultValue: true })
@Get('preview')
getPreview() { ... }Bypassing the guard
Use @BypassFeatureFlag() on methods that should always be accessible, even when a class-level flag is applied:
import { BypassFeatureFlag } from '@nestarc/feature-flag';
@FeatureFlag('BETA_API')
@Controller('beta')
export class BetaController {
@Get('docs')
betaDocs() { /* guarded by BETA_API */ }
@BypassFeatureFlag()
@Get('health')
healthCheck() {
return { status: 'ok' };
}
}Programmatic Evaluation
Inject FeatureFlagService for service-layer checks outside the HTTP request cycle:
import { FeatureFlagService } from '@nestarc/feature-flag';
@Injectable()
export class PaymentService {
constructor(private readonly flags: FeatureFlagService) {}
async processPayment(order: Order) {
const useNewGateway = await this.flags.isEnabled('NEW_PAYMENT_GATEWAY');
if (useNewGateway) {
return this.newGateway.process(order);
}
return this.legacyGateway.process(order);
}
}Evaluate all flags at once
const allFlags = await this.flags.evaluateAll();
// { NEW_DASHBOARD: true, PREMIUM_FEATURE: false, ... }Explicit evaluation context
Both isEnabled() and evaluateAll() accept an optional EvaluationContext to override the auto-detected context:
const enabled = await this.flags.isEnabled('MY_FLAG', {
userId: 'user-123',
tenantId: 'tenant-abc',
environment: 'staging',
});Passing null explicitly clears that dimension, suppressing any ambient value from the request context:
// Evaluate as if no user is present, even within a request with x-user-id
const globalResult = await this.flags.isEnabled('MY_FLAG', { userId: null });Detailed boolean evaluation
Use evaluateBoolean() when you need to explain why a flag resolved to a value:
const details = await this.flags.evaluateBoolean(
'NEW_CHECKOUT',
{ targetingKey: 'tenant-1', tenantId: 'tenant-1' },
{ defaultValue: false, trackExposure: true },
);
console.log(details);
// {
// flagKey: 'NEW_CHECKOUT',
// value: true,
// result: true,
// source: 'percentage',
// reason: 'PERCENTAGE_MATCH',
// defaultUsed: false,
// bucket: 17,
// targetingKey: 'tenant-1',
// evaluationTimeMs: 1
// }Missing flags and evaluation errors return the selected default instead of throwing. Default priority is:
- Invocation
defaultValue - Registry
defaultValue - Module
defaultOnMissing false
Type-safe flag registry
import { defineFlags, createFeatureFlagClient } from '@nestarc/feature-flag';
export const flags = defineFlags({
NEW_CHECKOUT: {
defaultValue: false,
bucketBy: 'tenantId',
trackExposure: true,
owner: 'payments',
tags: ['checkout'],
staleAt: '2026-09-01',
expiresAt: '2026-12-01',
},
});
const flagClient = createFeatureFlagClient(featureFlagService, flags);
const enabled = await flagClient.isEnabled('NEW_CHECKOUT', { tenantId: 'tenant-1' });You can also pass the registry to FeatureFlagModule.forRoot({ flags }) so service-level fallback and bucketBy defaults apply to direct FeatureFlagService calls.
OpenFeature boolean adapter
The optional adapter lives on a separate subpath and delegates boolean resolution to FeatureFlagService:
import { createOpenFeatureBooleanProvider } from '@nestarc/feature-flag/openfeature';
const provider = createOpenFeatureBooleanProvider(featureFlagService);
const result = await provider.resolveBooleanEvaluation(
'NEW_CHECKOUT',
false,
{ targetingKey: 'tenant-1', tenantId: 'tenant-1', plan: 'pro' },
);Only boolean evaluation is supported in v0.4.0. Variant flags and string/number/json remote config remain out of scope.
Attribute Targeting
Overrides match exact attributes. Every key/value in an override's attributes object must exist in the evaluation context attributes for the override to apply.
const enabled = await this.flags.isEnabled('NEW_CHECKOUT', {
userId: 'user-123',
tenantId: 'tenant-1',
environment: 'production',
attributes: {
plan: 'pro',
country: 'KR',
},
});Top-level userId, tenantId, and environment are merged into targeting attributes. If the same key also appears in attributes, the top-level value wins.
When multiple overrides match, the evaluator chooses the winner by:
- More attributes
- Higher
priority - Earlier
createdAt - Lower
id
Overrides
Set attribute-based overrides that take precedence over the global flag value:
await flags.setOverride('NEW_CHECKOUT', {
attributes: {
tenantId: 'tenant-1',
plan: 'pro',
country: 'KR',
},
enabled: true,
priority: 10,
});REST Admin API body:
{
"attributes": {
"tenantId": "tenant-1",
"plan": "pro",
"country": "KR"
},
"enabled": true,
"priority": 10
}Events
Enable event emission to observe flag lifecycle changes. Requires installing @nestjs/event-emitter.
Important: You must import EventEmitterModule.forRoot() in your app module. The feature-flag module reuses the same EventEmitter2 singleton that NestJS manages, so @OnEvent() listeners work out of the box.
Setup
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
EventEmitterModule.forRoot(), // must be imported
FeatureFlagModule.forRoot({
environment: 'production',
prisma: prismaService,
emitEvents: true,
}),
],
})
export class AppModule {}Event types
| Event constant | Event string | Payload type |
| ---------------------------------------- | ---------------------------------- | -------------------- |
| FeatureFlagEvents.EVALUATED | feature-flag.evaluated | FlagEvaluatedEvent |
| FeatureFlagEvents.EXPOSED | feature-flag.exposed | FlagExposedEvent |
| FeatureFlagEvents.CREATED | feature-flag.created | FlagMutationEvent |
| FeatureFlagEvents.UPDATED | feature-flag.updated | FlagMutationEvent |
| FeatureFlagEvents.ARCHIVED | feature-flag.archived | FlagMutationEvent |
| FeatureFlagEvents.OVERRIDE_SET | feature-flag.override.set | FlagOverrideEvent |
| FeatureFlagEvents.OVERRIDE_REMOVED | feature-flag.override.removed | FlagOverrideEvent |
| FeatureFlagEvents.CACHE_INVALIDATED | feature-flag.cache.invalidated | {} |
Listening to events
import { OnEvent } from '@nestjs/event-emitter';
import { FeatureFlagEvents, FlagEvaluatedEvent } from '@nestarc/feature-flag';
@Injectable()
export class FlagAuditListener {
@OnEvent(FeatureFlagEvents.EVALUATED)
handleEvaluation(event: FlagEvaluatedEvent) {
console.log(`Flag ${event.flagKey} = ${event.result} (${event.reason})`);
}
}Exposure events are opt-in per call, registry entry, or flag metadata via trackExposure. They do not persist analytics; attach your own listener if you need sampling, batching, or storage.
Testing
Import TestFeatureFlagModule from the /testing subpath to stub flag values in tests without a database connection:
import { TestFeatureFlagModule } from '@nestarc/feature-flag/testing';
describe('DashboardController', () => {
let app: INestApplication;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [
TestFeatureFlagModule.register({
NEW_DASHBOARD: true,
PREMIUM_FEATURE: false,
}),
],
controllers: [DashboardController],
}).compile();
app = module.createNestApplication();
await app.init();
});
it('should allow access when flag is enabled', () => {
return request(app.getHttpServer())
.get('/dashboard')
.expect(200);
});
});TestFeatureFlagModule.register() provides a global mock of FeatureFlagService:
isEnabled(key)returns the boolean you specified (defaulting tofalsefor unregistered keys)evaluateBoolean(key)returnsBooleanEvaluationDetailsevaluateAll()returns the full flag mapcreate(),update(),archive(),findByKey(),findAll()return fullFeatureFlagWithOverridesstub objectsfindByKey()throwsNotFoundExceptionfor unknown keys
For registry-based tests, use registerRegistry() and the injected controller:
import {
TestFeatureFlagController,
TestFeatureFlagModule,
} from '@nestarc/feature-flag/testing';
const module = await Test.createTestingModule({
imports: [TestFeatureFlagModule.registerRegistry(flags)],
}).compile();
const testFlags = module.get(TestFeatureFlagController);
testFlags.set('NEW_CHECKOUT', true);
testFlags.reset();The testing controller keeps state inside the compiled testing module. CRUD-style write methods on the mocked service still return stub objects and do not persist database rows.
Evaluation Priority
When isEnabled() is called, flags are evaluated through the current cascade. The first matching layer wins:
| Priority | Layer | Description |
| -------- | ---------------------- | ------------------------------------------------------------------ |
| 1 | Archived | If the flag has archivedAt set, evaluation always returns false |
| 2 | Attribute override | Best override whose attributes are all present in the evaluation context |
| 3 | Percentage rollout | Deterministic hash of flagKey + targetingKey mod 100 |
| 4 | Global default | The flag's enabled field |
Percentage rollout uses murmurhash3 for deterministic bucketing. The targeting key is resolved in this order: explicit context.targetingKey, registry or metadata bucketBy, then the legacy userId ?? tenantId fallback.
Configuration Reference
FeatureFlagModuleOptions
| Option | Type | Default | Description |
| ------------------- | --------------------------------- | --------- | --------------------------------------------------------------- |
| environment | string | required| Deployment environment (e.g. 'production', 'staging') |
| cacheTtlMs | number | 30000 | Cache TTL in ms. Set to 0 to disable caching |
| userIdExtractor | (req: Request) => string \| null| undefined| Extracts user ID from the incoming request |
| defaultOnMissing | boolean | false | Value returned when a flag key does not exist in the database |
| emitEvents | boolean | false | Emit lifecycle events via @nestjs/event-emitter |
| cacheAdapter | CacheAdapter | MemoryCacheAdapter | Pluggable cache backend (e.g. RedisCacheAdapter) |
| flags | FlagRegistry | undefined | Optional typed registry for defaults, bucketBy, and exposure settings |
FeatureFlagModuleRootOptions
Extends FeatureFlagModuleOptions with:
| Option | Type | Description |
| ------- | ----- | ------------------------------ |
| prisma| any | Prisma client instance |
CRUD Operations
FeatureFlagService also exposes methods for managing flags programmatically:
// Create a flag
const flag = await this.flags.create({
key: 'NEW_FEATURE',
description: 'Enables the new feature',
enabled: false,
percentage: 0,
});
// Update a flag
await this.flags.update('NEW_FEATURE', {
enabled: true,
percentage: 50,
});
// Archive a flag (soft delete -- evaluations return false)
await this.flags.archive('OLD_FEATURE');
// List all active (non-archived) flags
const allFlags = await this.flags.findAll();
// Manually invalidate the cache
this.flags.invalidateCache();Admin REST API
FeatureFlagAdminModule provides a REST API for managing flags. It requires a guard — the module won't register without one:
import { FeatureFlagAdminModule } from '@nestarc/feature-flag';
import { AdminAuthGuard } from './guards/admin-auth.guard';
@Module({
imports: [
FeatureFlagModule.forRoot({ ... }),
FeatureFlagAdminModule.register({
guard: AdminAuthGuard,
// path: 'feature-flags', // default
}),
],
})
export class AppModule {}Endpoints
| Method | Route | Description | Error Responses |
|--------|-------|-------------|-----------------|
| POST | /feature-flags | Create a flag | 409 duplicate key, 400 invalid percentage |
| GET | /feature-flags | List all flags | |
| GET | /feature-flags/:key | Get a single flag | 404 not found |
| PATCH | /feature-flags/:key | Update a flag | 404 not found, 400 invalid percentage |
| DELETE | /feature-flags/:key | Archive a flag | 404 not found |
| POST | /feature-flags/:key/evaluate | Evaluate a flag without mutating it | |
| POST | /feature-flags/:key/overrides | Set an override | 404 flag not found |
| DELETE | /feature-flags/:key/overrides | Remove an override | 404 flag not found |
Percentage values must be between 0 and 100 (inclusive). Invalid values return 400 Bad Request.
Custom Persistence (Advanced)
The default PrismaFeatureFlagRepository can be replaced with any implementation of FeatureFlagRepository:
import {
FeatureFlagModule,
FEATURE_FLAG_REPOSITORY,
FeatureFlagRepository,
} from '@nestarc/feature-flag';
@Module({
imports: [
FeatureFlagModule.forRoot({
environment: 'production',
prisma, // still required for module init, but unused if you override the repository
}),
],
providers: [
{
provide: FEATURE_FLAG_REPOSITORY,
useClass: MyCustomRepository, // implements FeatureFlagRepository
},
],
})
export class AppModule {}Custom Tenant Resolution (Advanced)
Override the default @nestarc/tenancy integration with your own TenantContextProvider:
import {
FeatureFlagModule,
TENANT_CONTEXT_PROVIDER,
TenantContextProvider,
} from '@nestarc/feature-flag';
@Injectable()
class MyTenantProvider implements TenantContextProvider {
getCurrentTenantId(): string | null {
// your custom tenant resolution logic
return 'tenant-from-custom-source';
}
}
@Module({
imports: [FeatureFlagModule.forRoot({ ... })],
providers: [
{ provide: TENANT_CONTEXT_PROVIDER, useClass: MyTenantProvider },
],
})
export class AppModule {}Examples
- examples/basic-guard - route gating with
@FeatureFlag() - examples/multi-tenant-targeting - tenant and plan targeting with attributes
- examples/redis-events - Redis cache invalidation and feature flag events
Performance
Measured with PostgreSQL 16, Prisma 6, 500 iterations on Apple Silicon:
| Scenario | Avg | P50 | P95 | P99 | |----------|-----|-----|-----|-----| | isEnabled() — cache hit | 0.04ms | 0.03ms | 0.05ms | 0.07ms | | isEnabled() — cache miss (DB lookup) | 1.30ms | 1.14ms | 2.54ms | 3.69ms | | isEnabled() — override cascade (cold) | 1.07ms | 1.02ms | 1.43ms | 2.11ms | | evaluateAll() — 50 flags (mixed) | 0.19ms | 0.04ms | 1.55ms | 1.71ms |
Cache speedup: 32.5x (hit vs miss). Keep the default 30s cache TTL for optimal performance.
Reproduce:
docker compose up -d && dotenv -e .env.test -- npx ts-node benchmarks/evaluation-overhead.ts
License
MIT
