業務邏輯放在 Controller

Controller 的唯一職責是:取出 request 參數、呼叫 Service、回傳 response。

// ❌ Controller 裡面做業務邏輯
router.post('/orders', authenticate, async (req, res) => {
  const { productId, quantity } = req.body;
 
  const product = await Product.findById(productId);
  if (!product) return res.status(404).json({ error: 'Product not found' });
 
  if (product.stock < quantity) {
    return res.status(400).json({ error: 'Insufficient stock' });
  }
 
  product.stock -= quantity;
  await product.save();
 
  const order = await Order.create({ productId, quantity, userId: req.user.id });
  await emailService.sendOrderConfirmation(order);
 
  res.status(201).json(order);
});

問題:庫存扣減邏輯、email 發送、訂單建立全在 controller——無法被其他入口(CLI、queue worker)呼叫,也無法單獨測試。

// ✅ Controller 只協調,業務邏輯在 Service
router.post('/orders', authenticate, validate(createOrderSchema), async (req, res) => {
  const order = await orderService.create(req.body, req.user.id);
  res.status(201).json(order);
});

直接 return Entity / Model

把 ORM model 直接 return 給 client,等於把 DB schema 暴露出去。

// ❌
async getUser(id: string) {
  return User.findById(id);  // 包含 password hash、內部欄位、DB 欄位名稱
}

用戶會看到 passwordHashdeletedAtinternalFlag——這些都不該出現在 API response 裡。DB schema 和 API contract 應該獨立演進,直接 return entity 把兩者綁死了。

// ✅
async getUser(id: string) {
  const user = await User.findById(id);
  return UserSerializer.toResponse(user);  // 只回傳對外的欄位
}

N+1 Query

// ❌ 每個 order 都打一次 DB 查 user
const orders = await Order.findAll();
for (const order of orders) {
  order.user = await User.findById(order.userId);  // N 次額外 query
}

100 筆訂單 = 101 次 query。流量低的時候看不出來,高流量或資料量增長時才集體爆發。

// ✅ 一次 JOIN 或 eager load
const orders = await Order.findAll({
  include: [{ model: User, as: 'user' }],
});
 
// 或手動批次查詢(不用 ORM 的 include)
const orders = await Order.findAll();
const userIds = [...new Set(orders.map(o => o.userId))];
const users = await User.findAll({ where: { id: userIds } });
const userMap = new Map(users.map(u => [u.id, u]));
orders.forEach(o => { o.user = userMap.get(o.userId); });

req.body 直接進 DB(Mass Assignment)

// ❌ 攻擊者可以傳 { isAdmin: true, balance: 999999 }
await User.create(req.body);
await user.update(req.body);

永遠只讓明確定義的欄位進 DB:

// ✅ 用 Zod / DTO 過濾
const dto = createUserSchema.parse(req.body);
await User.create({ name: dto.name, email: dto.email });

Catch 吞掉錯誤

// ❌ 錯誤被吞掉,debug 時找不到任何蹤跡
try {
  await emailService.send(email);
} catch {
  // 不處理
}
 
// ❌ console.log 不夠(沒有 context、不帶 stack trace、不進 alerting)
try {
  await emailService.send(email);
} catch (error) {
  console.log('email failed');
}
// ✅
try {
  await emailService.send(email);
} catch (error) {
  logger.error('Email send failed', {
    error: error instanceof Error ? error.message : String(error),
    stack: error instanceof Error ? error.stack : undefined,
    recipient: email.to,
    orderId,
  });
  // 決定要 throw(讓 caller 知道)還是 swallow(非關鍵操作)
  // 但不能什麼都不做
}

Promise 沒有 await(Fire and Forget 沒處理)

// ❌ Promise 沒有被 await,錯誤消失在 void
router.post('/orders', async (req, res) => {
  const order = await orderService.create(req.body);
  emailService.send(order);  // 這行的 rejection 沒人處理
  res.json(order);
});

如果是故意不等的 fire-and-forget,要明確處理 rejection:

