Dev Catchup: Building Reliable Backend Services

Node.js Backend — Standards & Common Pitfalls

Architecture

1. Separate layers properly — Controller / Service / Repository

// ❌ Everything dumped into the controller

app.post('/orders', async (req, res) => {
  const user = await prisma.user.findUnique({ where: { id: req.userId } });
  if (user.balance < req.body.total) return res.status(400).json({ error: 'Insufficient balance' });
  const order = await prisma.order.create({ data: { ... } });
  await prisma.user.update({ where: { id: user.id }, data: { balance: { decrement: order.total } } });
  await prisma.inventory.update({ ... });
  await sendEmail(user.email, 'Order confirmed', ...);
  res.json(order);
});

// → Can't test business logic in isolation
// → Need the same logic in a cron job? Duplicate everything
// → File grows to 500+ lines, everyone's afraid to touch it

Each layer has ONE job:

LayerResponsibility
ControllerParse HTTP request, call service, return HTTP response
ServiceBusiness logic, orchestrate workflow
RepositoryQuery database, return raw data
// ✅ Controller — only bridges HTTP ↔ Service
app.post('/orders', asyncHandler(async (req, res) => {
  const order = await orderService.create(req.userId, req.body);
  res.status(201).json({ success: true, data: order });
}));

// ✅ Service — all business logic, knows nothing about HTTP
class OrderService {
  constructor(
    private orderRepo: OrderRepository,
    private userRepo: UserRepository,
  ) {}

  async create(userId: string, input: CreateOrderInput) {
    const user = await this.userRepo.findById(userId);
    if (!user) throw new NotFoundError('User');
    if (user.balance < input.total) {
      throw new AppError(400, 'INSUFFICIENT_BALANCE', 'Not enough balance');
    }
    return this.orderRepo.createWithPayment(userId, input);
  }
}

// ✅ Repository — only knows how to read/write database
class OrderRepository {
  async createWithPayment(userId: string, input: CreateOrderInput) {
    return prisma.$transaction(async (tx) => {
      const order = await tx.order.create({ data: { userId, ...input } });
      await tx.user.update({
        where: { id: userId },
        data: { balance: { decrement: input.total } },
      });
      return order;
    });
  }
}

The same service works everywhere — no duplication:

// HTTP API
app.post('/orders', (req, res) => orderService.create(req.userId, req.body));

// GraphQL
createOrder: (_, args, ctx) => orderService.create(ctx.userId, args.input);

// Cron job
await orderService.processExpiredOrders();

// Queue worker
await orderService.create(job.data.userId, job.data.input);

// Unit test — mock the repo, test pure logic
mockUserRepo.findById.mockResolvedValue({ balance: 0 });
await expect(orderService.create('user1', { total: 100 }))
  .rejects.toThrow('INSUFFICIENT_BALANCE');

Rules:

  • Controller has if logic beyond auth/validation, or calls prisma directly → wrong layer
  • Service imports req/res, or imports prisma/mongoose directly → wrong layer
  • Repository throws business errors or calls external APIs → wrong layer

2. All computation logic belongs on the backend

// ❌ Frontend calculates total, discount, tax and sends them up
// POST /orders  body: { items: [...], total: 299000, discount: 50000, tax: 24900 }
app.post('/orders', async (req, res) => {
  const order = await prisma.order.create({
    data: {
      total:    req.body.total,       // whatever frontend sends
      discount: req.body.discount,    // user edits request → 100% discount
      tax:      req.body.tax,         // tax = 0 → tax evasion
    },
  });
});
// → User opens DevTools, sets total = 1000, discount = 999999 → free purchase
// ✅ Backend ALWAYS calculates — frontend only sends raw input
class OrderService {
  async create(userId: string, input: { items: OrderItemInput[], couponCode?: string }) {
    // 1. Get prices from DB (NEVER trust frontend prices)
    const products = await this.productRepo.findByIds(input.items.map(i => i.productId));

    // 2. Backend calculates subtotal
    const subtotal = input.items.reduce((sum, item) => {
      const product = products.find(p => p.id === item.productId);
      return sum + product.price * item.quantity;
    }, 0);

    // 3. Backend validates coupon and calculates discount
    const discount = input.couponCode
      ? await this.couponService.calculateDiscount(input.couponCode, subtotal)
      : 0;

    // 4. Backend calculates tax and total
    const tax   = (subtotal - discount) * 0.1;
    const total = subtotal - discount + tax;

    return this.orderRepo.create({ userId, items: input.items, subtotal, discount, tax, total });
  }
}

