Backend SQL
Backend SQL
Stages 1–10 Express, MongoDB, Auth, Testing, Deployment Built & Deployed
What We Built
A running Express server with TypeScript, configured from scratch.
Key Concepts
[Link] — Runtime that lets JavaScript run outside the browser. Everything else sits on top.
TypeScript — JavaScript with types. Compiles to JS before running. Catches mistakes early.
ts-node — Run .ts files directly during development without compiling first.
Ports — Port 3000 is a door number. localhost:3000 = 'this computer, door 3000.'
Commands
npm init -y # Creates [Link]
npm install typescript ts-node @types/node --save-dev # TypeScript tools
npx tsc --init # Creates [Link]
npm install express # Install Express
npm install @types/express --save-dev # TS types for Express
npm install nodemon --save-dev # Auto-restart
[Link]
{
"compilerOptions": {
"target": "ES2020", // Compile to ES2020 JavaScript
"module": "commonjs", // Use require() style — what Node expects
"rootDir": "./src", // TypeScript source files live here
"outDir": "./dist", // Compiled JavaScript output goes here
"strict": true, // Strict type checking
"esModuleInterop": true, // Lets you write: import express from 'express'
"skipLibCheck": true, // Don't type-check node_modules
"types": ["node"] // Tell TypeScript about Node's built-ins
},
"include": ["src/**/*"],
"exclude": ["node_modules", "src/__tests__"]
}
[Link] scripts
"scripts": {
"dev": "nodemon --exec ts-node src/[Link]", // Development
"build": "npx tsc", // Compile TS -> JS
"start": "node dist/[Link]", // Production
"test": "jest --forceExit" // Run tests
}
[Link](PORT, () => {
[Link](`Server running on port ${PORT}`)
})
Key Concepts
Route — HTTP method + URL path. GET /expenses and POST /expenses are different routes.
Controller — Function that runs when route matches. Contains logic, kept separate from routes.
Router() — Mini Express app for grouping routes. Attached with [Link]('/prefix', router).
REST Conventions
GET /expenses → getAllExpenses (fetch all)
POST /expenses → createExpense (create new)
PUT /expenses/:id → updateExpense (update specific)
DELETE /expenses/:id → deleteExpense (delete specific)
src/routes/[Link]
import { Router } from 'express'
import { getAllExpenses, createExpense, updateExpense, deleteExpense }
from '../controllers/[Link]'
import validate from '../middleware/validate'
import { createExpenseSchema, updateExpenseSchema } from '../validators/[Link]'
import protect from '../middleware/auth'
[Link]('/', getAllExpenses)
[Link]('/', validate(createExpenseSchema), createExpense)
[Link]('/:id', validate(updateExpenseSchema), updateExpense)
[Link]('/:id', deleteExpense)
Key Concepts
MongoDB — Stores data as documents (JSON-like). Collections = tables, Documents = rows.
Mongoose — Sits between your app and MongoDB. Schemas, models, clean query functions.
Schema — Defines data shape and rules. Types, required fields, defaults.
Model — Object you use to interact with collection. [Link](), [Link]() etc.
src/config/[Link]
import mongoose from 'mongoose'
src/models/[Link]
import mongoose from 'mongoose'
Key Concepts
Middleware — Functions running between request and response. Think checkpoints.
next() — Pass request to next thing in chain. Forgetting it causes requests to hang forever.
Order matters — Express reads top to bottom. json → logger → routes → 404 → error handler.
Middleware Signatures
// Regular middleware: 3 parameters
(req: Request, res: Response, next: NextFunction) => { next() }
src/middleware/[Link]
import { Request, Response, NextFunction } from 'express'
Zod — TypeScript-first validation. Parse data against a schema. Failure = clean error. Success = typed object.
safeParse — Never throws. Returns { success: true, data } or { success: false, error }.
src/validators/[Link]
import { z } from 'zod'
src/middleware/[Link]
const validate = (schema: ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
const result = [Link]([Link])
if (![Link]) {
[Link](400).json({
message: 'Validation failed',
errors: [Link]().fieldErrors
})
return
}
JWT — Token issued after login. Contains encoded userId + signature. Only your server can verify.
JWT Structure
[Link]
- header: algorithm used
- payload: { userId: '123' } — anyone can decode
- signature: proves not tampered. Only verifiable with JWT_SECRET
src/controllers/[Link]
export const register = async (req: Request, res: Response) => {
const { email, password } = [Link]
const existingUser = await [Link]({ email })
if (existingUser) { [Link](400).json({ message: 'User already exists' }); return }
src/middleware/[Link]
export interface AuthRequest extends Request {
userId?: string
}
src/utils/[Link]
class AppError extends Error {
statusCode: number
constructor(message: string, statusCode: number) {
super(message)
[Link] = statusCode
}
}
// Usage: throw new AppError('Expense not found', 404)
src/utils/[Link]
const asyncHandler = (fn: AsyncController) => {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next)
// .catch(next) = if anything throws, pass to global error handler
}
}
Error Flow
throw new AppError('Not found', 404)
→ [Link](next)
→ next(error)
→ Global error handler (4 params)
→ [Link](404).json({ message: 'Not found' })
Stage 8 — Project Structure
expensetracker/
■■■ src/
■ ■■■ __tests__/[Link]
■ ■■■ config/[Link]
■ ■■■ controllers/
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■■■ middleware/
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■■■ models/
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■■■ routes/
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■■■ types/[Link]
■ ■■■ utils/
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■■■ validators/
■ ■ ■■■ [Link]
■ ■ ■■■ [Link]
■ ■■■ [Link] ← app setup, exports app, NO listen()
■ ■■■ [Link] ← only: [Link]()
■■■ .env
■■■ .gitignore
■■■ [Link]
■■■ [Link]
[Link] and [Link] are separate so tests can import the app without starting a real server on a port.
const PORT = [Link] || 3000
// Railway sets PORT automatically. Never hardcode it.
Stage 9 — Testing
Jest — Test runner. Finds and runs test files, reports results.
Supertest — Makes HTTP requests to Express app without starting a real server.
src/__tests__/[Link]
import request from 'supertest'
import app from '../index'
PASS src/__tests__/[Link]
Auth Routes
v should register a new user (1104ms)
v should login with valid credentials (350ms)
v should reject login with wrong password (144ms)
Tests: 3 passed, 3 total
Stage 10 — Deployment
Stack: MongoDB Atlas (DB) + GitHub (code) + Railway (hosting)
Build vs Start
# Build (Railway runs once when deploying)
npm install --include=dev && npx tsc
# --include=dev: installs TypeScript, @types/* needed to compile
Railway Variables
MONGO_URI = mongodb://user:pass@host/db
JWT_SECRET = yoursecretkey
NODE_ENV = production
# Don't set PORT — Railway sets it automatically
Deployment Checklist
[ ] .gitignore: node_modules, .env, dist
[ ] [Link]: build and start scripts defined
[ ] PORT: uses [Link] || 3000
[ ] All secrets in Railway Variables tab
[ ] MongoDB Atlas: IP whitelist [Link]/0
[ ] Railway: Generate Domain matching correct port
[ ] Test: GET [Link]
SQL — Complete Syllabus
The biggest gap from the curriculum. Most backend tests use SQL, not MongoDB. ~40% of what online tests
check.
Foundations
What a relational database is vs document DB (MongoDB) — tables/rows vs collections/documents.
NULL — what it means, why it's dangerous, IS NULL vs = NULL (= NULL never works, always use IS NULL)
Primary key — uniquely identifies a row. Foreign key — references primary key of another table.
-- WHERE
SELECT * FROM users WHERE role = 'admin';
SELECT * FROM users WHERE age > 18 AND role = 'user';
SELECT * FROM users WHERE role IN ('admin', 'moderator');
SELECT * FROM users WHERE name LIKE 'S%'; -- Starts with S
SELECT * FROM users WHERE name LIKE '%ha'; -- Ends with ha
SELECT * FROM users WHERE name LIKE '%resh%'; -- Contains resh
SELECT * FROM users WHERE age BETWEEN 18 AND 25;
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
Aggregations
SELECT COUNT(*) FROM users; -- Total rows
SELECT COUNT(email) FROM users; -- Non-NULL emails only
SELECT SUM(amount) FROM expenses;
SELECT AVG(amount) FROM expenses;
SELECT MIN(amount), MAX(amount) FROM expenses;
-- GROUP BY
SELECT category, COUNT(*) as total FROM expenses GROUP BY category;
SELECT category, SUM(amount) as total FROM expenses GROUP BY category;
SELECT category, AVG(amount) as avg_spend FROM expenses GROUP BY category ORDER BY avg_spend DESC;
-- DISTINCT
SELECT DISTINCT category FROM expenses;
-- LEFT JOIN: ALL rows from left (users), matching from right (expenses)
-- Users with no expenses get NULL for expense columns
SELECT [Link], [Link]
FROM users u
LEFT JOIN expenses e ON [Link] = e.user_id;
-- RIGHT JOIN: ALL rows from right, matching from left (rarely used)
SELECT [Link], [Link]
FROM users u
RIGHT JOIN expenses e ON [Link] = e.user_id;
-- Multiple joins
SELECT [Link], [Link], [Link] as category_label
FROM users u
JOIN expenses e ON [Link] = e.user_id
JOIN categories c ON e.category_id = [Link];
Subqueries
-- Subquery in WHERE
SELECT * FROM users WHERE id IN (SELECT user_id FROM expenses WHERE amount > 500);
-- Correlated subquery (runs once per outer row — slow but powerful)
SELECT * FROM expenses e
WHERE amount > (SELECT AVG(amount) FROM expenses WHERE category = [Link]);
Window Functions
-- ROW_NUMBER: unique number per row within partition
SELECT name, amount, category,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY amount DESC) as rank_in_category
FROM expenses;
-- RANK vs DENSE_RANK
-- RANK: 1,2,2,4 (skips 3 after tie)
-- DENSE_RANK: 1,2,2,3 (no skipping)
SELECT name, amount,
RANK() OVER (ORDER BY amount DESC) as rank,
DENSE_RANK() OVER (ORDER BY amount DESC) as dense_rank
FROM expenses;
-- Running total
SELECT title, amount, SUM(amount) OVER (ORDER BY date) as running_total FROM expenses;
-- LEAD and LAG (next/previous row value)
SELECT title, amount,
LAG(amount) OVER (ORDER BY date) as prev_amount,
LEAD(amount) OVER (ORDER BY date) as next_amount
FROM expenses;
Indexes
What an index is — A sorted copy of a column stored separately for fast lookup. Like a book's index — you
find the page without reading the whole book.
B-tree index — Default. Good for equality and range queries. Keeps data sorted.
Reads vs Writes — Indexes speed up reads but slow writes (index must be updated on every
INSERT/UPDATE/DELETE).
When to index — Columns used in WHERE, JOIN conditions, ORDER BY. Foreign keys. High-cardinality
columns.
When NOT to index — Small tables, columns rarely queried, columns with very few distinct values (boolean),
tables with heavy write load.
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_category_amount ON expenses(category, amount); -- Composite
-- Composite index: useful when both columns are in WHERE together
-- Order matters: idx(category, amount) helps WHERE category=X AND amount>Y
-- But NOT WHERE amount>Y alone (leftmost prefix rule)
-- If anything fails:
ROLLBACK; -- Undo everything
-- Savepoints
BEGIN;
UPDATE ...;
SAVEPOINT sp1;
UPDATE ...; -- If this fails:
ROLLBACK TO sp1; -- Roll back to savepoint, not beginning
COMMIT;
Isolation levels from weakest to strongest: READ UNCOMMITTED → READ COMMITTED → REPEATABLE
READ → SERIALIZABLE
Database Design
1NF — No repeating groups, atomic values (no arrays in columns).
2NF — 1NF + no partial dependency (non-key columns depend on entire primary key).
3NF — 2NF + no transitive dependency (non-key columns don't depend on other non-key columns).
Denormalization — Intentionally breaking normal forms for performance. Store redundant data to avoid
expensive joins.
-- One-to-many: one user has many expenses
users: id, name
expenses: id, user_id (FK → [Link]), amount
-- Duplicate emails
SELECT email FROM users GROUP BY email HAVING COUNT(*) > 1;
Password Security
Why not plaintext — DB leaks happen. Plaintext = everyone's password exposed instantly.
Hashing vs Encryption — Encryption is reversible (you can decrypt). Hashing is one-way (you can't unhash).
Passwords must be hashed, never encrypted.
Salt — Random string added to password before hashing. Means same password hashes differently each time.
Defeats rainbow table attacks (precomputed hash lookups).
bcrypt — Industry standard. Slow by design (prevents brute force). Salt is built in. Salt rounds = 10 means 2^10
= 1024 hashing iterations.
Never — log passwords, return them in API responses, store in localStorage with other sensitive data.
// Register
const hashedPassword = await [Link](password, 10)
// hash('password123', 10) → '$2b$10$xyz...' (different each time due to salt)
// Login
const isMatch = await [Link](plaintext, storedHash)
// compare('password123', '$2b$10$xyz...') → true/false
Payload — Your data: { userId, iat, exp } — base64 encoded. Anyone can decode this. Never put sensitive
data here.
Signature — HMAC(base64(header) + '.' + base64(payload), secret). Only your server can create or verify this.
HS256 — Symmetric. Same secret signs and verifies. For single-server apps.
RS256 — Asymmetric. Private key signs, public key verifies. For multi-service architectures.
Claims — iss (issuer), exp (expiry unix timestamp), iat (issued at), sub (subject/userId), jti (unique ID).
[Link]({ userId: user._id }, secret, { expiresIn: '7d' })
// exp is automatically set to now + 7 days
[Link](token, secret)
// Throws if: signature invalid, expired, malformed
Session vs JWT
Session-based Auth: JWT Auth:
■■■■■■■■■■■■■■■■■■■■■■■■■■■■ ■■■■■■■■■■■■■■■■■■■■■■■■■■■■
■ Server stores session in DB/Redis ■ ■ Server stores NOTHING ■
■ Client gets session ID cookie ■ ■ Client stores JWT token ■
■ Every request: lookup session ■ ■ Every request: verify sig ■
■ Easy to revoke (delete session) ■ ■ Hard to revoke (wait expiry)■
■ Stateful ■ ■ Stateless ■
■ Bad for horizontal scaling ■ ■ Good for horizontal scaling■
■■■■■■■■■■■■■■■■■■■■■■■■■■■■ ■■■■■■■■■■■■■■■■■■■■■■■■■■■■
Refresh token — Long-lived (30 days). Stored securely (httpOnly cookie). Used ONLY to get new access
token.
Flow — Login → get both tokens. Access token expires → send refresh token → get new access token.
Refresh token expires → login again.
Refresh token rotation — Each time refresh token is used, a new one is issued and old is invalidated. Limits
replay attacks.
// Login response
{ accessToken: 'eyJ...', refreshToken: 'eyJ...' }
// POST /auth/refresh
// Body: { refreshToken }
// Server: verify refresh token, issue new access token
// Response: { accessToken: 'eyJ...' }
OpenID Connect — Identity layer on top of OAuth2 (who you are). The id_token is OIDC.
// Role middleware
const requireRole = (...roles: string[]) => {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if () {
[Link](403).json({ message: 'Forbidden' })
return
}
next()
}
}
// Usage
[Link]('/users/:id', protect, requireRole('admin'), deleteUser)
403 Forbidden = authenticated but not authorized. 401 Unauthorized = not authenticated.
XSS — Cross-Site Scripting. Injected scripts steal tokens from localStorage. httpOnly cookies can't be
accessed by JS.
Brute force — Rate limit login endpoint. Lock account after N failures. Add delay after failures.
JWT none algorithm — Always specify algorithm in verify(). Never trust the alg from the token header.
Secret compromise — All tokens invalid immediately. Must rotate secret and force re-login.
API Design — Complete Syllabus
REST Principles
Stateless — Every request contains everything the server needs. Server stores no client state between
requests. JWT is the mechanism for this.
Resource naming — Nouns, not verbs. /expenses not /getExpenses not /createExpense.
Pagination
Offset/Limit — Simple. ?page=2&limit;=10. Problems: slow on large tables (DB scans all preceding rows),
inconsistent if rows inserted during pagination.
Cursor-based — ?cursor=lastId&limit;=10. Fast (uses indexed ID), consistent, but can't jump to arbitrary
pages.
// Offset pagination
GET /expenses?page=2&limit=10
// SELECT * FROM expenses LIMIT 10 OFFSET 10
// Problem: OFFSET 10000 makes DB scan 10000 rows
// Cursor pagination
GET /expenses?cursor=6a0d45f1&limit=10
// SELECT * FROM expenses WHERE _id > cursor ORDER BY _id LIMIT 10
// Fast — uses indexed _id directly
// Response format
{
"data": [...],
"pagination": {
"nextCursor": "6a0eaf39", // null if no more pages
"hasMore": true,
"total": 247 // optional, expensive to compute
}
}
// Date range
GET /expenses?from=2026-01-01&to=2026-05-31
API Versioning
URL versioning — /v1/expenses. Most common. Explicit, easy to route, easy to deprecate.
Why version: breaking changes (removing fields, changing data types) need a new version so old clients don't
break.
Rate Limiting
What it is — Limit how many requests a client can make in a time window. Prevents abuse, DoS attacks.
Token bucket — Bucket holds N tokens. Each request consumes one. Bucket refills at rate R/second. Allows
bursting.
Sliding window — Count requests in rolling time window. More accurate, more memory.
Fixed window — Count requests per fixed window (per minute). Simple but allows double the rate at window
boundary.
// Express rate limiting (express-rate-limit library)
import rateLimit from 'express-rate-limit'
// Response headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1716000000
// Status 429 Too Many Requests when exceeded
CORS
What it is — Cross-Origin Resource Sharing. Browser security feature. By default, JS on [Link] can't
make requests to [Link] (same-origin policy).
Why it exists — Prevents malicious sites from making API calls using your logged-in session.
Preflight — Before complex requests, browser sends OPTIONS request asking 'is this allowed?' Server must
respond with allowed origins/methods.
Note: CORS is enforced by browsers only. curl, Postman, Thunder Client are not affected.
import cors from 'cors'
GraphQL:
+ Client specifies exactly what fields it needs
+ Single endpoint (/graphql)
+ Strongly typed schema
+ Great for complex, nested data
- Complex to implement
- Caching harder
- Learning curve
gRPC:
+ Binary protocol (10x faster than JSON)
+ Strongly typed (Protocol Buffers)
+ Perfect for internal service-to-service
- Not browser-native
- Not human-readable
- Overkill for most APIs
Rule of thumb:
Public API → REST
Complex data, multiple clients → GraphQL
Internal microservices, performance critical → gRPC
Webhooks
What they are — Your server gets called by an external service when something happens. Stripe calls you
when payment succeeds. GitHub calls you when code is pushed.
vs Polling — Polling: you ask 'anything new?' every N seconds. Webhook: they tell you immediately.
Webhooks are more efficient.
// Receiving a webhook
[Link]('/webhooks/stripe', [Link]({ type: 'application/json' }), (req, res) => {
const sig = [Link]['stripe-signature']
switch ([Link]) {
case 'payment_intent.succeeded':
// Handle successful payment
break
}
Always verify webhook signatures. Attacker could send fake webhook events to your endpoint.
API Security
HTTPS always — HTTP transmits plaintext. Man-in-the-middle can read tokens, passwords, data.
Railway/Render provide HTTPS automatically.
Input sanitization — Prevent NoSQL injection ($where, $gt etc in MongoDB). Prevent SQL injection (use
parameterized queries, not string concatenation).
Sensitive data in responses — Never return passwords. Consider which fields to expose. Use
.select('-password') in Mongoose.
import helmet from 'helmet'
[Link](helmet()) // Sets ~15 security headers automatically
// Or per-query:
const user = await [Link](id).select('-password')
Quick Reference
Error Flow
throw new AppError('Not found', 404)
→ [Link](next)
→ next(error)
→ Global error handler (4 params)
→ [Link](404).json({ message: 'Not found' })
-- Find duplicates
SELECT email, COUNT(*) FROM users GROUP BY email HAVING COUNT(*) > 1;