// ✅ 故意不等,但處理錯誤
emailService.send(order).catch(err => {
  logger.error('Email fire-and-forget failed', { error: err.message, orderId: order.id });
});
 
// 更好的做法:丟進 queue,讓 queue 處理 retry 和錯誤
await emailQueue.add('send-order-confirmation', { orderId: order.id });

同步操作放在 HTTP Request 生命週期

// ❌ 在 request handler 裡做重的操作
router.post('/reports', async (req, res) => {
  const report = await generateReport(req.query);  // 可能要 30 秒
  res.json(report);  // 用戶等 30 秒,或 timeout
});

任何超過 1-2 秒的操作都不適合放在 HTTP request 的同步路徑上:

// ✅ 丟進 queue,立刻回傳 job ID
router.post('/reports', async (req, res) => {
  const jobId = await reportQueue.add('generate', req.query);
  res.status(202).json({ jobId, message: '報表產生中,請稍後查詢' });
});
 
router.get('/reports/:jobId', async (req, res) => {
  const job = await reportQueue.getJob(req.params.jobId);
  res.json({ status: job.state, result: job.returnvalue });
});

硬編碼配置

// ❌
const db = new Database({
  host: 'prod-db.internal',
  password: 'super-secret-password-123',
  maxConnections: 10,
});

配置應來自環境變數,secrets 不入 codebase:

// ✅
const dbConfig = z.object({
  DB_HOST: z.string(),
  DB_PASSWORD: z.string(),
  DB_MAX_CONNECTIONS: z.coerce.number().default(10),
}).parse(process.env);
 
const db = new Database({
  host: dbConfig.DB_HOST,
  password: dbConfig.DB_PASSWORD,
  maxConnections: dbConfig.DB_MAX_CONNECTIONS,
});

詳見 Config Management


Response 格式不統一

// ❌ 不同 endpoint 回不同格式
// GET /users → { data: [...] }
// GET /orders → [...]
// POST /login → { token: '...' }
// 錯誤時 → { message: '...' } 或 { error: '...' } 或 { msg: '...' }

前端要寫大量 if/else 處理各種格式,型別定義也沒辦法共用。

// ✅ 統一的 response envelope
const response = {
  success: true,
  data: result,
  meta: { page: 1, total: 100 },  // pagination 時
};
 
const errorResponse = {
  success: false,
  error: { code: 'USER_NOT_FOUND', message: '用戶不存在' },
};

詳見 Shared Utilities Layer


缺乏冪等性保護

// ❌ 用戶點了兩次「確認付款」,重複扣款
router.post('/payments', async (req, res) => {
  await paymentService.charge(req.body.amount, req.body.cardToken);
  res.json({ success: true });
});

金融操作、訂單建立、庫存扣減,都要加冪等保護:

// ✅ 用 Idempotency-Key header
router.post('/payments', idempotencyMiddleware, async (req, res) => {
  // middleware 已經處理重複 key 的 response cache
  await paymentService.charge(req.body);
  res.json({ success: true });
});

詳見 Idempotency 設計


缺乏 Graceful Shutdown

// ❌ 直接 kill,進行中的 request 就死了
process.on('SIGTERM', () => {
  process.exit(0);  // 立刻退出
});
// ✅ 讓進行中的 request 完成,不接新的
let isShuttingDown = false;
 
process.on('SIGTERM', async () => {
  isShuttingDown = true;
  server.close(async () => {
    await db.close();
    await redis.quit();
    process.exit(0);
  });
});
 
// 新 request 進來時如果在關機中,回 503
app.use((req, res, next) => {
  if (isShuttingDown) {
    return res.status(503).json({ error: 'Server shutting down' });
  }
  next();
});

缺乏 Pagination

// ❌ 一次回傳所有資料
async getProducts() {
  return Product.findAll();  // 1 萬筆都出來了
}

任何可能成長的 list endpoint 都要加 pagination。資料量小的時候看不出問題,大了之後 query 慢、response 大、記憶體爆。

詳見 Pagination 設計


延伸閱讀