create-node-prodkit
v1.2.0
Published
Production-grade Node.js + Express skeleton CLI — scaffold a new project in seconds
Downloads
615
Maintainers
Readme
Node.js Production-Grade Skeleton
A clean, opinionated Node.js REST API skeleton using ES Modules, Express 5, and a layered architecture designed for production. Available as an NPM CLI — scaffold a fully wired project in one command.
npx create-node-prodkit project-nameTable of Contents
- Prerequisites
- Getting Started
- Project Structure
- Request Lifecycle (The Full Flow)
- Configuration
- How to Write a Feature — Step by Step
- Utilities — How to Use Each One
- Middlewares
- Database Connections
- Logging System Deep Dive
- Environment Variables Reference
- Code Standards & Conventions
- Rule 1 — Single Responsibility
- Rule 2 — File & Folder Naming
- Rule 3 — Naming Conventions
- Rule 4 — Import Order
- Rule 5 — Exports
- Rule 6 — Async / Await
- Rule 7 — Error Handling
- Rule 8 — Responses
- Rule 9 — Logging
- Rule 10 — Configuration
- Rule 11 — Small & Flat Functions
- Rule 12 — Comments
- Rule 13 — Repository Shape
- Rule 14 — Never Pass req/res to Services
- What NOT to Do
- CLI Package — How It Works
- Maintaining & Publishing Updates
1. Prerequisites
| Tool | Minimum Version | |------|----------------| | Node.js | 18+ | | npm | 9+ |
2. Getting Started
# 1. Clone or copy the skeleton
cd project-name
# 2. Install dependencies
npm install
# 3. Create your environment file
cp .env.example .env # or create .env manually (see section 11)
# 4. Start the dev server (with hot reload)
npm run devThe server starts at http://localhost:3000 by default.
How hot reload works:
nodemonwatches thesrc/directory. Any.jsor.jsonfile change triggers an automatic restart. This is configured inpackage.jsonundernodemonConfig.
3. Project Structure
src/
├── server.js → Entry point: starts the HTTP server
├── app.js → Express app setup, middleware stack, routes
│
├── config/
│ └── app.config.js → All config values read from .env in one place
│
├── routes/
│ ├── index.route.js → Root router — mounts all feature routers
│ └── sample1.route.js → Feature-specific routes
│
├── controllers/
│ └── sample.controller.js → Handles req/res, calls services
│
├── services/
│ └── sample.service.js → Business logic layer
│
├── repositories/
│ └── sample.repository.js → Data access layer (DB or external API)
│
├── models/
│ └── sample.model.js → DB model definitions (Mongoose, Sequelize, etc.)
│
├── middlewares/
│ ├── error.middleware.js → Global error handler
│ ├── ip.middleware.js → IP allowlist guard
│ ├── rateLimit.middleware.js → Request throttling
│ └── encrypt.middleware.js → Placeholder for payload encryption
│
├── validations/
│ └── sample.validation.js → Request body/param/query validators
│
├── db/
│ ├── mongo.db.js → MongoDB connection setup
│ ├── mysql.db.js → MySQL connection setup
│ └── postgres.db.js → PostgreSQL connection setup
│
└── utils/
├── asyncHandler.js → Wraps async route handlers to catch errors
├── response.util.js → Standardised JSON response helpers
├── log.util.js → Logging service (file or external)
├── logSanitizer.util.js → Masks/removes sensitive fields before logging
├── sensitiveKeys.util.js → List of field names treated as sensitive
└── axios.util.js → Centralised HTTP client wrapper4. Request Lifecycle (The Full Flow)
The Layered Architecture
This project enforces a strict one-way data flow. Each layer has exactly one job. No layer skips another or reaches backwards.
┌─────────────────────────────────────────────────────────────┐
│ CLIENT │
│ HTTP Request (method + headers + body) │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ GLOBAL MIDDLEWARES │
│ (app.js — in order) │
│ │
│ 1. rateLimitMiddleware │
│ → Counts requests per IP per window │
│ → 429 if exceeded, otherwise continues │
│ │
│ 2. ipMiddleware │
│ → Checks client IP against allowlist │
│ → 403 if not allowed, otherwise continues │
│ │
│ 3. express.json() │
│ → Parses raw request body into req.body │
│ → Unreadable body = 400 automatically │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ROUTER │
│ routes/index.route.js │
│ │
│ → Matches URL prefix (e.g. /api/v1/users) │
│ → Delegates to the correct feature router │
│ → Feature router matches the rest (e.g. /:id) │
│ → Calls the middleware chain for that route │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ VALIDATION MIDDLEWARE │
│ validations/<feature>.validation.js │
│ │
│ → Reads req.params / req.body / req.query │
│ → Checks required fields, types, formats │
│ → 422 + error list if invalid │
│ → Calls next() if all good — does NOT touch DB │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CONTROLLER │
│ controllers/<feature>.controller.js │
│ │
│ → Extracts clean inputs from req (params, body, query) │
│ → Calls the service with plain values (no req/res passed) │
│ → Receives result from service │
│ → Calls ResponseUtil.success() to send response │
│ → Wrapped in asyncHandler — errors auto-forwarded to next()│
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SERVICE │
│ services/<feature>.service.js │
│ │
│ → Receives plain data (id, name, body object, etc.) │
│ → Contains ALL business logic and decisions │
│ → Calls repository to get/write data │
│ → Calls logService() to record meaningful events │
│ → Throws error with error.status set for HTTP errors │
│ → Returns clean result data to controller │
└─────────────────────────┬───────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ REPOSITORY │
│ repositories/<feature>.repository.js │
│ │
│ → ONLY layer that talks to external data sources │
│ → Uses apiCall() for external APIs │
│ → Uses DB model for database operations │
│ → Returns raw data — no logic, no transforms │
└─────────────────────────┬───────────────────────────────────┘
│
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│ Database │ │ External API │
│ (DB layer│ │ (axios.util) │
│ /db/) │ └──────────────┘
└──────────┘The Error Path
Any layer can signal a failure. Here is exactly what happens:
Service throws: errorMiddleware receives it:
───────────────── ──────────────────────────
const err = new Error("Not found") err.status === 404
err.status = 404 → ResponseUtil.notFound(res, err.message)
throw err logService("error", err.message, { stack })| Who throws | How | Where it lands |
|---|---|---|
| Service | throw error with .status | errorMiddleware via asyncHandler → next(err) |
| Validation | ResponseUtil.validation() directly | Stops at validation, never reaches controller |
| Repository throws (DB/API error) | Bubble up via async/await | errorMiddleware via asyncHandler → next(err) |
| Unhandled crash | Any uncaught throw | errorMiddleware default → 500 |
Concrete Traced Example — GET /api/v1/users/42
1. Request arrives: GET /api/v1/users/42
2. rateLimitMiddleware
→ IP 127.0.0.1 has made 3 requests this window. Limit is 100. ✓ Pass.
3. ipMiddleware
→ clientIp = "127.0.0.1". In allowedIps. ✓ Pass.
4. express.json()
→ No body on GET. req.body = {}. ✓ Pass.
5. Router: /api/v1 → index.route.js
→ Prefix "/users" matches userRoutes.
→ Path "/:id" matches. req.params.id = "42".
6. validateGetUser middleware
→ id = "42". Not empty. Not NaN. ✓ Pass. next().
7. getUserById controller
→ const { id } = req.params → id = "42"
→ calls getUserByIdService("42")
8. getUserByIdService
→ calls userRepository.findById("42")
9. userRepository.findById
→ apiCall({ method: "GET", url: "https://api.example.com/users/42" })
→ Returns: { id: 42, name: "Vinay", email: "[email protected]" }
10. Back in service
→ data is not null. ✓
→ logService("info", "User fetched", { userId: 42 })
└─ sanitizeLogData masks "userId" if LOG_TYPE=2, removes if LOG_TYPE=3
└─ Writes JSON line to logs/app-2026-02-21.log
→ return { id: 42, name: "Vinay", email: "[email protected]" }
11. Back in controller
→ user = { id: 42, name: "Vinay", ... }
→ ResponseUtil.success(res, "User fetched successfully", user)
12. Response sent to client:
HTTP 200
{
"success": true,
"statusCode": 200,
"message": "User fetched successfully",
"data": { "id": 42, "name": "Vinay", "email": "[email protected]" },
"errors": null
}What if user ID doesn't exist — the error path traced
8. userRepository.findById("999")
→ API returns null / DB returns null
9. Back in service:
→ data is null
→ const err = new Error("User not found")
→ err.status = 404
→ throw err
10. asyncHandler catches the throw
→ calls next(err)
11. errorMiddleware receives err
→ logService("error", "User not found", { stack: "..." })
→ err.status === 404 → ResponseUtil.notFound(res, "User not found")
12. Response sent to client:
HTTP 404
{
"success": false,
"statusCode": 404,
"message": "User not found",
"data": null,
"errors": null
}5. Configuration
All configuration lives in src/config/app.config.js. Never read process.env directly anywhere else in the codebase. Always import from config.
// src/config/app.config.js
import dotenv from "dotenv";
dotenv.config();
export default {
port: process.env.PORT || 3000,
api: {
timeout: Number(process.env.API_TIMEOUT) || 5000,
},
rateLimit: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // max requests per window per IP
},
logging: {
mode: process.env.LOG_MODE || "internal", // "internal" | "external"
externalUrl: process.env.LOG_SERVICE_URL || "",
directory: "logs",
fileName: "app",
maxSize: 5 * 1024 * 1024, // 5 MB per log file
dailyRotate: true,
logType: Number(process.env.LOG_TYPE) || 1 // 1=show | 2=mask | 3=remove sensitive
},
};To add a new config value:
// .env
DB_URI=mongodb://localhost:27017/mydb
// app.config.js — add inside the export
db: {
uri: process.env.DB_URI || "mongodb://localhost:27017/mydb",
},
// Usage anywhere
import config from "../config/app.config.js";
const uri = config.db.uri;6. How to Write a Feature — Step by Step
Example feature: Users — Get user by ID
Step 1: Create the Route
// src/routes/user.route.js
import express from "express";
import { getUserById } from "../controllers/user.controller.js";
import { validateGetUser } from "../validations/user.validation.js";
const router = express.Router();
router.get("/:id", validateGetUser, getUserById);
export default router;Rules:
- One file per feature/resource.
- Route file only defines HTTP method + path + middleware chain + controller.
- No business logic here.
Step 2: Create the Controller
// src/controllers/user.controller.js
import { asyncHandler } from "../utils/asyncHandler.js";
import { ResponseUtil } from "../utils/response.util.js";
import { getUserByIdService } from "../services/user.service.js";
export const getUserById = asyncHandler(async (req, res) => {
const { id } = req.params;
const user = await getUserByIdService(id);
return ResponseUtil.success(res, "User fetched successfully", user);
});Rules:
- Always wrap with
asyncHandler— it catches all thrown errors automatically. - Only extract data from
req(params, body, query, headers) here. - Never write business logic or DB queries in a controller.
- Always use
ResponseUtilfor the response — never callres.json()directly. - Return the
ResponseUtilcall so the function exits cleanly.
Step 3: Create Validation Middleware
// src/validations/user.validation.js
import { ResponseUtil } from "../utils/response.util.js";
export const validateGetUser = (req, res, next) => {
const { id } = req.params;
const errors = [];
if (!id) errors.push("id is required");
if (isNaN(Number(id))) errors.push("id must be a number");
if (errors.length > 0) {
return ResponseUtil.validation(res, "Validation failed", errors);
}
next();
};Rules:
- Validation middleware runs before the controller.
- Use
ResponseUtil.validation()(422) to return errors. - Never throw from validation — call
ResponseUtildirectly and return. - Keep validation pure: only checks shape/format of input, no DB calls.
Step 4: Create the Service
// src/services/user.service.js
import { userRepository } from "../repositories/user.repository.js";
import { logService } from "../utils/log.util.js";
export const getUserByIdService = async (id) => {
const user = await userRepository.findById(id);
await logService("info", "User fetched", { userId: id });
if (!user) {
const error = new Error("User not found");
error.status = 404;
throw error;
}
return user;
};Rules:
- Services contain all business logic.
- Services call repositories, never call DB drivers directly.
- To signal an error to the client, create an
Error, seterror.statusto the HTTP code, andthrowit. TheerrorMiddlewarewill catch it. - Log meaningful events here with
logService. - Services do not touch
reqorres. They receive plain data and return plain data.
Supported error status codes:
| error.status | Response sent |
|----------------|--------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 422 | Validation Error |
| anything else | 500 Internal Server Error |
Step 5: Create the Repository
// src/repositories/user.repository.js
import { apiCall } from "../utils/axios.util.js";
// OR import your DB model here
export const userRepository = {
findById: async (id) => {
// External API example:
return apiCall({
method: "GET",
url: `https://jsonplaceholder.typicode.com/users/${id}`,
});
// MongoDB example (once model is connected):
// return UserModel.findById(id).lean();
// MySQL/Postgres example:
// return db.query("SELECT * FROM users WHERE id = ?", [id]);
},
};Rules:
- Repositories are the only layer allowed to talk to a DB or external API.
- Export a plain object with named methods — this makes it easy to mock in tests.
- Never put business logic here. Just raw data operations.
- Return raw data. Let the service decide what to do with it.
Step 6: Create the Model (optional)
Models are used only when you have a connected database. Leave empty if using only external API calls.
// src/models/user.model.js — MongoDB example
import mongoose from "mongoose";
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
createdAt: { type: Date, default: Date.now },
});
export const UserModel = mongoose.model("User", userSchema);Step 7: Register the Route
// src/routes/index.route.js
import express from "express";
import sampleRoutes from "./sample1.route.js";
import userRoutes from "./user.route.js"; // ← add this
const router = express.Router();
router.use("/sample", sampleRoutes);
router.use("/users", userRoutes); // ← and this
export default router;Final endpoint: GET /api/v1/users/:id
7. Utilities — How to Use Each One
asyncHandler
File: src/utils/asyncHandler.js
Purpose: Eliminates the need for try/catch in every controller. If the async function throws, it automatically calls next(error) which triggers errorMiddleware.
// Without asyncHandler — verbose and error-prone
router.get("/", async (req, res, next) => {
try {
const data = await someService();
res.json(data);
} catch (err) {
next(err);
}
});
// With asyncHandler — clean
import { asyncHandler } from "../utils/asyncHandler.js";
router.get("/", asyncHandler(async (req, res) => {
const data = await someService();
ResponseUtil.success(res, "OK", data);
}));Rule: Every controller function must be wrapped in asyncHandler. No exceptions.
ResponseUtil
File: src/utils/response.util.js
Purpose: Enforces a consistent JSON response shape across the entire API.
Response shape:
{
"success": true,
"statusCode": 200,
"message": "User fetched successfully",
"data": { ... },
"errors": null
}Available methods:
import { ResponseUtil } from "../utils/response.util.js";
ResponseUtil.success(res, "Message", data); // 200
ResponseUtil.created(res, "Created", data); // 201
ResponseUtil.badRequest(res, "Bad input", errors); // 400
ResponseUtil.unauthorized(res, "Login required"); // 401
ResponseUtil.forbidden(res, "No access"); // 403
ResponseUtil.notFound(res, "Not found"); // 404
ResponseUtil.validation(res, "Failed", errorsArray); // 422
ResponseUtil.exception(res, "Server error"); // 500
// Custom status code:
ResponseUtil.send(res, 429, "Too many requests");Rule: Never use res.json(), res.send(), or res.status() directly. Always go through ResponseUtil.
logService
File: src/utils/log.util.js
Purpose: Writes structured log entries to a rotating daily log file, or posts them to an external log service.
import { logService } from "../utils/log.util.js";
// Levels: "info" | "warn" | "error" | "debug"
await logService("info", "User logged in", { userId: 42, role: "admin" });
await logService("error", "DB connection failed", { host: "localhost" });
await logService("warn", "Rate limit approaching", { ip: "192.168.1.1" });Log output (written to logs/app-YYYY-MM-DD.log):
{
"level": "info",
"message": "User logged in",
"timestamp": "21/02/2026, 10:30:00 am",
"meta": { "userId": 42, "role": "admin" }
}Rules:
- Always
awaitthe log call. - Log at the service layer, not in controllers or repositories.
- Sensitive fields in
metaare automatically masked based onLOG_TYPE(see section 10).
apiCall (axios.util)
File: src/utils/axios.util.js
Purpose: A centralised HTTP client wrapper around axios with a global timeout from config.
import { apiCall } from "../utils/axios.util.js";
// GET request
const posts = await apiCall({
method: "GET",
url: "https://api.example.com/posts",
});
// POST request with body
const result = await apiCall({
method: "POST",
url: "https://api.example.com/users",
data: { name: "Vinay", email: "[email protected]" },
});
// With custom headers
const secured = await apiCall({
method: "GET",
url: "https://api.example.com/secure",
headers: { Authorization: "Bearer TOKEN" },
});Rules:
- Only use
apiCallfrom repositories when calling external APIs. - Never call
axiosdirectly anywhere in the codebase. - Timeout is set globally via
config.api.timeout(default 5000ms).
logSanitizer / sensitiveKeys
Files: src/utils/logSanitizer.util.js, src/utils/sensitiveKeys.util.js
Purpose: Automatically strips or masks sensitive field values from log metadata so passwords, tokens, and IDs are never written to log files in plain text.
To add a sensitive field:
// src/utils/sensitiveKeys.util.js
export const sensitiveKeys = [
"userId", // existing
"id", // existing
"password", // ← add yours here
"token",
"email",
"creditCard",
];Behaviour controlled by LOG_TYPE in .env:
| LOG_TYPE | Behaviour |
|-----------|-----------|
| 1 | Sensitive values shown as-is (development) |
| 2 | Sensitive values masked: vi****ay |
| 3 | Sensitive keys removed entirely from logs |
You never need to call sanitizeLogData manually — logService calls it automatically.
8. Middlewares
rateLimitMiddleware
File: src/middlewares/rateLimit.middleware.js
Limits each IP to 100 requests per 15-minute window (configurable in app.config.js).
Returns 429 Too Many Requests via ResponseUtil.send when exceeded.
To change limits:
// app.config.js
rateLimit: {
windowMs: 10 * 60 * 1000, // 10 minutes
max: 50, // 50 requests
},ipMiddleware
File: src/middlewares/ip.middleware.js
Only allows requests from IPs listed in the allowedIps array. All others receive 403 Forbidden.
To allow more IPs:
const allowedIps = [
"127.0.0.1", // localhost IPv4
"::1", // localhost IPv6
"192.168.1.10", // existing
"203.0.113.50", // ← add your server/office IP
];To disable IP filtering in development, move
ipMiddlewareout ofapp.jsor wrap it with an env check.
errorMiddleware
File: src/middlewares/error.middleware.js
The last middleware in app.js. Catches every error that reaches next(err) (including those thrown inside asyncHandler).
- Logs the error with
logService("error", ...). - Maps
error.statusto the correctResponseUtilmethod. - You never need to call this manually — just throw errors in services.
How to trigger it from a service:
const error = new Error("Item not found");
error.status = 404;
throw error; // ← asyncHandler sends this to errorMiddlewareencryptMiddleware
File: src/middlewares/encrypt.middleware.js
A placeholder for encrypting/decrypting request or response payloads. Currently a no-op (next() only).
To use it on specific routes:
import { encryptMiddleware } from "../middlewares/encrypt.middleware.js";
router.post("/sensitive-data", encryptMiddleware, myController);To add to all routes, add it in app.js:
app.use(encryptMiddleware);9. Database Connections
The src/db/ folder has three empty setup files. Choose the one matching your database, connect in server.js before starting the listener.
MongoDB (Mongoose)
// src/db/mongo.db.js
import mongoose from "mongoose";
import config from "../config/app.config.js";
export const connectMongo = async () => {
await mongoose.connect(config.db.uri);
console.log("MongoDB connected");
};// src/server.js
import { connectMongo } from "./db/mongo.db.js";
import app from "./app.js";
import config from "./config/app.config.js";
connectMongo().then(() => {
app.listen(config.port, () => {
console.log(`Server running on port: ${config.port}`);
});
});MySQL / PostgreSQL
Follow the same pattern using mysql2 or pg pool, exporting a connectDB function and calling it in server.js.
10. Logging System Deep Dive
| Setting | Controlled by | Default |
|---------|--------------|---------|
| Log destination | LOG_MODE env | internal (file) |
| External log URL | LOG_SERVICE_URL env | none |
| Log file name | app.config.js | app-YYYY-MM-DD.log |
| Max file size | app.config.js | 5 MB (then .backup) |
| Daily rotation | app.config.js | enabled |
| Sensitive data | LOG_TYPE env | 1 (show all) |
In development, set LOG_TYPE=1 to see all data.
In production, set LOG_TYPE=3 to remove all sensitive fields from logs.
Log files are written to the logs/ directory at the project root.
11. Environment Variables Reference
Create a .env file in the project root:
# Server
PORT=3000
# API
API_TIMEOUT=5000
# Logging
LOG_MODE=internal # internal | external
LOG_SERVICE_URL= # only needed if LOG_MODE=external
LOG_TYPE=1 # 1=show | 2=mask | 3=remove sensitive fields
# Database (add as needed)
DB_URI=mongodb://localhost:27017/mydb12. Code Standards & Conventions
These are not suggestions. They are the rules that keep the codebase consistent, predictable, and easy to navigate for anyone who picks it up.
Rule 1 — Single Responsibility Per Layer
Every file has exactly one job. If you find yourself doing two things in one file, split it.
| Layer | Its ONE job | What it must NOT do | |-------|------------|---------------------| | Route | Define URL shape + middleware chain | No logic, no DB calls | | Validation | Check input shape/format | No DB calls, no business rules | | Controller | Bridge req → service → res | No business logic, no DB calls | | Service | Business logic + decisions | No res.json(), no DB calls directly | | Repository | Read/write data | No business rules, no response building | | Util | A single reusable operation | No app-specific logic | | Middleware | One cross-cutting concern | No feature-specific business logic |
Rule 2 — File & Folder Naming
✅ user.route.js → <feature>.route.js
✅ user.controller.js → <feature>.controller.js
✅ user.service.js → <feature>.service.js
✅ user.repository.js → <feature>.repository.js
✅ user.model.js → <feature>.model.js
✅ user.validation.js → <feature>.validation.js
✅ auth.middleware.js → <name>.middleware.js
✅ response.util.js → <name>.util.js
❌ userController.js (no camelCase file names)
❌ UserRoute.js (no PascalCase file names)
❌ get-user.js (no verb-based names for modules)- Use
kebab-casefor all file names. - Never abbreviate (
usr,ctrl,svc) — spell it out. - Group files in the folder that matches their layer. Never mix layers in one folder.
Rule 3 — Naming Conventions in Code
// ✅ Variables & functions → camelCase
const userId = req.params.id;
const fetchedUser = await getUserByIdService(id);
// ✅ Classes & Models → PascalCase
class UserRepository { ... }
const UserModel = mongoose.model("User", userSchema);
// ✅ Constants that never change → UPPER_SNAKE_CASE
const MAX_RETRIES = 3;
const DEFAULT_TIMEOUT = 5000;
// ✅ Exported service functions → camelCase verb + noun
export const getUserByIdService = async (id) => { ... };
export const createUserService = async (data) => { ... };
export const deleteUserService = async (id) => { ... };
// ✅ Exported controllers → camelCase verb + noun
export const getUser = asyncHandler(async (req, res) => { ... });
export const createUser = asyncHandler(async (req, res) => { ... });
export const deleteUser = asyncHandler(async (req, res) => { ... });
// ✅ Validation exports → validate + PascalCase feature + action
export const validateGetUser = (req, res, next) => { ... };
export const validateCreateUser = (req, res, next) => { ... };
// ❌ Avoid vague names
const data = ...; // what data?
const result = ...; // result of what?
const temp = ...; // always temporary — never commit this
const x = ...; // neverRule 4 — Import Order
Organise imports in this order, with a blank line between groups:
// 1. Node.js built-in modules
import fs from "fs";
import path from "path";
// 2. Third-party npm packages
import express from "express";
import axios from "axios";
// 3. Internal config
import config from "../config/app.config.js";
// 4. Internal utils
import { logService } from "../utils/log.util.js";
import { ResponseUtil } from "../utils/response.util.js";
// 5. Internal app modules (routes, controllers, services, repos, models)
import { userRepository } from "../repositories/user.repository.js";- Always include the
.jsextension — ES Modules require it. - Always use relative paths within
src/. - Never use
index.jsbarrel re-exports — import the file directly.
Rule 5 — Exports
// Controllers → named export per handler
export const getUser = asyncHandler(...);
export const createUser = asyncHandler(...);
// Services → named export per function
export const getUserByIdService = async (id) => { ... };
// Repositories → named export of a single object
export const userRepository = {
findById: async (id) => { ... },
create: async (data) => { ... },
};
// Config → single default export
export default { port: ..., db: { ... } };
// Utils → named exports
export const ResponseUtil = { ... };
export const asyncHandler = (fn) => { ... };Never mix named and default exports in the same file.
Rule 6 — Async / Await
// ✅ Always async/await
const user = await userRepository.findById(id);
// ✅ Controller always wrapped in asyncHandler
export const getUser = asyncHandler(async (req, res) => {
const user = await getUserByIdService(req.params.id);
return ResponseUtil.success(res, "OK", user);
});
// ❌ Never raw .then().catch() chains
userRepository.findById(id)
.then(user => res.json(user))
.catch(err => next(err)); // ← don't do this
// ❌ Never forget await — silent bugs
const user = userRepository.findById(id); // returns a Promise, not data!Rule 7 — Error Handling
// ✅ In a service — throw with a status code
if (!user) {
const err = new Error("User not found");
err.status = 404;
throw err;
}
// ✅ Carry extra detail when needed
if (existingUser) {
const err = new Error("Email already registered");
err.status = 409; // Conflict
err.field = "email"; // optional extra context
throw err;
}
// ✅ In validation middleware — respond directly, don't throw
if (errors.length > 0) {
return ResponseUtil.validation(res, "Validation failed", errors);
}
// ❌ Never swallow errors silently
try {
await doSomething();
} catch (err) {
// nothing here — the error disappears and the bug is hidden
}
// ❌ Never send response from a service
if (!user) {
return res.status(404).json({ message: "Not found" }); // ← wrong layer
}
// ❌ Never catch-and-ignore in repositories
findById: async (id) => {
try {
return await UserModel.findById(id);
} catch (err) {
return null; // ← bug hidden, service thinks "not found" when it's a crash
}
}HTTP status codes to use:
| Code | Meaning | When to use |
|------|---------|-------------|
| 400 | Bad Request | Malformed input the client sent |
| 401 | Unauthorized | Not logged in / missing token |
| 403 | Forbidden | Logged in but no permission |
| 404 | Not Found | Resource does not exist |
| 409 | Conflict | Duplicate resource (email already exists) |
| 422 | Unprocessable | Validation failed (use from validation layer) |
| 429 | Too Many Requests | Rate limit exceeded (handled by middleware) |
| 500 | Server Error | Anything unexpected — default fallback |
Rule 8 — Responses
// ✅ Always use ResponseUtil
return ResponseUtil.success(res, "User created", user); // 200
return ResponseUtil.created(res, "User created", user); // 201
return ResponseUtil.notFound(res, "User not found"); // 404
// ✅ Always return the ResponseUtil call in a controller
export const getUser = asyncHandler(async (req, res) => {
const user = await getUserByIdService(id);
return ResponseUtil.success(res, "OK", user); // ← return prevents accidental double-send
});
// ❌ Never call res directly
res.json({ user }); // no standard shape
res.status(200).send(user); // bypasses ResponseUtil
res.status(201).json({ user }); // inconsistent shapeEvery API response has this exact shape — always:
{
"success": true,
"statusCode": 200,
"message": "User fetched successfully",
"data": { },
"errors": null
}Rule 9 — Logging
// ✅ Log meaningful business events in services
await logService("info", "User registered", { userId: user.id, plan: "free" });
await logService("warn", "Login failed", { email, attempts: failCount });
await logService("error", "Payment failed", { orderId, reason });
// ✅ Always await logService
await logService("info", "...", { ... }); // ← sync file writes need this
// ✅ Include relevant context in meta — not just the message
await logService("info", "Order placed", { orderId, userId, amount, currency });
// ❌ Don't log in controllers — wrong layer
export const getUser = asyncHandler(async (req, res) => {
await logService("info", "getUser called"); // ← move this to the service
...
});
// ❌ Don't use console.log in production code
console.log(user); // won't be in log files, not structured, not sanitized
console.error(err); // use logService("error", ...) instead
// ❌ Don't log sensitive data manually
await logService("info", "User login", { password: req.body.password }); // ← add "password" to sensitiveKeys insteadLog levels — when to use which:
| Level | When |
|-------|------|
| "info" | Normal business events (user created, order placed, data fetched) |
| "warn" | Something unexpected but not fatal (retry attempted, limit approaching) |
| "error" | Something failed that needs attention (DB error, payment failed) |
| "debug" | Detailed info for tracing — remove before production |
Rule 10 — Configuration
// ✅ Only ever read process.env in app.config.js
// app.config.js
export default {
db: { uri: process.env.DB_URI || "mongodb://localhost/mydb" },
};
// ✅ Everywhere else — import from config
import config from "../config/app.config.js";
const uri = config.db.uri;
// ❌ Never read process.env outside of app.config.js
const timeout = Number(process.env.API_TIMEOUT); // ← scattered, untracked
if (process.env.NODE_ENV === "production") { ... } // ← add NODE_ENV to config firstThis means all env usage is visible in one file. If an env var is renamed or removed, there's one place to fix it.
Rule 11 — Keep Functions Small and Flat
// ✅ One function = one action. Short and readable.
export const createUserService = async (data) => {
const exists = await userRepository.findByEmail(data.email);
if (exists) {
const err = new Error("Email already registered");
err.status = 409;
throw err;
}
const user = await userRepository.create(data);
await logService("info", "User created", { userId: user.id });
return user;
};
// ❌ Avoid deep nesting — flatten with early returns
export const createUserService = async (data) => {
const exists = await userRepository.findByEmail(data.email);
if (!exists) {
const user = await userRepository.create(data);
if (user) {
await logService("info", "created", { id: user.id });
if (user.id) {
return user; // ← 4 levels deep, hard to follow
}
}
}
};Target: max 2 levels of nesting inside a function. Use early returns to bail out.
Rule 12 — Comments
// ✅ Comment WHY, not WHAT — the code already says what
// Rate window is 15 min to align with our SLA response commitment
windowMs: 15 * 60 * 1000,
// ✅ Mark intentional decisions
// LOG_TYPE 3 removes keys entirely — preferred for PCI-DSS compliance
if (config.logging.logType === 3) { continue; }
// ❌ Don't comment obvious code
// Get user by id
const user = await userRepository.findById(id); // ← the code already says this
// ❌ Don't leave dead code commented out — delete it (git can restore it)
// const oldService = await legacyUserService(id);
// return oldService.data;Rule 13 — Repository Shape
Repositories must always be exported as an object with named method keys, not as individual exported functions:
// ✅ Correct — object with methods (easy to mock in tests)
export const userRepository = {
findById: async (id) => { ... },
findByEmail: async (email) => { ... },
create: async (data) => { ... },
update: async (id, data) => { ... },
remove: async (id) => { ... },
};
// ❌ Wrong — individual exports make mocking harder
export const findById = async (id) => { ... };
export const findByEmail = async (email) => { ... };Rule 14 — Never Pass req or res to a Service
Services are pure logic functions. They must never know they are inside an HTTP context.
// ✅ Controller extracts, service receives plain values
export const getUser = asyncHandler(async (req, res) => {
const { id } = req.params; // ← controller extracts
const user = await getUserByIdService(id); // ← service gets plain value
return ResponseUtil.success(res, "OK", user);
});
// ❌ Never pass req or res into a service
const user = await getUserByIdService(req); // ← service now depends on HTTP layerThis makes services reusable and testable outside of Express (e.g. from a CLI script, a cron job, or a test).
Quick Reference Card
Layer Receives Returns Can call
──────────────────────────────────────────────────────────────────
Middleware req, res, next next() or response ResponseUtil
Validation req, res, next next() or 422 ResponseUtil
Controller req, res response Service, ResponseUtil
Service plain values plain data Repository, logService
Repository plain values plain data apiCall, DB model
Util anything anything other utils only13. What NOT to Do
| ❌ Don't | ✅ Do instead |
|---------|--------------|
| Read process.env.X directly in business code | Import from config/app.config.js |
| Call res.json() or res.status() directly | Use ResponseUtil methods |
| Write try/catch in every controller | Wrap with asyncHandler and throw |
| Put business logic in a controller | Move it to a service |
| Put DB queries in a service | Move them to a repository |
| Call axios directly | Use apiCall from axios.util.js |
| Log raw objects with sensitive fields | Let logService sanitize via sensitiveKeys |
| Leave LOG_TYPE=1 in production | Set LOG_TYPE=3 in production .env |
| Add routes directly in app.js | Add them in routes/index.route.js |
| Omit .js in import paths | Always include .js (ES Module requirement) |
14. CLI Package — How It Works
What the CLI does
When you run npx create-node-prodkit my-api, it:
- Validates the project name (letters, numbers, hyphens, underscores only).
- Copies everything inside
templates/to a new folder namedmy-apiin your current directory. - Replaces the
{{PROJECT_NAME}}placeholder insidetemplates/package.jsonwithmy-api. - Renames
gitignore→.gitignore(npm strips dotfiles from published tarballs). - Runs
npm installinside the new project folder. - Prints the next-steps guide.
CLI usage
# Using npx (recommended — always gets the latest version)
npx create-node-prodkit my-api
# Using npm init shorthand
npm create node-prodkit my-api
# Globally installed
npm install -g create-node-prodkit
create-node-prodkit my-apiWhat gets published to npm
Only these three items are included in the npm tarball (controlled by "files" in package.json):
bin/ ← CLI entry point
templates/ ← the full skeleton that gets copied to new projects
README.md ← documentationThe src/ folder, logs/, .env, and dev config files are excluded via .npmignore.
Project layout after scaffolding
my-api/
├── src/ ← full skeleton ready to use
├── .env.example ← copy to .env and fill in values
├── .gitignore
└── package.json ← name set to "my-api", deps installed15. Maintaining & Publishing Updates
The golden rule
Whenever you change a file in
src/, copy the same change to the matching file intemplates/src/.
The src/ folder is the live working skeleton (for developing and testing the skeleton itself). templates/src/ is the snapshot that gets distributed via npm.
Step-by-step: making a change
1. Make the change in src/
Edit the file normally. Test it by running npm run dev and verifying the behaviour.
2. Mirror the change to templates/src/
For a single file:
cp src/utils/response.util.js templates/src/utils/response.util.jsFor a full sync of everything at once:
node --input-type=module --eval "
import { cpSync } from 'fs';
cpSync('src', 'templates/src', { recursive: true });
console.log('templates/src synced');
"3. Bump the version — choose the right level
| Change type | Version bump | Command |
|-------------|-------------|---------|
| Bug fix, minor tweak | patch 1.0.0 → 1.0.1 | npm version patch |
| New feature, new utility | minor 1.0.0 → 1.1.0 | npm version minor |
| Breaking change (renames, removals) | major 1.0.0 → 2.0.0 | npm version major |
npm version automatically:
- Updates
versioninpackage.json - Creates a git commit
- Creates a git tag
4. Log the change in CHANGELOG.md
## [1.1.0] - 2026-02-21
### Added
- `logService` now supports an `"external"` mode posting to a remote URL
### Changed
- `asyncHandler` improved error propagation
### Fixed
- nodemon config path corrected to `src/server.js`5. Publish to npm
First-time setup:
npm login # log in to your npm accountEvery release:
# Preview exactly what will be published before pushing
npm pack --dry-run
# Publish
npm publish
# Or publish with a tag (e.g., for a beta)
npm publish --tag betaQuick release checklist
[ ] Change made in src/
[ ] Same change mirrored to templates/src/
[ ] CHANGELOG.md updated
[ ] npm version patch|minor|major (updates package.json + git tag)
[ ] git push && git push --tags
[ ] npm publish
[ ] Verify: npx create-node-prodkit@latest test-projectWhat each file in this repo does for the CLI
| File / Folder | Purpose |
|--------------|---------|
| bin/create.js | CLI executable — parses args, copies templates, runs npm install |
| templates/ | Snapshot of the skeleton distributed via npm |
| templates/package.json | Has {{PROJECT_NAME}} placeholder replaced at scaffold time |
| templates/gitignore | Renamed to .gitignore during scaffold (npm strips dotfiles) |
| templates/.env.example | All env vars documented with safe defaults |
| src/ | Live working skeleton for development — not published to npm |
| .npmignore | Excludes src/, logs, .env, editor config from the tarball |
| package.json "files" | Allowlist: only bin/, templates/, README.md go to npm |
