Files
flyer-crawler.projectium.com/docs/development/CODE-PATTERNS.md
Torben Sorensen 61f24305fb
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 22m13s
ADR-024 Feature Flagging Strategy
2026-01-28 23:23:45 -08:00

17 KiB

Code Patterns

Common code patterns extracted from Architecture Decision Records (ADRs). Use these as templates when writing new code.

Quick Reference

Pattern Key Function/Class Import From
Error Handling handleDbError(), NotFoundError src/services/db/errors.db.ts
Repository Methods get*, find*, list* src/services/db/*.db.ts
API Responses sendSuccess(), sendPaginated(), sendError() src/utils/apiResponse.ts
Transactions withTransaction() src/services/db/connection.db.ts
Validation validateRequest() src/middleware/validation.ts
Authentication authenticateJWT src/middleware/auth.ts
Caching cacheService src/services/cache.server.ts
Background Jobs Queue classes src/services/queues.server.ts
Feature Flags isFeatureEnabled(), useFeatureFlag() src/services/featureFlags.server.ts

Table of Contents


Error Handling

ADR: ADR-001

Repository Layer Error Handling

import { handleDbError, NotFoundError } from '../services/db/errors.db';
import { PoolClient } from 'pg';

export async function getFlyerById(id: number, client?: PoolClient): Promise<Flyer> {
  const db = client || pool;

  try {
    const result = await db.query('SELECT * FROM flyers WHERE id = $1', [id]);

    if (result.rows.length === 0) {
      throw new NotFoundError('Flyer', id);
    }

    return result.rows[0];
  } catch (error) {
    throw handleDbError(error);
  }
}

Route Layer Error Handling

import { sendError } from '../utils/apiResponse';

app.get('/api/v1/flyers/:id', async (req, res) => {
  try {
    const flyer = await flyerDb.getFlyerById(parseInt(req.params.id));
    return sendSuccess(res, flyer);
  } catch (error) {
    // IMPORTANT: Use req.originalUrl for dynamic path logging (not hardcoded paths)
    req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
    return sendError(res, error);
  }
});

Best Practice: Always use req.originalUrl.split('?')[0] in error log messages instead of hardcoded paths. This ensures logs reflect the actual request URL including version prefixes (/api/v1/). See Error Logging Path Patterns for details.

Custom Error Types

// NotFoundError - Entity not found
throw new NotFoundError('Flyer', id);

// ValidationError - Invalid input
throw new ValidationError('Invalid email format');

// DatabaseError - Database operation failed
throw new DatabaseError('Failed to insert flyer', originalError);

Repository Patterns

ADR: ADR-034

Method Naming Conventions

Prefix Returns Not Found Behavior Use Case
get* Entity Throws NotFoundError When entity must exist
find* Entity | null Returns null When entity may not exist
list* Array Returns [] When returning multiple

Get Method (Must Exist)

/**
 * Get a flyer by ID. Throws NotFoundError if not found.
 */
export async function getFlyerById(id: number, client?: PoolClient): Promise<Flyer> {
  const db = client || pool;

  try {
    const result = await db.query('SELECT * FROM flyers WHERE id = $1', [id]);

    if (result.rows.length === 0) {
      throw new NotFoundError('Flyer', id);
    }

    return result.rows[0];
  } catch (error) {
    throw handleDbError(error);
  }
}

Find Method (May Not Exist)

/**
 * Find a flyer by ID. Returns null if not found.
 */
export async function findFlyerById(id: number, client?: PoolClient): Promise<Flyer | null> {
  const db = client || pool;

  try {
    const result = await db.query('SELECT * FROM flyers WHERE id = $1', [id]);

    return result.rows[0] || null;
  } catch (error) {
    throw handleDbError(error);
  }
}

List Method (Multiple Results)

/**
 * List all active flyers. Returns empty array if none found.
 */
export async function listActiveFlyers(client?: PoolClient): Promise<Flyer[]> {
  const db = client || pool;

  try {
    const result = await db.query(
      'SELECT * FROM flyers WHERE end_date >= CURRENT_DATE ORDER BY start_date DESC',
    );

    return result.rows;
  } catch (error) {
    throw handleDbError(error);
  }
}

API Response Patterns

ADR: ADR-028

Success Response

import { sendSuccess } from '../utils/apiResponse';

app.post('/api/v1/flyers', async (req, res) => {
  const flyer = await flyerService.createFlyer(req.body);
  // sendSuccess(res, data, statusCode?, meta?)
  return sendSuccess(res, flyer, 201);
});

Paginated Response

import { sendPaginated } from '../utils/apiResponse';

app.get('/api/v1/flyers', async (req, res) => {
  const page = parseInt(req.query.page as string) || 1;
  const limit = parseInt(req.query.limit as string) || 20;
  const { items, total } = await flyerService.listFlyers(page, limit);

  // sendPaginated(res, data[], { page, limit, total }, meta?)
  return sendPaginated(res, items, { page, limit, total });
});

Error Response

import { sendError, sendSuccess, ErrorCode } from '../utils/apiResponse';

