Backend Architecture
Enforces production-grade Node.js/Express backend patterns — clean MVC structure, secure auth, professional API design, and hardened deployment. Primary constraint: no backend feature is built without a defined security boundary, centralized error handling, and environment-variable-only secrets.
Core Rules
Rule: All secrets live in .env only — never in source code. Why: automated
bots scan public repositories 24/7; hardcoded credentials are stolen within
minutes of exposure, causing data breaches and massive cloud billing spikes.
Rule: Never connect frontend directly to the database. Why: this bypasses all business logic, leaks connection strings to the browser, and removes the security layer entirely. All queries go through the Express backend only.
Rule: Wrap every controller in try-catch and use centralized error middleware. Why: unhandled exceptions crash the Node process silently — the server dies with no error returned to the client, and the cause is invisible.
Rule: Never store JWT access tokens in localStorage on the client. Why: XSS scripts read localStorage freely. Access tokens go in React state; refresh tokens go in HttpOnly cookies set by the backend — invisible to JS.
Rule: Whitelist only your production server IP in the database firewall.
Why: open access (0.0.0.0/0) means any internet scanner can attempt
connections. A single leaked credential becomes a full breach.
MVC Folder Structure (non-negotiable)
backend/
├── .env ← all secrets — never commit this
├── .gitignore ← must include .env, node_modules/, dist/
└── src/
├── models/ ← Mongoose schemas or SQL table definitions
├── controllers/ ← business logic per resource (invoices, users, auth)
├── routes/ ← Express route declarations + HTTP method mapping
├── middleware/ ← auth checks, CORS, rate limiting, payload parsing
└── services/ ← external utilities: PDF gen, email, Cron jobsEach layer has one job:
models/defines data shape — no logic.controllers/runs logic — no routing.routes/maps paths to controllers — no logic.middleware/intercepts requests — no business logic.services/handles external systems — no routing.
Never mix responsibilities across layers. Why: mixing makes bugs untraceable and makes the codebase unmaintainable as it grows.
API Design
REST Naming Rules:
- Base:
https://api.yourdomain.com - Versioning: always prefix with
/api/v1/— prevents breaking integrations when routes evolve. - Resources: plural nouns only (
/users,/invoices,/orders) - Identifiers: parameterized (
/:id) — never expose raw database keys in URLs
Standard Endpoint Map:
GET /api/v1/invoices → list all invoices for authenticated user
GET /api/v1/invoices/:id → get one invoice
POST /api/v1/invoices → create new invoice
PUT /api/v1/invoices/:id → replace entire invoice
PATCH /api/v1/invoices/:id → update specific fields only
DELETE /api/v1/invoices/:id → permanently delete invoiceHTTP Methods — when to use which:
| Method | Use | Behavior |
|---|---|---|
| GET | Fetch data | Safe, idempotent, no body |
| POST | Create new record | Body contains payload |
| PUT | Replace entire resource | Body replaces all fields |
| PATCH | Update specific fields | Body contains only changed fields |
| DELETE | Remove record | Irreversible unless soft-delete implemented |
Status Codes — use exact codes, not just 200:
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST — new record written |
| 400 | Bad Request | Invalid payload, missing required fields |
| 401 | Unauthorized | No valid token or token expired |
| 403 | Forbidden | Authenticated but lacks permission |
| 404 | Not Found | Resource or route does not exist |
| 409 | Conflict | Duplicate (email already registered) |
| 500 | Server Error | Unhandled exception — should never reach client |
Standard Response Wrapper:
// Success
{ "success": true, "data": { ... } }
// Error
{ "success": false, "errorCode": "INVOICE_NOT_FOUND", "message": "Invoice with this ID does not exist." }Why: consistent wrappers allow the frontend to handle all responses with
one pattern — checking success flag before accessing data.
Webhooks: Use for payment events (Razorpay, Stripe). The external system POSTs to your webhook URL on events — do not poll. Polling is slow, resource-heavy, and misses events during downtime.
Database Design
SQL vs NoSQL — decide using this rule:
| Project type | Database |
|---|---|
| Complex relationships, transactions, e-commerce | PostgreSQL (SQL) |
| Flexible documents, user profiles, content-heavy | MongoDB (NoSQL) |
| Default for AI-assisted fast iteration | MongoDB + Mongoose |
MongoDB Schema Rules:
- Always define Mongoose schemas even though MongoDB is schema-less. Why: type-safety at the server layer prevents corrupt documents from being stored.
- Required fields, type validation, and unique constraints must be declared in the schema — never rely on the application layer to enforce these.
- Always index documents with
userIdto isolate user data. Why: queries without userId isolation will leak one user's data to another.
// Correct schema with type safety and isolation
const InvoiceSchema = new Schema({
userId: { type: Schema.Types.ObjectId, ref: "User", required: true, index: true },
amount: { type: Number, required: true },
status: { type: String, enum: ["draft", "sent", "paid"], default: "draft" },
createdAt: { type: Date, default: Date.now },
});Authentication & Security
Auth Architecture:
- Register → hash password with bcrypt → store hash only, never plaintext.
- Login → verify bcrypt hash → issue access token (short-lived, in JSON body)
- refresh token (long-lived, in HttpOnly cookie).
- Protected routes → verify access token in
Authorization: Bearerheader. - Token refresh → client sends HttpOnly cookie to
/auth/refresh→ new access token issued without re-login.
JWT Token Rules:
| Token | Storage | Expiry | Purpose |
|---|---|---|---|
| Access Token | React state / Context | 15 min | Authorize API requests |
| Refresh Token | HttpOnly cookie (backend sets) | 7–30 days | Mint new access tokens |
CORS Configuration:
app.use(cors({
origin: process.env.CLIENT_URL, // never "*" in production
credentials: true, // required for HttpOnly cookies
}));Why: wildcard CORS allows any domain to call your API — this defeats the purpose of having a backend security boundary.
Environment Variables — mandatory structure:
PORT=50001
MONGO_URI=mongodb+srv://...
JWT_ACCESS_SECRET=<random 64-char string>
JWT_REFRESH_SECRET=<random 64-char string>
CLIENT_URL=https://app.yourdomain.comAll of these go in .env only. They are set in the hosting provider's
dashboard (Kloudbean) for production — never uploaded as a file.
Centralized Error Handling
Every controller must follow this pattern:
export const getInvoices = async (req, res, next) => {
try {
const invoices = await Invoice.find({ userId: req.user.id });
res.status(200).json({ success: true, data: invoices });
} catch (err) {
next(err); // passes to global error middleware — never call res.json in catch
}
};Global error middleware (registered last in app.js):
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
success: false,
errorCode: err.code || "SERVER_ERROR",
message: err.message || "An unexpected error occurred.",
});
});Why: without this, Node.js crashes silently on unhandled errors, leaving the client with no response and the developer with no traceable cause.
Automated Maintenance (Cron Jobs)
Use node-cron for background tasks that must run on a schedule:
- Daily trash purge: delete records soft-deleted more than 30 days ago.
- Token sweeper: delete expired password reset tokens from the database.
cron.schedule("0 0 * * *", async () => {
await Trash.deleteMany({ deletedAt: { $lt: new Date(Date.now() - 30 * 86400000) } });
});Production Readiness Checklist
Before any backend goes live, every item must be checked:
-
.envis in.gitignore— confirm withgit statusbefore pushing - All passwords hashed with bcrypt — no plaintext stored anywhere
- Global error middleware registered and returning JSON (not HTML errors)
- MongoDB Atlas IP whitelist contains only production server IP — not
0.0.0.0/0 - CORS origin set to exact production domain — not
* - All env variables set in hosting dashboard — not uploaded as a file
- Cron jobs running for scheduled maintenance tasks
- Postman collection created and all endpoints verified locally
Decision Guide
| Situation | Correct action |
|---|---|
| Unsure SQL vs MongoDB | Ask: complex multi-table joins needed? Yes → SQL. No → MongoDB |
| Need to update one field only | Use PATCH, not PUT |
| Frontend getting 500 errors | Add centralized error middleware — server is crashing silently |
| API works locally but fails in production | Check: CORS origin, env vars in hosting dashboard, DB IP whitelist |
| User data leaking across accounts | Add userId filter to all queries — missing isolation |
Anti-Patterns
| ❌ Never do this | ✅ Do this instead |
|---|---|
| Hardcode secrets in source files | Store all secrets in .env only |
Leave DB open to 0.0.0.0/0 |
Whitelist production server IP only |
Use * as CORS origin |
Set exact domain via process.env.CLIENT_URL |
| Store plaintext passwords | Always hash with bcrypt before storing |
| Mix logic across MVC layers | One layer, one job — strictly separated |
| Return raw error stack traces | Use centralized middleware returning structured JSON |
| Poll for payment events | Use webhooks — trigger-driven, real-time, no overhead |
Gotchas
A backend that works perfectly locally but crashes in production is almost always missing environment variables in the hosting dashboard. Check this first before debugging anything else.
MongoDB Mongoose validation only runs on
save()andcreate()— not onfindOneAndUpdate()unless you pass{ runValidators: true }. Missing this allows corrupt data to bypass schema constraints.HttpOnly cookies require
credentials: truein both the CORS config and the frontend fetch call. Missing either side and the cookie is never sent.JWT
process.env.JWT_ACCESS_SECRETthat is undefined will cause thejwt.sign()call to fail silently or sign with"undefined"as the secret — always check env vars load before the app starts.
Handoff
Skill: backend-architecture
Type: Reference
Trigger phrases covered: "build an API route", "set up auth", "JWT refresh token",
"MongoDB schema", "backend is crashing", "CORS error"
DO NOT TRIGGER for: frontend components, CSS, React state, UI layout tasks
Test prompts to verify:
1. "Set up JWT authentication with refresh tokens" → Should trigger; HttpOnly cookie
pattern, access token in React state, bcrypt hashing — all enforced
2. "My backend is returning 500 errors in production but works locally"
→ Should trigger; env vars in hosting dashboard checked, CORS origin verified
3. "Build me a Tailwind card component" → Should NOT trigger; frontend UI task
Pass/fail rubric score: 5/5
Suggested next iteration: add SQL/PostgreSQL specific schema and query patterns
if user's projects shift toward relational data models