Skip to content

Instantly share code, notes, and snippets.

@nextlevelshit
Created July 11, 2025 09:47
Show Gist options
  • Select an option

  • Save nextlevelshit/57d2011ad10e7dae078e95274907353a to your computer and use it in GitHub Desktop.

Select an option

Save nextlevelshit/57d2011ad10e7dae078e95274907353a to your computer and use it in GitHub Desktop.
The 12-Factor App: Modern JavaScript/ESM Edition

The 12-Factor App: Modern JavaScript/ESM Edition

1. Codebase

One codebase tracked in revision control, many deploys

// ❌ Bad: Multiple repos for same logical app
conference-web/
conference-api/
conference-workers/

// ✅ Good: Single repo, different entry points
conference-marketplace/
├── src/
   ├── web/
      └── server.js
   ├── api/
      └── server.js
   └── workers/
       └── page-generator.js
├── shared/
   ├── config.js
   └── utils.js
└── package.json

Different deployments from same codebase:

{
  "scripts": {
    "start:web": "node src/web/server.js",
    "start:api": "node src/api/server.js", 
    "start:worker": "node src/workers/page-generator.js"
  }
}

2. Dependencies

Explicitly declare and isolate dependencies

// package.json with exact engines
{
  "type": "module",
  "engines": {
    "node": ">=20.0.0"
  },
  "dependencies": {
    "express": "^4.18.2",
    "helmet": "^7.0.0"
  }
}
// src/config.js - Clean ESM imports
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const packageJson = JSON.parse(
  readFileSync(join(process.cwd(), 'package.json'), 'utf8')
);

export const appVersion = packageJson.version;

3. Config

Store config in the environment

// src/config.js
import { z } from 'zod';

const configSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().min(1),
});

export const config = configSchema.parse(process.env);
// src/web/server.js
import { config } from '../config.js';
import express from 'express';

const app = express();

app.listen(config.PORT, () => {
  console.log(`Server running on port ${config.PORT}`);
});

4. Backing Services

Treat backing services as attached resources

// src/services/cache.js
import Redis from 'ioredis';

export class CacheService {
  #client;
  
  constructor(url) {
    this.#client = new Redis(url);
  }
  
  async get(key) {
    const value = await this.#client.get(key);
    return value ? JSON.parse(value) : null;
  }
  
  async set(key, value, ttl = 3600) {
    await this.#client.setex(key, ttl, JSON.stringify(value));
  }
  
  async close() {
    await this.#client.disconnect();
  }
}
// src/services/database.js
import pg from 'pg';

export class DatabaseService {
  #pool;
  
  constructor(connectionString) {
    this.#pool = new pg.Pool({ connectionString });
  }
  
  async query(text, params) {
    const client = await this.#pool.connect();
    try {
      const result = await client.query(text, params);
      return result.rows;
    } finally {
      client.release();
    }
  }
  
  async close() {
    await this.#pool.end();
  }
}

Service factory pattern for your conference marketplace:

// src/services/index.js
import { config } from '../config.js';
import { CacheService } from './cache.js';
import { DatabaseService } from './database.js';

export const createServices = () => ({
  cache: new CacheService(config.REDIS_URL),
  database: new DatabaseService(config.DATABASE_URL),
});

5. Build, Release, Run

Strictly separate build and run stages

// package.json
{
  "type": "module",
  "scripts": {
    "build": "parcel build src/web/index.html --dist-dir dist/web",
    "build:api": "node scripts/build-api.js",
    "start": "node dist/server.js",
    "dev": "node --watch src/web/server.js"
  }
}
// scripts/build-api.js
import { build } from 'esbuild';
import { readdir } from 'node:fs/promises';

const entryPoints = await readdir('src/api', { withFileTypes: true });
const apiFiles = entryPoints
  .filter(dirent => dirent.isFile() && dirent.name.endsWith('.js'))
  .map(dirent => `src/api/${dirent.name}`);

await build({
  entryPoints: apiFiles,
  bundle: true,
  platform: 'node',
  target: 'node20',
  format: 'esm',
  outdir: 'dist/api',
  external: ['pg', 'ioredis'], // Keep external deps
});

6. Processes

Execute as stateless processes

// ❌ Bad: In-memory state
const userSessions = new Map();

export const loginHandler = (req, res) => {
  const sessionId = crypto.randomUUID();
  userSessions.set(sessionId, { userId: req.body.userId });
  res.json({ sessionId });
};

// ✅ Good: External state storage
export const createLoginHandler = (cache) => async (req, res) => {
  const sessionId = crypto.randomUUID();
  await cache.set(`session:${sessionId}`, { userId: req.body.userId });
  res.json({ sessionId });
};
// src/web/server.js
import express from 'express';
import { createServices } from '../services/index.js';
import { createLoginHandler } from '../handlers/auth.js';

const app = express();
const services = createServices();

app.post('/login', createLoginHandler(services.cache));

// Graceful shutdown
process.on('SIGTERM', async () => {
  await services.cache.close();
  await services.database.close();
  process.exit(0);
});

7. Port Binding

Export services via port binding

// src/web/server.js
import express from 'express';
import { config } from '../config.js';

const app = express();

// Self-contained HTTP service
const server = app.listen(config.PORT, () => {
  console.log(`Conference marketplace on port ${config.PORT}`);
});

// Export for testing
export { app, server };
// src/api/server.js - Different service, different port
import express from 'express';
import { config } from '../config.js';

const app = express();
const apiPort = config.API_PORT || 3001;

app.listen(apiPort, () => {
  console.log(`API server on port ${apiPort}`);
});

8. Concurrency

Scale via process model

// src/web/server.js - Web process
import express from 'express';
import { createServices } from '../services/index.js';

