Back to Journal
SaaS Engineering

How to Build Feature Flag Architecture Using Nestjs

Step-by-step tutorial for building Feature Flag Architecture with Nestjs, from project setup through deployment.

Muneer Puthiya Purayil 22 min read

This tutorial walks through building a complete feature flag system using NestJS, from project setup through a production-ready flag evaluation service with targeting rules, percentage rollouts, and a management API.

Project Setup

bash
1nest new feature-flag-service
2cd feature-flag-service
3npm install @nestjs/config @nestjs/schedule @prisma/client class-validator class-transformer
4npm install -D prisma
5npx prisma init
6 

Database Schema

prisma
1// prisma/schema.prisma
2model FeatureFlag {
3 id String @id @default(uuid())
4 key String @unique
5 name String
6 description String?
7 enabled Boolean @default(false)
8 percentage Float @default(100)
9 rules Json @default("[]")
10 variants Json @default("[]")
11 defaultVariant String @default("")
12 type FlagType @default(RELEASE)
13 owner String?
14 expiresAt DateTime?
15 createdAt DateTime @default(now())
16 updatedAt DateTime @updatedAt
17 
18 @@index([key])
19 @@index([type])
20}
21 
22enum FlagType {
23 RELEASE
24 EXPERIMENT
25 OPS
26 PERMISSION
27}
28 
29model FlagEvaluation {
30 id String @id @default(uuid())
31 flagKey String
32 userId String
33 enabled Boolean
34 variant String?
35 reason String
36 timestamp DateTime @default(now())
37 
38 @@index([flagKey, timestamp])
39 @@index([userId])
40}
41 

Flag Evaluation Module

typescript
1// src/flags/flag-evaluator.service.ts
2import { Injectable, OnModuleInit } from "@nestjs/common";
3import { Cron, CronExpression } from "@nestjs/schedule";
4import { PrismaService } from "../prisma/prisma.service";
5import { createHash } from "crypto";
6 
7interface FlagConfig {
8 key: string;
9 enabled: boolean;
10 percentage: number;
11 rules: Rule[];
12 variants: Variant[];
13 defaultVariant: string;
14}
15 
16interface Rule {
17 conditions: Condition[];
18 variant: string;
19 priority: number;
20}
21 
22interface Condition {
23 attribute: string;
24 operator: "eq" | "neq" | "in" | "contains" | "starts_with";
25 values: string[];
26}
27 
28interface Variant {
29 key: string;
30 weight: number;
31}
32 
33export interface EvalContext {
34 userId: string;
35 email?: string;
36 plan?: string;
37 country?: string;
38 properties?: Record<string, string>;
39}
40 
41export interface EvalResult {
42 enabled: boolean;
43 variant: string;
44 reason: string;
45}
46 
47@Injectable()
48export class FlagEvaluatorService implements OnModuleInit {
49 private flags = new Map<string, FlagConfig>();
50 
51 constructor(private prisma: PrismaService) {}
52 
53 async onModuleInit() {
54 await this.syncFlags();
55 }
56 
57 @Cron(CronExpression.EVERY_10_SECONDS)
58 async syncFlags() {
59 const dbFlags = await this.prisma.featureFlag.findMany({
60 where: { enabled: true },
61 });
62 
63 const newFlags = new Map<string, FlagConfig>();
64 for (const flag of dbFlags) {
65 newFlags.set(flag.key, {
66 key: flag.key,
67 enabled: flag.enabled,
68 percentage: flag.percentage,
69 rules: flag.rules as Rule[],
70 variants: flag.variants as Variant[],
71 defaultVariant: flag.defaultVariant,
72 });
73 }
74 this.flags = newFlags;
75 }
76 
77 evaluate(flagKey: string, context: EvalContext): EvalResult {
78 const flag = this.flags.get(flagKey);
79 if (!flag || !flag.enabled) {
80 return { enabled: false, variant: "", reason: "disabled" };
81 }
82 
83 // Rule-based targeting
84 const sortedRules = [...flag.rules].sort(
85 (a, b) => b.priority - a.priority
86 );
87 for (const rule of sortedRules) {
88 if (this.matchesAll(rule.conditions, context)) {
89 return { enabled: true, variant: rule.variant, reason: "rule_match" };
90 }
91 }
92 
93 // Percentage rollout
94 if (flag.percentage > 0 && flag.percentage < 100) {
95 const bucket = this.hashBucket(flagKey, context.userId);
96 if (bucket < flag.percentage) {
97 return {
98 enabled: true,
99 variant: flag.defaultVariant,
100 reason: "percentage",
101 };
102 }
103 return { enabled: false, variant: "", reason: "percentage_excluded" };
104 }
105 
106 return { enabled: true, variant: flag.defaultVariant, reason: "default" };
107 }
108 
109 isEnabled(flagKey: string, context: EvalContext): boolean {
110 return this.evaluate(flagKey, context).enabled;
111 }
112 
113 private hashBucket(flagKey: string, userId: string): number {
114 const hash = createHash("sha256")
115 .update(`${flagKey}:${userId}`)
116 .digest();
117 return (hash.readUInt32BE(0) / 0xffffffff) * 100;
118 }
119 
120 private matchesAll(conditions: Condition[], ctx: EvalContext): boolean {
121 return conditions.every((c) => this.matchCondition(c, ctx));
122 }
123 
124 private matchCondition(cond: Condition, ctx: EvalContext): boolean {
125 const value = this.getAttribute(cond.attribute, ctx);
126 switch (cond.operator) {
127 case "eq": return cond.values[0] === value;
128 case "neq": return cond.values[0] !== value;
129 case "in": return cond.values.includes(value);
130 case "contains": return value.includes(cond.values[0]);
131 case "starts_with": return value.startsWith(cond.values[0]);
132 default: return false;
133 }
134 }
135 
136 private getAttribute(attr: string, ctx: EvalContext): string {
137 const map: Record<string, string | undefined> = {
138 user_id: ctx.userId,
139 email: ctx.email,
140 plan: ctx.plan,
141 country: ctx.country,
142 };
143 return map[attr] ?? ctx.properties?.[attr] ?? "";
144 }
145}
146 