Same applies to reports:

// ❌ Frontend fetches raw data, calculates stats itself
// Problems:
// 1. Web calculates differently than mobile → numbers don't match
// 2. Change the formula → redeploy web + mobile + admin
// 3. Large dataset → load 50K records to client → slow

// ✅ Backend calculates, frontend only displays
class ReportService {
  async getRevenue(from: Date, to: Date) {
    const result = await prisma.order.aggregate({
      where: { status: 'completed', createdAt: { gte: from, lte: to } },
      _sum:   { total: true, discount: true, tax: true },
      _count: { id: true },
    });
    return {
      totalRevenue:      result._sum.total,
      totalDiscount:     result._sum.discount,
      orderCount:        result._count.id,
      averageOrderValue: result._sum.total / result._count.id,
    };
  }
}
// → Change calculation logic? Update backend once, deploy once
// → Web, mobile, admin all show correct numbers immediately

Rule: Frontend sends input (productId, quantity, couponCode). Backend returns computed results (total, discount, tax, report). NEVER trust numbers from the frontend.

3. God file — one file doing too much

// ❌ user.service.ts — 1500 lines, 40 methods
class UserService {
  async register()              { ... }
  async login()                 { ... }
  async forgotPassword()        { ... }
  async updateProfile()         { ... }
  async uploadAvatar()          { ... }
  async getNotifications()      { ... }
  async calculateLoyaltyPoints(){ ... }
  async syncWithCRM()           { ... }
  // ... 26 more methods
}
// → 2 devs edit the same file → constant merge conflicts
// → Finding a function means scrolling through 1500 lines
// → Small login bug → scared to deploy because this file touches everything
// ✅ Split by domain/responsibility
class AuthService    { async register() {...} async login() {...} async forgotPassword() {...} }
class ProfileService { async updateProfile() {...} async uploadAvatar() {...} }
class NotificationService { async getNotifications() {...} async markAsRead() {...} }

Same for utils:

// ❌ utils.ts — 2000 lines containing everything
export function formatDate()    { ... }
export function sendEmail()     { ... }  // NOT a util — it's a service
export function calculateTax()  { ... }  // business logic
export function uploadToS3()    { ... }  // infrastructure

// ✅ Split properly
// utils/date.ts          — only date helpers
// utils/string.ts        — only string helpers
// services/email.service.ts   — service, not util
// infrastructure/storage.ts   — S3/storage operations

Rule of thumb: File > 300 lines or > 10 methods → split it. Generic names (utils.ts, helpers.ts, common.ts) → definitely split.

API Documentation

4. Using Swagger/OpenAPI — and remembering to disable it in production

// ❌ No API docs — or docs live in Notion/Postman and drift out of sync within a week
// Frontend constantly pings backend: "what fields does this endpoint take?"
// New devs spend their first week reverse-engineering routes from code

// ✅ Use Swagger/OpenAPI — docs generated from code, always in sync
import swaggerUi   from 'swagger-ui-express';
import swaggerJsdoc from 'swagger-jsdoc';

const specs = swaggerJsdoc({
  definition: {
    openapi: '3.0.0',
    info:    { title: 'My API', version: '1.0.0' },
    servers: [{ url: 'http://localhost:3000' }],
  },
  apis: ['./src/routes/*.ts'], // reads JSDoc comments
});

/**
 * @openapi
 * /orders:
 *   post:
 *     summary: Create an order
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             type: object
 *             required: [items]
 *             properties:
 *               items:
 *                 type: array
 *                 items:
 *                   type: object
 *                   properties:
 *                     productId: { type: string }
 *                     quantity:  { type: integer, minimum: 1 }
 *               couponCode: { type: string }
 *     responses:
 *       201: { description: Order created }
 *       400: { description: Validation error }
 */
app.post('/orders', ...);

⚠️ CRITICAL: Disable Swagger UI in production

// ❌ Leaving /api-docs open in production
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
// → Anyone can visit yoursite.com/api-docs and see your ENTIRE API surface:
//   every endpoint, every field, every validation rule, every error code
// → Attackers use this as a recon blueprint: IDOR targets, mass-assignment
//   candidates, hidden internal endpoints you forgot to mark private

// ✅ Option 1: Only expose in non-production environments
if (process.env.NODE_ENV !== 'production') {
  app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
}

