業務邏輯放在 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 欄位名稱
}用戶會看到 passwordHash、deletedAt、internalFlag——這些都不該出現在 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,
});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: '用戶不存在' },
};缺乏冪等性保護
// ❌ 用戶點了兩次「確認付款」,重複扣款
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 設計。