Flag Guard Decorator

typescript
1// src/flags/flag.guard.ts
2import { Injectable, CanActivate, ExecutionContext, SetMetadata } from "@nestjs/common";
3import { Reflector } from "@nestjs/core";
4import { FlagEvaluatorService } from "./flag-evaluator.service";
5 
6export const FEATURE_FLAG_KEY = "feature_flag";
7export const FeatureFlag = (flagKey: string) => SetMetadata(FEATURE_FLAG_KEY, flagKey);
8 
9@Injectable()
10export class FeatureFlagGuard implements CanActivate {
11 constructor(
12 private reflector: Reflector,
13 private flagService: FlagEvaluatorService,
14 ) {}
15 
16 canActivate(context: ExecutionContext): boolean {
17 const flagKey = this.reflector.get<string>(FEATURE_FLAG_KEY, context.getHandler());
18 if (!flagKey) return true;
19 
20 const request = context.switchToHttp().getRequest();
21 const evalContext = {
22 userId: request.user?.id ?? request.headers["x-user-id"] ?? "",
23 plan: request.user?.plan ?? request.headers["x-plan"] ?? "",
24 email: request.user?.email ?? "",
25 };
26 
27 return this.flagService.isEnabled(flagKey, evalContext);
28 }
29}
30 

Management API Controller

typescript
1// src/flags/flags.controller.ts
2import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, HttpCode } from "@nestjs/common";
3import { IsString, IsBoolean, IsNumber, IsOptional, Min, Max } from "class-validator";
4import { PrismaService } from "../prisma/prisma.service";
5import { FlagEvaluatorService } from "./flag-evaluator.service";
6 
7class CreateFlagDto {
8 @IsString() key: string;
9 @IsString() name: string;
10 @IsOptional() @IsString() description?: string;
11 @IsOptional() @IsBoolean() enabled?: boolean;
12 @IsOptional() @IsNumber() @Min(0) @Max(100) percentage?: number;
13 @IsOptional() @IsString() owner?: string;
14 @IsOptional() @IsString() type?: string;
15}
16 
17class EvaluateDto {
18 @IsString() flagKey: string;
19 @IsString() userId: string;
20 @IsOptional() @IsString() plan?: string;
21 @IsOptional() @IsString() email?: string;
22}
23 
24@Controller("flags")
25export class FlagsController {
26 constructor(
27 private prisma: PrismaService,
28 private evaluator: FlagEvaluatorService,
29 ) {}
30 
31 @Get()
32 async listFlags(@Query("type") type?: string) {
33 return this.prisma.featureFlag.findMany({
34 where: type ? { type: type as any } : undefined,
35 orderBy: { createdAt: "desc" },
36 });
37 }
38 
39 @Post()
40 async createFlag(@Body() dto: CreateFlagDto) {
41 const flag = await this.prisma.featureFlag.create({ data: dto as any });
42 await this.evaluator.syncFlags();
43 return flag;
44 }
45 
46 @Put(":key")
47 async updateFlag(@Param("key") key: string, @Body() dto: Partial<CreateFlagDto>) {
48 const flag = await this.prisma.featureFlag.update({
49 where: { key },
50 data: dto as any,
51 });
52 await this.evaluator.syncFlags();
53 return flag;
54 }
55 
56 @Post("evaluate")
57 @HttpCode(200)
58 async evaluateFlag(@Body() dto: EvaluateDto) {
59 return this.evaluator.evaluate(dto.flagKey, {
60 userId: dto.userId,
61 plan: dto.plan,
62 email: dto.email,
63 });
64 }
65 
66 @Post(":key/toggle")
67 async toggleFlag(@Param("key") key: string) {
68 const current = await this.prisma.featureFlag.findUniqueOrThrow({ where: { key } });
69 const updated = await this.prisma.featureFlag.update({
70 where: { key },
71 data: { enabled: !current.enabled },
72 });
73 await this.evaluator.syncFlags();
74 return updated;
75 }
76 
77 @Delete(":key")
78 async deleteFlag(@Param("key") key: string) {
79 await this.prisma.featureFlag.delete({ where: { key } });
80 await this.evaluator.syncFlags();
81 }
82}
83 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Using the Guard in Route Handlers