app.get('/api/v1/flyers/:id', async (req, res) => {
  try {
    const flyer = await flyerDb.getFlyerById(parseInt(req.params.id));
    return sendSuccess(res, flyer);
  } catch (error) {
    // sendError(res, code, message, statusCode?, details?, meta?)
    if (error instanceof NotFoundError) {
      return sendError(res, ErrorCode.NOT_FOUND, error.message, 404);
    }
    req.log.error({ error }, `Error in ${req.originalUrl.split('?')[0]}:`);
    return sendError(res, ErrorCode.INTERNAL_ERROR, 'An error occurred', 500);
  }
});

Transaction Management

ADR: ADR-002

Basic Transaction

import { withTransaction } from '../services/db/connection.db';

export async function createFlyerWithItems(
  flyerData: FlyerInput,
  items: FlyerItemInput[],
): Promise<Flyer> {
  return withTransaction(async (client) => {
    // Create flyer
    const flyer = await flyerDb.createFlyer(flyerData, client);

    // Create items
    const createdItems = await flyerItemDb.createItems(
      items.map((item) => ({ ...item, flyer_id: flyer.id })),
      client,
    );

    // Automatically commits on success, rolls back on error
    return { ...flyer, items: createdItems };
  });
}

Nested Transactions

export async function bulkImportFlyers(flyersData: FlyerInput[]): Promise<ImportResult> {
  return withTransaction(async (client) => {
    const results = [];

    for (const flyerData of flyersData) {
      try {
        // Each flyer import is atomic
        const flyer = await createFlyerWithItems(
          flyerData,
          flyerData.items,
          client, // Pass transaction client
        );
        results.push({ success: true, flyer });
      } catch (error) {
        results.push({ success: false, error: error.message });
      }
    }

    return results;
  });
}

Input Validation

ADR: ADR-003

Zod Schema Definition

// src/schemas/flyer.schemas.ts
import { z } from 'zod';

export const createFlyerSchema = z.object({
  store_id: z.number().int().positive(),
  image_url: z
    .string()
    .url()
    .regex(/^https?:\/\/.*/),
  start_date: z.string().datetime(),
  end_date: z.string().datetime(),
  items: z
    .array(
      z.object({
        name: z.string().min(1).max(255),
        price: z.number().positive(),
        quantity: z.string().optional(),
      }),
    )
    .min(1),
});

export type CreateFlyerInput = z.infer<typeof createFlyerSchema>;

Route Validation Middleware

import { validateRequest } from '../middleware/validation';
import { createFlyerSchema } from '../schemas/flyer.schemas';

app.post('/api/v1/flyers', validateRequest(createFlyerSchema), async (req, res) => {
  // req.body is now type-safe and validated
  const flyer = await flyerService.createFlyer(req.body);
  return sendSuccess(res, flyer, 201);
});

Manual Validation

import { createFlyerSchema } from '../schemas/flyer.schemas';

export async function processFlyer(data: unknown): Promise<Flyer> {
  // Validate and parse input
  const validated = createFlyerSchema.parse(data);

  // Type-safe from here on
  return flyerDb.createFlyer(validated);
}

Authentication

ADR: ADR-048

Protected Route with JWT

import { authenticateJWT } from '../middleware/auth';

app.get(
  '/api/v1/profile',
  authenticateJWT, // Middleware adds req.user
  async (req, res) => {
    // req.user is guaranteed to exist
    const user = await userDb.getUserById(req.user.id);
    return sendSuccess(res, user);
  },
);

Optional Authentication

import { optionalAuth } from '../middleware/auth';

app.get(
  '/api/v1/flyers',
  optionalAuth, // req.user may or may not exist
  async (req, res) => {
    const flyers = req.user
      ? await flyerDb.listFlyersForUser(req.user.id)
      : await flyerDb.listPublicFlyers();

    return sendSuccess(res, flyers);
  },
);

Generate JWT Token

import jwt from 'jsonwebtoken';
import { env } from '../config/env';

export function generateToken(user: User): string {
  return jwt.sign({ id: user.id, email: user.email }, env.JWT_SECRET, { expiresIn: '7d' });
}

Caching

ADR: ADR-009

Cache Pattern

import { cacheService } from '../services/cache.server';

export async function getFlyer(id: number): Promise<Flyer> {
  // Try cache first
  const cached = await cacheService.get<Flyer>(`flyer:${id}`);
  if (cached) return cached;

  // Cache miss - fetch from database
  const flyer = await flyerDb.getFlyerById(id);

  // Store in cache (1 hour TTL)
  await cacheService.set(`flyer:${id}`, flyer, 3600);

  return flyer;
}

Cache Invalidation

export async function updateFlyer(id: number, data: UpdateFlyerInput): Promise<Flyer> {
  const flyer = await flyerDb.updateFlyer(id, data);

  // Invalidate cache
  await cacheService.delete(`flyer:${id}`);
  await cacheService.invalidatePattern('flyers:list:*');

  return flyer;
}

Background Jobs

ADR: ADR-006

Queue Job

import { flyerProcessingQueue } from '../services/queues.server';

