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 type | Pattern | Example |
|---|---|---|
| Model | <resource>.model.js | user.model.js |
| Controller | <resource>.controller.js | user.controller.js |
| Service | <resource>.service.js | user.service.js |
| Route | <resource>.routes.js | user.routes.js |
| Validator | <resource>.validator.js | user.validator.js |
| Middleware | <name>.middleware.js | auth.middleware.js |
| Test | <name>.test.js | user.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
| Command | What it does |
|---|---|
pm2 start server.js | Start as background daemon |
pm2 start server.js -i max | Cluster mode (all CPUs) |
pm2 start ecosystem.config.js --env production | Start from config file |
pm2 list | Show all processes |
pm2 logs | Stream all logs |
pm2 logs blog-api | Logs for one process |
pm2 restart blog-api | Kill + start (brief downtime) |
pm2 reload blog-api | Zero-downtime graceful restart |
pm2 stop blog-api | Stop a process |
pm2 delete blog-api | Remove from PM2 list |
pm2 monit | Terminal monitoring dashboard |
pm2 startup | Generate OS startup script |
pm2 save | Save 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 type | isOperational | Show real message? |
|---|---|---|
| User not found (404) | true | Yes |
| Duplicate email (409) | true | Yes |
| TypeError, ReferenceError | false | No -- "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
| Script | Command | When |
|---|---|---|
start | node server.js | Production |
dev | nodemon server.js | Development |
test | jest --coverage | CI + dev |
lint | eslint . | CI check |
lint:fix | eslint . --fix | Dev fix |
format | prettier --write . | Dev format |
format:check | prettier --check . | CI check |
prepare | husky | Auto: 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.jssuffixes, 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.