// ✅ Option 2: If you MUST keep it in production, put it behind auth
app.use(
  '/api-docs',
  basicAuth({ users: { [process.env.DOCS_USER!]: process.env.DOCS_PASS! }, challenge: true }),
  swaggerUi.serve,
  swaggerUi.setup(specs),
);

// ✅ Option 3: Serve the raw spec to internal tools only, no public UI
//    (generate openapi.json at build time, share via internal portal)

Rules:

  • Keep JSDoc/OpenAPI annotations next to the route — they rot the moment they live elsewhere
  • NODE_ENV !== 'production' guard is the default; add auth only if product needs public docs
  • Review the generated spec before each release — make sure no admin/internal route leaked in
  • Never include real tokens, sample user IDs, or production URLs in examples

Database

5. Redundant queries — batch what you can into fewer SQL calls

The classic "N+1" is querying inside a loop, but the real principle is broader: if you can get the data in 1 query, don't split it into N separate ones.

// ===== CASE 1: Query inside a loop (classic N+1) =====
// ❌ GET /orders — list orders with user info
const orders = await prisma.order.findMany();                                         // 1 query
for (const order of orders) {
  order.user = await prisma.user.findUnique({ where: { id: order.userId } });         // N queries
}
// 100 orders = 101 queries → should be 1-2

// ✅ Include / Join — one round trip
const orders = await prisma.order.findMany({
  include: { user: { select: { id: true, name: true } } },
});
// ===== CASE 2: Multiple separate queries that could be one =====
// ❌ Dashboard API — 4 separate queries, sequential = slow
const totalUsers    = await prisma.user.count();
const totalOrders   = await prisma.order.count();
const totalRevenue  = await prisma.order.aggregate({ _sum: { total: true } });
const newUsersToday = await prisma.user.count({ where: { createdAt: { gte: startOfDay } } });

// ✅ Run them in parallel — same 4 queries, 1 round trip time
const [totalUsers, totalOrders, totalRevenue, newUsersToday] = await Promise.all([
  prisma.user.count(),
  prisma.order.count(),
  prisma.order.aggregate({ _sum: { total: true } }),
  prisma.user.count({ where: { createdAt: { gte: startOfDay } } }),
]);

// ✅ Or even better — one raw SQL if your DB supports it
const dashboard = await prisma.$queryRaw`
  SELECT
    (SELECT COUNT(*) FROM users)                                AS total_users,
    (SELECT COUNT(*) FROM orders)                               AS total_orders,
    (SELECT COALESCE(SUM(total), 0) FROM orders)                AS total_revenue,
    (SELECT COUNT(*) FROM users WHERE created_at >= ${startOfDay}) AS new_users_today
`;
// ===== CASE 3: Checking related data one by one =====
// ❌ Check stock for each item individually — 10 items = 10 queries
async function validateStock(items: CartItem[]) {
  for (const item of items) {
    const product = await prisma.product.findUnique({ where: { id: item.productId } });
    if (product.stock < item.quantity) throw new Error(`${product.name} out of stock`);
  }
}

// ✅ Fetch all products at once, check in memory — 1 query regardless of cart size
async function validateStock(items: CartItem[]) {
  const productIds = items.map(i => i.productId);
  const products   = await prisma.product.findMany({ where: { id: { in: productIds } } });

  for (const item of items) {
    const product = products.find(p => p.id === item.productId);
    if (!product)                        throw new NotFoundError(`Product ${item.productId}`);
    if (product.stock < item.quantity)   throw new Error(`${product.name} out of stock`);
  }
}

How to detect: Enable query logging in development, count queries per request. If the same table gets hit N times → batch it.

6. Missing or wrong transaction scope

// ===== CASE 1: No transaction → inconsistent data =====
// ❌ User buys Premium plan — one step fails midway
await prisma.payment.create({ data: { userId, amount: 299000, status: 'completed' } }); // ✅ money deducted
await prisma.user.update({ where: { id: userId }, data: { plan: 'premium' } });          // ❌ DB timeout
await prisma.subscription.create({ data: { userId, plan: 'premium', expiredAt } });      // ❌ never runs
// → Money gone but user is still Free. Support has to fix manually.