const app = express();
const { cache, database } = createServices();

app.get('/conference/:slug', async (req, res) => {
  const conference = await database.query(
    'SELECT * FROM conferences WHERE slug = $1',
    [req.params.slug]
  );
  res.json(conference[0]);
});
// src/workers/page-generator.js - Worker process
import { Queue, Worker } from 'bullmq';
import { createServices } from '../services/index.js';
import { config } from '../config.js';

const { cache, database } = createServices();

const pageQueue = new Queue('page-generation', {
  connection: { host: config.REDIS_URL }
});

const worker = new Worker('page-generation', async (job) => {
  const { conferenceSlug } = job.data;
  const conference = await database.query(
    'SELECT * FROM conferences WHERE slug = $1',
    [conferenceSlug]
  );
  
  // Generate and cache page
  const page = generateConferencePage(conference[0]);
  await cache.set(`page:${conferenceSlug}`, page);
}, {
  connection: { host: config.REDIS_URL }
});

export { pageQueue, worker };

9. Disposability

Fast startup, graceful shutdown

// src/web/server.js
import express from 'express';
import { createServices } from '../services/index.js';

const app = express();
let services;

// Lazy service initialization for fast startup
const getServices = () => {
  if (!services) {
    services = createServices();
  }
  return services;
};

app.get('/conference/:slug', async (req, res) => {
  const { database } = getServices();
  const conference = await database.query(/* ... */);
  res.json(conference[0]);
});

const server = app.listen(config.PORT);

// Graceful shutdown
const shutdown = async (signal) => {
  console.log(`Received ${signal}, shutting down gracefully`);
  
  server.close(() => {
    console.log('HTTP server closed');
  });
  
  if (services) {
    await services.cache.close();
    await services.database.close();
  }
  
  process.exit(0);
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

10. Dev/Prod Parity

Keep environments similar

// src/config.js - Same config shape everywhere
export const config = {
  database: {
    url: process.env.DATABASE_URL,
    ssl: process.env.NODE_ENV === 'production',
    pool: {
      min: process.env.DB_POOL_MIN || 2,
      max: process.env.DB_POOL_MAX || 10,
    }
  },
  redis: {
    url: process.env.REDIS_URL,
    retryDelayOnFailover: 100,
  }
};
# Same container for all environments
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "src/web/server.js"]

11. Logs

Treat logs as event streams

// src/lib/logger.js
import { createLogger, format, transports } from 'winston';

export const logger = createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: format.combine(
    format.timestamp(),
    format.json()
  ),
  transports: [
    new transports.Console()
  ]
});
// src/web/server.js
import { logger } from '../lib/logger.js';

app.use((req, res, next) => {
  const start = Date.now();
  
  res.on('finish', () => {
    logger.info({
      method: req.method,
      url: req.url,
      status: res.statusCode,
      duration: Date.now() - start,
      userAgent: req.get('User-Agent')
    });
  });
  
  next();
});

12. Admin Processes

One-off admin tasks

// scripts/migrate.js
import { createServices } from '../src/services/index.js';

const { database } = createServices();

const migrations = [
  'CREATE TABLE IF NOT EXISTS conferences (id SERIAL PRIMARY KEY, slug TEXT UNIQUE)',
  'CREATE INDEX IF NOT EXISTS idx_conferences_slug ON conferences(slug)',
];

for (const migration of migrations) {
  await database.query(migration);
  console.log(`Executed: ${migration}`);
}

await database.close();
process.exit(0);
// scripts/seed-conferences.js
import { readFile } from 'node:fs/promises';
import { createServices } from '../src/services/index.js';

const { database } = createServices();

const seedData = JSON.parse(
  await readFile('data/conferences.json', 'utf8')
);

for (const conference of seedData) {
  await database.query(
    'INSERT INTO conferences (slug, name, date) VALUES ($1, $2, $3) ON CONFLICT (slug) DO NOTHING',
    [conference.slug, conference.name, conference.date]
  );
}

console.log(`Seeded ${seedData.length} conferences`);
await database.close();
{
  "scripts": {
    "db:migrate": "node scripts/migrate.js",
    "db:seed": "node scripts/seed-conferences.js",
    "cache:clear": "node scripts/clear-cache.js"
  }
}

Conference Marketplace Example

// src/web/server.js
import express from 'express';
import { config } from '../config.js';
import { createServices } from '../services/index.js';
import { logger } from '../lib/logger.js';

const app = express();
const services = createServices();

app.get('/conference/:slug', async (req, res) => {
  try {
    // Try cache first
    const cached = await services.cache.get(`page:${req.params.slug}`);
    if (cached) {
      logger.info({ event: 'cache_hit', slug: req.params.slug });
      return res.send(cached);
    }
    
    // Generate fresh page
    const conference = await services.database.query(
      'SELECT * FROM conferences WHERE slug = $1',
      [req.params.slug]
    );
    
    if (!conference.length) {
      return res.status(404).send('Conference not found');
    }
    
    const page = generateConferencePage(conference[0]);
    await services.cache.set(`page:${req.params.slug}`, page, 3600);
    
    logger.info({ 
      event: 'page_generated', 
      slug: req.params.slug,
      seo_ready: true 
    });
    
    res.send(page);
  } catch (error) {
    logger.error({ 
      event: 'page_error', 
      slug: req.params.slug, 
      error: error.message 
    });
    res.status(500).send('Internal error');
  }
});

const server = app.listen(config.PORT, () => {
  logger.info({ event: 'server_started', port: config.PORT });
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  server.close();
  await services.cache.close();
  await services.database.close();
  logger.info({ event: 'server_shutdown' });
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment