Episode 3 — NodeJS MongoDB Backend Architecture / 3.13 — Production Project Structure

3.13 — Production Project Structure: Quick Revision

Episode 3 supplement -- print-friendly.

How to use

Skim -> drill weak spots in 3.13.a through 3.13.f -> 3.13-Exercise-Questions.md.


Folder structure (layer-based)

project-root/
├── src/
│   ├── config/          -- db.js, index.js, constants.js
│   ├── controllers/     -- thin: parse req, call service, send res
│   ├── middleware/       -- auth, error handler, validation, uploads
│   ├── models/          -- Mongoose schemas
│   ├── routes/          -- URL -> controller mapping
│   ├── services/        -- business logic (no req/res)
│   ├── utils/           -- ApiError, ApiResponse, asyncHandler
│   ├── validators/      -- Joi/Zod schemas
│   └── app.js           -- Express setup (middleware, routes, error handler)
├── server.js            -- entry point: connect DB, bind port
├── .env / .env.example
├── .gitignore
├── ecosystem.config.js  -- PM2 config
├── .eslintrc.js
├── .prettierrc
└── package.json

app.js vs server.js: app = Express config (testable, portable). server = DB + port (production lifecycle).

Request flow: Client -> Route -> Middleware -> Controller -> Service -> Model -> DB -> back


Naming conventions

File typePatternExample
Model<resource>.model.jsuser.model.js
Controller<resource>.controller.jsuser.controller.js
Service<resource>.service.jsuser.service.js
Route<resource>.routes.jsuser.routes.js
Validator<resource>.validator.jsuser.validator.js
Middleware<name>.middleware.jsauth.middleware.js
Test<name>.test.jsuser.service.test.js

Rule: kebab-case for everything. Avoids case-sensitivity bugs (macOS vs Linux).


Essential .gitignore