// ✅ All-or-nothing
await prisma.$transaction(async (tx) => {
  await tx.payment.create({ data: { userId, amount: 299000, status: 'completed' } });
  await tx.user.update({ where: { id: userId }, data: { plan: 'premium' } });
  await tx.subscription.create({ data: { userId, plan: 'premium', expiredAt } });
  // If any step fails → ALL rolled back, money not deducted
});
// ===== CASE 2: Transaction scope too wide → locks too long =====
// ❌ External API call inside transaction → DB rows locked for 5-10 seconds
await prisma.$transaction(async (tx) => {
  const order   = await tx.order.create({ data: orderData });
  const payment = await stripeClient.charges.create({ amount: order.total }); // 3-5s timeout
  await tx.order.update({ where: { id: order.id }, data: { paymentId: payment.id } });
});

// ✅ Keep transaction fast — external calls OUTSIDE
// Step 1: Create order as pending (fast transaction)
const order = await prisma.$transaction(async (tx) => {
  const order = await tx.order.create({ data: { ...orderData, status: 'pending_payment' } });
  await tx.inventory.update({
    where: { productId: order.productId },
    data:  { quantity: { decrement: order.quantity } },
  });
  return order;
});

// Step 2: Call payment gateway OUTSIDE transaction
const payment = await stripeClient.charges.create({ amount: order.total });

// Step 3: Update status (fast transaction)
await prisma.order.update({
  where: { id: order.id },
  data:  { paymentId: payment.id, status: 'paid' },
});
// If payment fails → order stays pending → cron job cleans up later

7. SELECT * instead of selecting only needed fields

// ❌ Fetching all 30 columns when you only need 3
const users = await prisma.user.findMany(); // all fields including password hash

// ✅ Select only what you need
const users = await prisma.user.findMany({
  select: { id: true, name: true, avatar: true },
});
// → Less data transfer, faster, no sensitive field leaks

8. Storing dates as DATE instead of TIMESTAMP

// ❌ Storing startDate, endDate as DATE (date only, no time)
model Promotion {
  startDate DateTime @db.Date  // 2025-04-22
  endDate   DateTime @db.Date  // 2025-04-30
}
// Promotion ends April 30 — but does "end" mean 00:00 or 23:59?
// User in UTC+7 sees it expire earlier than user in UTC-5
// WHERE date BETWEEN '2025-04-22' AND '2025-04-30'
// → Runs from 00:00 on the 22nd to 00:00 on the 30th (loses entire day 30)

// ✅ ALWAYS store TIMESTAMP with time component
model Promotion {
  startAt DateTime @db.Timestamptz  // 2025-04-22T00:00:00+07:00
  endAt   DateTime @db.Timestamptz  // 2025-04-30T23:59:59+07:00
}

model Subscription {
  // ❌ Wrong
  startDate DateTime @db.Date   // "starts May 1" — but 00:00 in which timezone?
  endDate   DateTime @db.Date   // "expires May 31" — can user still access at 23:00?

  // ✅ Correct
  startAt DateTime @db.Timestamptz  // 2025-05-01T00:00:00Z — clearly UTC
  endAt   DateTime @db.Timestamptz  // 2025-05-31T23:59:59Z — clearly UTC
}

Best practices:

  • Store UTC in database, convert to local timezone in client/API response
  • Use Timestamptz (PostgreSQL) or DATETIME (MySQL) — NOT DATE for business logic
  • Compare with: WHERE startAt <= NOW() AND endAt >= NOW() — always correct regardless of timezone
  • Naming: use startAt/endAt instead of startDate/endDate to signal time component

Security

9. Not checking ownership (IDOR)

// ❌ User A can view/edit/delete User B's data
app.get('/orders/:id', authenticate, async (req, res) => {
  const order = await prisma.order.findUnique({ where: { id: req.params.id } });
  res.json(order); // NOT checking order.userId === req.user.id
  // → Anyone who knows an orderId can view it
});

// ✅ Always filter by ownership
app.get('/orders/:id', authenticate, async (req, res) => {
  const order = await prisma.order.findFirst({
    where: {
      id:     req.params.id,
      userId: req.user.id,  // only fetch orders belonging to current user
    },
  });
  if (!order) throw new NotFoundError('Order'); // 404, NOT 403 (avoid leaking existence)
  res.json(order);
});

Applies to ALL resources — files, messages, profiles, settings. Rule: Every query fetching data must filter by user ownership or have an explicit permission check.

10. Mass assignment

// ❌ Spreading request body directly into DB
app.patch('/users/me', authenticate, async (req, res) => {
  const user = await prisma.user.update({
    where: { id: req.user.id },
    data: req.body, // Attacker sends { "role": "admin", "balance": 999999 }
  });
});

// ✅ Whitelist allowed fields
const updateProfileSchema = z.object({
  name:   z.string().min(2).max(100).optional(),
  avatar: z.string().url().optional(),
  bio:    z.string().max(500).optional(),
  // NO role, balance, isVerified, etc.
});

app.patch('/users/me', authenticate, validate(updateProfileSchema), async (req, res) => {
  const user = await prisma.user.update({
    where: { id: req.user.id },
    data: req.body, // already filtered through schema
  });
});

11. Not validating query params / request input

// ❌ Everyone validates body, but FORGETS query params
app.get('/products', async (req, res) => {
  const page  = req.query.page;   // string "abc" → NaN → bug
  const limit = req.query.limit;  // string "99999" → OOM
  const sort  = req.query.sort;   // "password" → leaks sensitive field
});

// ✅ Validate query params too
const listQuerySchema = z.object({
  page:   z.coerce.number().int().min(1).default(1),
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  sort:   z.enum(['createdAt', 'name', 'price', '-createdAt', '-name', '-price']).default('-createdAt'),
  status: z.enum(['active', 'inactive', 'all']).optional(),
});

Rule: Validate all input — body, query, params, headers. Not just body.

12. Returning too much data in response

// ❌ Return entire object, client only needs a few fields
const user = await prisma.user.findUnique({ where: { id } });
res.json(user); // password hash, internal flags, metadata all leak to client

// ✅ Select explicit or use DTO/serializer
const user = await prisma.user.findUnique({
  where:  { id },
  select: { id: true, name: true, email: true, avatar: true },
});

13. Leaking information through error messages

// ❌ Tells attacker whether an email exists
POST /auth/login → "Password incorrect"  // → email exists
POST /auth/login → "User not found"       // → email doesn't exist
// → Attacker can enumerate valid emails

// ✅ Same response for all cases
POST /auth/login         → "Invalid email or password"
POST /auth/forgot-password → "If this email exists, we sent a reset link"

// Even timing should be the same → hash a dummy password even if user doesn't exist

14. Exposing sensitive data in logs

// ❌ Logging entire request body — contains passwords, tokens, PII
logger.info({ body: req.body }, 'Request received');
// Output: { body: { email: "user@x.com", password: "MySecret123", ssn: "123-45-6789" } }

// ✅ Redact sensitive fields
const logger = pino({
  redact: {
    paths: [
      'req.headers.authorization',
      'req.headers.cookie',
      'req.body.password',
      'req.body.confirmPassword',
      'req.body.token',
      'req.body.creditCard',
      '*.password',
      '*.secret',
      '*.accessToken',
      '*.refreshToken',
    ],
    censor: '[REDACTED]',
  },
});

Error Handling

15. Swallowing errors — catch and do nothing

// ❌ Bug silently disappears
try {
  await sendEmail(user.email, template);
} catch (err) {
  // "fix later" — never fixed
}

// ❌ Catch-all with generic response — no log, nobody knows what happened
try {
  await complexBusinessLogic();
} catch (err) {
  res.status(500).json({ error: 'Something went wrong' });
}

// ✅ Catch, log, then decide: rethrow or handle gracefully
try {
  await sendEmail(user.email, template);
} catch (err) {
  logger.error({ err, userId: user.id, template }, 'Failed to send email');
  await emailQueue.add('retry-email', { userId: user.id, template }, {
    attempts: 3,
    backoff: { type: 'exponential', delay: 5000 },
  });
}

16. Unhandled Promise Rejections

// ❌ Fire-and-forget async function → crashes app if it throws
app.post('/orders', async (req, res) => {
  const order = await orderService.create(req.body);
  sendOrderConfirmationEmail(order); // no await, no catch → unhandled rejection
  res.json(order);
});

// ✅ Option 1: await + catch
await sendOrderConfirmationEmail(order).catch(err => {
  logger.error({ err }, 'Email send failed');
});

// ✅ Option 2: use a queue (better for heavy tasks)
await emailQueue.add('order-confirmation', { orderId: order.id });

// ✅ Safety net (always have it, but DON'T rely on it)
process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'Unhandled Rejection — THIS IS A BUG');
});

17. Not distinguishing operational vs programmer errors

Operational errors: expected, handled (invalid input, network timeout, DB down)
Programmer errors: bugs (TypeError, null reference, logic errors)

// ❌ Treating all errors the same
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message }); // leaks internal error messages
});

// ✅ Handle them differently
class AppError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public isOperational = true,
  ) { super(message); }
}

app.use((err, req, res, next) => {
  if (err instanceof AppError && err.isOperational) {
    return res.status(err.statusCode).json({
      success: false,
      error: { code: err.code, message: err.message },
    });
  }

  // Programmer error: log details, return generic message
  logger.fatal({ err, req: { method: req.method, url: req.url } },
    'PROGRAMMER ERROR — needs immediate fix');
  res.status(500).json({
    success: false,
    error: { code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' },
  });
});

18. Inconsistent error response format

// ❌ Every endpoint returns errors differently → frontend has to guess
res.status(400).json({ message: 'Bad request' });           // endpoint A
res.status(400).json({ error: 'Invalid email' });            // endpoint B
res.status(400).json({ errors: [{ field: 'email' }] });      // endpoint C

// ✅ Always use ONE format, enforced by global error handler
{
  "success": false,
  "error": {
    "code":    "VALIDATION_ERROR",
    "message": "Human-readable message",
    "details": [...]  // optional
  }
}

Performance

19. Not caching rarely-changing data

// ❌ Every request queries config/settings from DB
app.get('/products', async (req, res) => {
  const categories = await prisma.category.findMany();    // changes once a week
  const settings   = await prisma.settings.findFirst();   // changes once a month
  const products   = await prisma.product.findMany({ ... }); // the data you need
  // 3 queries instead of 1
});

// ✅ Cache rarely-changing data
const getCategories = async () => {
  const cached = await redis.get('categories');
  if (cached) return JSON.parse(cached);

  const categories = await prisma.category.findMany();
  await redis.set('categories', JSON.stringify(categories), 'EX', 3600); // 1h
  return categories;
};

// Invalidate when admin updates
app.put('/admin/categories', async (req, res) => {
  await prisma.category.update({ ... });
  await redis.del('categories');
});

20. Blocking the event loop

// ❌ CPU-intensive task blocks the entire server
app.post('/reports/generate', async (req, res) => {
  const data = await prisma.order.findMany({ ... }); // 100K records
  const csv  = generateCSV(data); // CPU-bound, blocks event loop for 5 seconds
  // During those 5 seconds NO other request gets processed
  res.send(csv);
});

// ✅ Offload heavy work to worker thread or queue
app.post('/reports/generate', async (req, res) => {
  const jobId = await reportQueue.add('generate-csv', { filters: req.body });
  res.status(202).json({
    success: true,
    data: { jobId, status: 'processing', checkUrl: `/reports/status/${jobId}` },
  });
});
// Client polls status or gets notified via WebSocket when done

Quick Reference

#PitfallImpactFix
1Business logic in controllerCan't test/reuseController → Service → Repo
2Computation logic on frontendUser edits price, reports mismatchBackend computes all, FE only displays
3God fileMerge conflicts, fear of deployingSplit by domain, < 300 lines per file
4Swagger/OpenAPI docsNo docs → drift; public docs in prod → recon blueprintUse swagger-jsdoc, disable/auth-gate in production
5Redundant queries (N+1)API 10-100x slowerBatch into fewer queries, use includes
6Wrong/missing transactionMoney deducted but plan unchangedAll-or-nothing, external calls outside tx
7SELECT *Slow, leaks sensitive fieldsSelect only needed fields
8DATE instead of TIMESTAMPTimezone bugs, lost hoursAlways Timestamptz, store UTC
9No ownership check (IDOR)User sees other's dataFilter by userId in every query
10Mass assignmentUser sets role=adminWhitelist fields with Zod
11No query/params validationInjection, OOM, data leakZod validate all input
12Returning too much dataLeaks password hash, PIISelect explicit / DTO
13Info leak via error messagesAttacker enumerates emailsSame response for all cases
14Sensitive data in logsPII leakPino redact
15Swallowing errorsHidden bugs, blind debuggingLog + retry/throw
16Unhandled Promise RejectionsApp crashesawait + catch or use queue
17Not distinguishing error typesLeaks internals or hides bugsOperational vs programmer errors
18Inconsistent error formatFrontend guesses formatOne format, global error handler
19Not caching static dataUnnecessary DB queriesRedis cache + invalidate on update
20Blocking event loopServer freezesQueue / Worker thread

Last updated: May 2026 · Maintained by the backend team