export async function enqueueFlyerProcessing(flyerId: number): Promise<void> {
  await flyerProcessingQueue.add(
    'process-flyer',
    {
      flyerId,
      timestamp: Date.now(),
    },
    {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000,
      },
    },
  );
}

Process Job

// src/services/workers.server.ts
import { Worker } from 'bullmq';

const flyerWorker = new Worker(
  'flyer-processing',
  async (job) => {
    const { flyerId } = job.data;

    try {
      // Process flyer
      const result = await aiService.extractFlyerData(flyerId);
      await flyerDb.updateFlyerWithData(flyerId, result);

      // Update progress
      await job.updateProgress(100);

      return { success: true, itemCount: result.items.length };
    } catch (error) {
      logger.error('Flyer processing failed', { flyerId, error });
      throw error; // Will retry automatically
    }
  },
  {
    connection: redisConnection,
    concurrency: 5,
  },
);

Feature Flags

ADR: ADR-024

Feature flags enable controlled feature rollout, A/B testing, and quick production disablement without redeployment. All flags default to false (opt-in model).

Backend Usage

import { isFeatureEnabled, getFeatureFlags } from '../services/featureFlags.server';

// Check a specific flag in route handler
router.get('/dashboard', async (req, res) => {
  if (isFeatureEnabled('newDashboard')) {
    return sendSuccess(res, { version: 'v2', data: await getNewDashboardData() });
  }
  return sendSuccess(res, { version: 'v1', data: await getLegacyDashboardData() });
});

// Check flag in service layer
function processFlyer(flyer: Flyer): ProcessedFlyer {
  if (isFeatureEnabled('experimentalAi')) {
    return processWithExperimentalAi(flyer);
  }
  return processWithStandardAi(flyer);
}

// Get all flags (admin endpoint)
router.get('/admin/feature-flags', requireAdmin, async (req, res) => {
  sendSuccess(res, { flags: getFeatureFlags() });
});

Frontend Usage

import { useFeatureFlag, useAllFeatureFlags } from '../hooks/useFeatureFlag';
import { FeatureFlag } from '../components/FeatureFlag';

// Hook approach - for logic beyond rendering
function Dashboard() {
  const isNewDashboard = useFeatureFlag('newDashboard');

  useEffect(() => {
    if (isNewDashboard) {
      analytics.track('new_dashboard_viewed');
    }
  }, [isNewDashboard]);

  return isNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
}

// Declarative component approach
function App() {
  return (
    <FeatureFlag feature="newDashboard" fallback={<LegacyDashboard />}>
      <NewDashboard />
    </FeatureFlag>
  );
}

// Debug panel showing all flags
function DebugPanel() {
  const flags = useAllFeatureFlags();
  return (
    <ul>
      {Object.entries(flags).map(([name, enabled]) => (
        <li key={name}>
          {name}: {enabled ? 'ON' : 'OFF'}
        </li>
      ))}
    </ul>
  );
}

Adding a New Flag

  1. Backend (src/config/env.ts):

    // In featureFlagsSchema
    myNewFeature: booleanString(false),  // FEATURE_MY_NEW_FEATURE
    
    // In loadEnvVars()
    myNewFeature: process.env.FEATURE_MY_NEW_FEATURE,
    
  2. Frontend (src/config.ts and src/vite-env.d.ts):

    // In config.ts featureFlags section
    myNewFeature: import.meta.env.VITE_FEATURE_MY_NEW_FEATURE === 'true',
    
    // In vite-env.d.ts
    readonly VITE_FEATURE_MY_NEW_FEATURE?: string;
    
  3. Environment (.env.example):

    # FEATURE_MY_NEW_FEATURE=false
    # VITE_FEATURE_MY_NEW_FEATURE=false
    

Testing Feature Flags

// Backend - reset modules to test different states
beforeEach(() => {
  vi.resetModules();
  process.env.FEATURE_NEW_DASHBOARD = 'true';
});

// Frontend - mock config module
vi.mock('../config', () => ({
  default: {
    featureFlags: {
      newDashboard: true,
      betaRecipes: false,
    },
  },
}));

Flag Lifecycle

Phase Actions
Add Add to schemas (backend + frontend), default false, document
Enable Set env var ='true', restart application
Remove Remove conditional code, remove from schemas, remove env vars
Sunset Max 3 months after full rollout - remove flag

Current Flags

Flag Backend Env Var Frontend Env Var Purpose
bugsinkSync FEATURE_BUGSINK_SYNC VITE_FEATURE_BUGSINK_SYNC Bugsink error sync
advancedRbac FEATURE_ADVANCED_RBAC VITE_FEATURE_ADVANCED_RBAC Advanced RBAC features
newDashboard FEATURE_NEW_DASHBOARD VITE_FEATURE_NEW_DASHBOARD New dashboard experience
betaRecipes FEATURE_BETA_RECIPES VITE_FEATURE_BETA_RECIPES Beta recipe features
experimentalAi FEATURE_EXPERIMENTAL_AI VITE_FEATURE_EXPERIMENTAL_AI Experimental AI features
debugMode FEATURE_DEBUG_MODE VITE_FEATURE_DEBUG_MODE Debug mode