typescript
1// src/checkout/checkout.controller.ts
2@Controller("checkout")
3@UseGuards(FeatureFlagGuard)
4export class CheckoutController {
5 @Post()
6 @FeatureFlag("new-checkout-flow")
7 async processCheckout(@Body() dto: CheckoutDto) {
8 return this.checkoutService.process(dto);
9 }
10}
11 

Bulk Evaluation Endpoint

For frontend applications that need to know all flag states at once:

typescript
1@Post("evaluate/bulk")
2@HttpCode(200)
3async evaluateBulk(@Body() body: { userId: string; plan?: string; flagKeys?: string[] }) {
4 const context: EvalContext = { userId: body.userId, plan: body.plan };
5 const flags = body.flagKeys ?? Array.from(this.evaluator.getAllFlagKeys());
6
7 const results: Record<string, EvalResult> = {};
8 for (const key of flags) {
9 results[key] = this.evaluator.evaluate(key, context);
10 }
11 return results;
12}
13 

Testing

typescript
1describe("FlagEvaluatorService", () => {
2 let service: FlagEvaluatorService;
3 
4 beforeEach(async () => {
5 const module = await Test.createTestingModule({
6 providers: [FlagEvaluatorService, { provide: PrismaService, useValue: mockPrisma }],
7 }).compile();
8 service = module.get(FlagEvaluatorService);
9 });
10 
11 it("evaluates percentage rollout correctly", async () => {
12 mockPrisma.featureFlag.findMany.mockResolvedValue([
13 { key: "test", enabled: true, percentage: 50, rules: "[]", variants: "[]", defaultVariant: "" },
14 ]);
15 await service.syncFlags();
16 
17 let enabled = 0;
18 for (let i = 0; i < 10_000; i++) {
19 if (service.isEnabled("test", { userId: `user-${i}` })) enabled++;
20 }
21 expect(enabled / 100).toBeGreaterThanOrEqual(48);
22 expect(enabled / 100).toBeLessThanOrEqual(52);
23 });
24 
25 it("applies targeting rules", async () => {
26 mockPrisma.featureFlag.findMany.mockResolvedValue([{
27 key: "enterprise",
28 enabled: true,
29 percentage: 0,
30 rules: JSON.stringify([{
31 conditions: [{ attribute: "plan", operator: "in", values: ["enterprise"] }],
32 variant: "full",
33 priority: 1,
34 }]),
35 variants: "[]",
36 defaultVariant: "",
37 }]);
38 await service.syncFlags();
39 
40 expect(service.isEnabled("enterprise", { userId: "1", plan: "enterprise" })).toBe(true);
41 expect(service.isEnabled("enterprise", { userId: "2", plan: "free" })).toBe(false);
42 });
43});
44 

Deployment

dockerfile
1FROM node:20-alpine AS builder
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci
5COPY . .
6RUN npx prisma generate
7RUN npm run build
8 
9FROM node:20-alpine
10WORKDIR /app
11COPY --from=builder /app/dist ./dist
12COPY --from=builder /app/node_modules ./node_modules
13COPY --from=builder /app/prisma ./prisma
14EXPOSE 3000
15CMD ["node", "dist/main.js"]
16 

Conclusion

NestJS provides an excellent foundation for feature flag services. The module system naturally separates flag evaluation from flag management, the guard decorator pattern enables clean endpoint-level gating, and the scheduling module handles background sync without external dependencies. The Prisma integration provides type-safe database access for flag configuration persistence.

This implementation covers the essentials: percentage-based rollouts with sticky bucketing, rule-based targeting, a management CRUD API, and a guard decorator for endpoint gating. Extend it with audit logging (NestJS interceptors), WebSocket push for real-time flag updates, and Prometheus metrics via @willsoto/nestjs-prometheus for production observability.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026