node_modules/
.env
.env.local
.env.*.local
logs/
*.log
dist/
build/
coverage/
.DS_Store
Thumbs.db
.vscode/
.idea/
public/uploads/*
!public/uploads/.gitkeep

Commit .gitignore first, before any code.


.env template

# .env.example -- committed (no real secrets)
PORT=3000
NODE_ENV=development
MONGODB_URI=mongodb://localhost:27017/your-db-name
JWT_SECRET=replace-with-a-strong-random-string
JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=replace-with-another-strong-random-string
JWT_REFRESH_EXPIRES_IN=30d
CORS_ORIGIN=http://localhost:5173
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password
LOG_LEVEL=debug

Config module (single source of truth):

// src/config/index.js
const config = {
  env: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT, 10) || 3000,
  mongoUri: process.env.MONGODB_URI,
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '7d',
  },
};

// Fail fast
const required = ['MONGODB_URI', 'JWT_SECRET'];
const missing = required.filter((k) => !process.env[k]);
if (missing.length) throw new Error(`Missing env vars: ${missing.join(', ')}`);

module.exports = config;

PM2 commands

CommandWhat it does
pm2 start server.jsStart as background daemon
pm2 start server.js -i maxCluster mode (all CPUs)
pm2 start ecosystem.config.js --env productionStart from config file
pm2 listShow all processes
pm2 logsStream all logs
pm2 logs blog-apiLogs for one process
pm2 restart blog-apiKill + start (brief downtime)
pm2 reload blog-apiZero-downtime graceful restart
pm2 stop blog-apiStop a process
pm2 delete blog-apiRemove from PM2 list
pm2 monitTerminal monitoring dashboard
pm2 startupGenerate OS startup script
pm2 saveSave process list for auto-start on reboot

Ecosystem config:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'blog-api',
    script: './server.js',
    instances: 'max',
    exec_mode: 'cluster',
    autorestart: true,
    max_memory_restart: '1G',
    env_production: { NODE_ENV: 'production', PORT: 8080 },
  }],
};

ApiError class

class ApiError extends Error {
  constructor(statusCode, message = 'Something went wrong', errors = [], stack = '') {
    super(message);
    this.statusCode = statusCode;
    this.message = message;
    this.errors = errors;
    this.data = null;
    this.success = false;
    this.isOperational = true;  // true = expected; false = bug

    if (stack) this.stack = stack;
    else Error.captureStackTrace(this, this.constructor);
  }
}
Error typeisOperationalShow real message?
User not found (404)trueYes
Duplicate email (409)trueYes
TypeError, ReferenceErrorfalseNo -- "Something went wrong"

ApiResponse class

class ApiResponse {
  constructor(statusCode, data, message = 'Success') {
    this.statusCode = statusCode;
    this.data = data;
    this.message = message;
    this.success = statusCode < 400;
  }
}

// Usage
res.status(201).json(new ApiResponse(201, newPost, 'Post created successfully'));

asyncHandler

const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Usage: eliminates try/catch in every handler
const getUser = asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new ApiError(404, 'User not found');
  res.json(new ApiResponse(200, user, 'User fetched'));
});

CORS config (production)

const allowedOrigins = process.env.CORS_ORIGIN.split(',').map(o => o.trim());

const corsOptions = {
  origin: (origin, cb) => {
    if (!origin) return cb(null, true);           // mobile, Postman, server-to-server
    if (allowedOrigins.includes(origin)) return cb(null, true);
    cb(new ApiError(403, 'Not allowed by CORS'));
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,  // cache preflight 24h
};

ESLint + Prettier setup

# Install
npm install -D eslint prettier eslint-config-prettier eslint-plugin-prettier

# Git hooks
npm install -D husky lint-staged
npx husky init

# Commit message validation (optional)
npm install -D @commitlint/cli @commitlint/config-conventional

.eslintrc.js:

module.exports = {
  env: { node: true, es2021: true, jest: true },
  extends: ['eslint:recommended', 'plugin:prettier/recommended'],  // prettier LAST
  parserOptions: { ecmaVersion: 'latest', sourceType: 'commonjs' },
  rules: {
    'no-console': 'warn',
    'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    'prefer-const': 'error',
    'no-var': 'error',
    'eqeqeq': ['error', 'always'],
  },
};

.prettierrc:

{
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "semi": true,
  "printWidth": 100,
  "bracketSpacing": true,
  "arrowParens": "always",
  "endOfLine": "lf"
}

lint-staged (in package.json):

{
  "lint-staged": {
    "*.js": ["eslint --fix", "prettier --write"],
    "*.json": ["prettier --write"]
  }
}

Husky pre-commit: .husky/pre-commit contains npx lint-staged.


npm scripts

ScriptCommandWhen
startnode server.jsProduction
devnodemon server.jsDevelopment
testjest --coverageCI + dev
linteslint .CI check
lint:fixeslint . --fixDev fix
formatprettier --write .Dev format
format:checkprettier --check .CI check
preparehuskyAuto: installs git hooks after npm install

Postman essentials

Collection = folder of related API requests, organised by resource.

Environments = variable sets: {{baseUrl}}, {{token}}, {{userId}}. Switch between local / staging / production.

Auto-save token (Tests tab of login request):

const res = pm.response.json();
if (res.success) {
  pm.environment.set('token', res.data.accessToken);
  pm.environment.set('userId', res.data.user.id);
}
pm.test('Status is 200', () => pm.response.to.have.status(200));

Newman (CLI runner for CI/CD):

newman run Collection.json \
  --environment Env.json \
  --reporters cli,htmlextra \
  --reporter-htmlextra-export report.html

Master workflow

1. Scaffold    mkdir -p src/{config,controllers,middleware,models,routes,services,utils,validators}
2. Git         git init && add .gitignore (first commit)
3. Config      .env + .env.example + src/config/index.js (fail-fast validation)
4. App         app.js (middleware chain) + server.js (DB + listener)
5. Layers      routes -> controllers -> services -> models
6. Errors      ApiError + ApiResponse + asyncHandler + global error handler
7. CORS        Production whitelist from CORS_ORIGIN env var
8. Lint        ESLint + Prettier + eslint-config-prettier
9. Hooks       Husky + lint-staged + commitlint
10. Test       Postman collection + environments + Newman in CI/CD
11. Deploy     PM2 cluster + ecosystem.config.js + pm2 startup + pm2 save

One-liners

  • Structure = src/ with config, controllers, middleware, models, routes, services, utils, validators.
  • Naming = kebab-case files, .model.js / .controller.js suffixes, Conventional Commits.
  • Config = .env + dotenv + config module + fail-fast validation.
  • Production = PM2 cluster + ApiError + asyncHandler + ApiResponse + CORS whitelist.
  • Lint/Format = ESLint (bugs) + Prettier (style) + Husky + lint-staged.
  • Testing = Postman collections + environments + Newman CI/CD.

End of 3.13 quick revision.