NestJS Feature Flags Without External Services
You want to gate a new feature behind a flag. A managed service, a self-hosted flag service, and an environment-variable check have different operational costs. If your application already uses PostgreSQL, storing flags alongside its data is another option.
What if your PostgreSQL database — the one you already have — could be your feature flag store?
The Problem with Environment Variables
// The simplest approach — but painful in practice
@Get('analytics')
async analytics() {
if (process.env.ENABLE_ANALYTICS !== 'true') {
throw new ForbiddenException();
}
return this.analyticsService.getDashboard();
}This works until you need:
- Per-tenant flags — Tenant A gets the feature, Tenant B doesn't
- Gradual rollout — Enable for 10% of users, then 50%, then 100%
- Runtime changes — Change flag state without redeploying, subject to evaluation precedence and cache propagation
- User overrides — QA team needs access before launch
A database-backed package supplies these mechanics while leaving your application responsible for authentication, flag operations, and monitoring.
Database-Backed Flags
The approach: store flags in PostgreSQL, evaluate them at request time, cache aggressively.
This abbreviated schema illustrates the storage shape. Use the complete installation schema for required timestamps, metadata, indexes, and constraints.
-- Conceptual excerpt, not a complete migration
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT false,
percentage INT DEFAULT 0,
archived_at TIMESTAMPTZ
);
CREATE TABLE feature_flag_overrides (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flag_id UUID REFERENCES feature_flags(id) ON DELETE CASCADE,
attributes JSONB NOT NULL,
enabled BOOLEAN NOT NULL,
priority INT NOT NULL DEFAULT 0
);Version 0.5 uses attribute-based overrides rather than fixed tenant, user, and environment columns. Top-level evaluation context values are merged into the attribute set. The evaluation priority chain has four layers:
- Archived? → always false
- Best matching attribute override → specificity, priority, creation time, then ID
- Percentage rollout? → deterministic hash
- Global default →
enabledfield
The Decorator Pattern
Instead of if/else in every controller, a decorator-based guard:
@Get('analytics')
@FeatureFlag('PREMIUM_ANALYTICS')
async analytics() {
// Only executes if flag is enabled for the current context
return this.analyticsService.getDashboard();
}The guard resolves the configured evaluation context, evaluates the four-layer cascade, and returns 403 if the flag is off.
The global enabled field is a fallback after overrides and percentage evaluation. Turning it off does not stop a matching enabling override or an active percentage rollout. See rollout semantics.
Historical cache measurements
Without caching, every @FeatureFlag() check hits the database. With a 30-second TTL in-memory cache:
| Scenario | Latency |
|---|---|
| Cache hit | 0.04ms |
| Cache miss (DB lookup) | 1.17ms |
The earlier report described a 29.2x speedup for that local workload. These historical measurements used Apple Silicon, PostgreSQL 16, Prisma 7.9.1, and local Docker; they were not rerun for the documentation correction or unreleased fixes. See the benchmark methodology and corrected bulk count.
Using @nestarc/feature-flag
This is exactly what @nestarc/feature-flag implements:
npm install @nestarc/feature-flag@0.5.0// app.module.ts
FeatureFlagModule.forRoot({
environment: 'production',
prisma,
cacheTtlMs: 30_000,
// Use your authenticated principal in production.
userIdExtractor: (req) => {
const header = req.headers['x-user-id'];
return Array.isArray(header) ? header[0] ?? null : header ?? null;
},
}),This registration fragment assumes an existing Prisma client with the feature-flag models. Follow Installation and first evaluation for peer dependencies, migrations, DI wiring, and an expected HTTP result. Events additionally require EventEmitterModule.forRoot().
Documentation · GitHub · Benchmark
Published version limits
In npm 0.5.0, supply a stable userId or tenantId for percentage rollouts; an explicit targetingKey is dropped by the service path. Typed-client registry bucketing and bulk registry bucketing also have known inconsistencies. Fixes and direct custom-backend registration options are unreleased. See the agent guide's version boundary before copying current repository examples.
The package avoids an external feature-flag service, but it still requires its NestJS/Prisma peers and a database. The default 30-second TTL is a starting point. Redis invalidation is best-effort and does not make toggles instantaneous across replicas.