Feature Flag Service Tutorial · Module 01 of 10

Foundations & Project Setup

Build the foundation for a feature flag service. Create a Git repo, set up Node.js, design the database schema for flags, contexts, and evaluations, and scaffold the API. By the end, you'll have a working skeleton ready for flag logic.

~3–4 hrsBeginnerSetup focus
← Back to Module 01 overview
What You'll Have at the End

Definition of Done

  • Git repository initialized with Node.js 20, TypeScript, ESLint, Prettier.
  • PostgreSQL database with schema: flags, flag_rules, flag_evaluations, audit_logs.
  • Express server with health check endpoint.
  • Design doc explaining flag anatomy, targeting, and evaluation rules.
  • README with quick start and architecture overview.
  • All changes committed to git.
The Steps

Build It

STEP 1

Initialize Git repository

mkdir feature-flag-service
cd feature-flag-service
git init
git config user.email "you@example.com"
git config user.name "Your Name"
git branch -M main
✓ Verify: git status shows "On branch main".
STEP 2

Initialize Node.js project

npm init -y
npm install express postgres pino uuid dotenv

Install dev dependencies:

npm install --save-dev typescript @types/node @types/express eslint prettier ts-node nodemon

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Create .eslintrc.json:

{
  "env": { "node": true, "es2020": true },
  "extends": "eslint:recommended",
  "parserOptions": { "ecmaVersion": "latest", "sourceType": "module" },
  "rules": { "no-console": "off", "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }
}

Create .prettierrc.json:

{
  "semi": true,
  "singleQuote": true,
  "trailingComma": "es5",
  "printWidth": 100,
  "tabWidth": 2
}
✓ Verify: npx tsc --version shows TypeScript version. npm run lint passes.
STEP 3

Create project structure

mkdir -p src/{routes,db,types,middleware,utils}
mkdir -p docs
✓ Verify: Directories exist and are empty.
STEP 4

Set up database connection

Create src/db/pool.ts:

import { Pool } from 'postgres';

const pool = new Pool({
  host: process.env.DB_HOST || 'localhost',
  port: parseInt(process.env.DB_PORT || '5432'),
  database: process.env.DB_NAME || 'feature_flag_service',
  user: process.env.DB_USER || 'postgres',
  password: process.env.DB_PASSWORD || 'postgres'
});

pool.on('error', (err) => {
  console.error('Unexpected error on idle client', err);
});

export default pool;
✓ Verify: File compiles without errors.
STEP 5

Design database schema

Create src/db/schema.sql:

-- Flags: Feature flag definitions
CREATE TABLE IF NOT EXISTS flags (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL UNIQUE,
  description TEXT,
  status TEXT DEFAULT 'off', -- off, on, percentage, rules
  percentage_enabled INT DEFAULT 0, -- 0-100 for percentage-based rollout
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Flag Rules: Targeting rules for a flag (e.g., user in segment, beta tester)
CREATE TABLE IF NOT EXISTS flag_rules (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  flag_id UUID NOT NULL REFERENCES flags(id) ON DELETE CASCADE,
  priority INT NOT NULL, -- Lower number = higher priority
  rule_type TEXT NOT NULL, -- 'segment', 'user_attribute', 'percentage'
  rule_value JSONB NOT NULL, -- {"segment": "beta_testers"} or {"attribute": "country", "operator": "eq", "value": "US"}
  enabled BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Flag Evaluations: Log of flag checks for auditing
CREATE TABLE IF NOT EXISTS flag_evaluations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  flag_id UUID NOT NULL REFERENCES flags(id) ON DELETE CASCADE,
  context_id TEXT NOT NULL, -- User ID, Session ID, etc.
  result BOOLEAN NOT NULL, -- true = flag enabled, false = disabled
  evaluated_rule_id UUID, -- Which rule matched (if any)
  timestamp TIMESTAMP DEFAULT NOW()
);

-- Audit Logs: Who changed what
CREATE TABLE IF NOT EXISTS audit_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  flag_id UUID REFERENCES flags(id) ON DELETE SET NULL,
  action TEXT NOT NULL, -- 'created', 'updated', 'deleted', 'toggled'
  details JSONB,
  changed_by TEXT, -- User email or service name
  timestamp TIMESTAMP DEFAULT NOW()
);

-- Indexes for performance
CREATE INDEX idx_flags_name ON flags(name);
CREATE INDEX idx_flag_rules_flag_id ON flag_rules(flag_id, priority);
CREATE INDEX idx_evaluations_flag_id ON flag_evaluations(flag_id);
CREATE INDEX idx_evaluations_context ON flag_evaluations(context_id);
CREATE INDEX idx_audit_logs_flag_id ON audit_logs(flag_id);
CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp);
✓ Verify: Schema is syntactically correct SQL.
STEP 6

Create database migration runner

Create src/db/migrations.ts:

import fs from 'fs';
import path from 'path';
import pool from './pool';

export async function runMigrations() {
  try {
    const schemaPath = path.join(__dirname, 'schema.sql');
    const schema = fs.readFileSync(schemaPath, 'utf-8');

    const client = await pool.connect();
    try {
      await client.query(schema);
      console.log('✓ Database migrations completed');
    } finally {
      client.release();
    }
  } catch (err) {
    console.error('Migration failed:', err);
    process.exit(1);
  }
}

export default runMigrations;
✓ Verify: File compiles without errors.
STEP 7

Create Express server skeleton

Create src/index.ts:

import express, { Request, Response } from 'express';
import runMigrations from './db/migrations';
import flagRoutes from './routes/flags';

const app = express();
const PORT = parseInt(process.env.PORT || '3000');

// Middleware
app.use(express.json());

// Health check
app.get('/health', (_req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Routes
app.use('/flags', flagRoutes);

// 404 handler
app.use((_req: Request, res: Response) => {
  res.status(404).json({ error: 'Not found' });
});

// Error handler
app.use((err: any, _req: Request, res: Response) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

// Start server
async function start() {
  try {
    await runMigrations();
    app.listen(PORT, () => {
      console.log(`✓ Server running on http://localhost:${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err);
    process.exit(1);
  }
}

start();