業務邏輯放在 Controller

Controller 在 Express 的正確職責是四件事:validate → resource check → 呼叫 Service → serialize 回傳。其他都不屬於 controller。

// ❌ 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 四件事:validate → resource check → Service → serialize
router.post('/orders',
  authenticate,
  validate(createOrderSchema),          // 1. 格式驗證(middleware)
  async (req, res) => {
    // 2. Resource check:這個 product 存在嗎?我有權限嗎?
    const product = await productService.findById(req.body.productId);
    if (!product) return res.status(404).json({ error: 'Product not found' });
 
    // 3. 呼叫 Service 做業務邏輯
    const order = await orderService.create(req.body, req.user.id);
 
    // 4. Serialize:轉成 response 格式(不直接 return entity)
    res.status(201).json(OrderSerializer.toResponse(order));
  }
);

Resource check 和 Django serializer 的差異:Django serializer 在驗證階段自動查 DB 確認外鍵存在(PrimaryKeyRelatedField)。Express 沒有這層,要在 controller 或 service 裡明確寫。慣例是:外鍵存在性在 service 裡查(業務規則),不在 controller 查——controller 只確認「是否有權取得這個資源」。


直接 return Entity / Model(不過 Serializer)

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

// ❌
async getUser(id: string) {
  return User.findById(id);  // 包含 passwordHash、deletedAt、internalFlag
}

Serializer 的職責不只是「隱藏欄位」,它做三件事:

  1. 欄位過濾:不暴露 passwordHashdeletedAt 等內部欄位
  2. 格式轉換createdAt 從 Date object 轉 ISO string;金額從整數分轉顯示格式
  3. 關聯攤平user.shop.name 轉成 response 裡的 shopName,不暴露巢狀結構
// ✅
class UserSerializer {
  static toResponse(user: User) {
    return {
      id: user.id,
      name: user.name,
      email: user.email,
      role: user.role,
      shopName: user.shop?.name,           // 攤平關聯
      createdAt: user.createdAt.toISOString(),  // 格式轉換
      // 不包含:passwordHash、deletedAt、internalNote
    };
  }
}

DB schema 和 API contract 獨立演進:DB 加新欄位不會自動洩漏出去,API 格式改了也不影響 DB 設計。


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 設計


Soft Delete 沒有設 Default Scope

Soft delete 是加 deleted_at 欄位取代真正刪除,讓資料可以還原、保留審計記錄。問題是如果沒有設 default scope,每個查詢都要手動加 WHERE deleted_at IS NULL,遲早漏掉。

// ❌ 沒有 default scope,每個查詢要自己記得
const products = await Product.findAll({
  where: { deleted_at: null },  // 忘了加就查到已刪除的資料
});

正確做法:在 Model 設 paranoid(Sequelize)或 Global Scope

// Sequelize paranoid mode:自動加 WHERE deleted_at IS NULL
Product.init({ /* ... */ }, {
  paranoid: true,
  deletedAt: 'deletedAt',
});
 
// 所有查詢自動過濾
await Product.findAll();         // 只回傳未刪除的
await Product.destroy({ where: { id } }); // 實際是 SET deletedAt = NOW()
 
// 需要查已刪除的:
await Product.findAll({ paranoid: false });

Soft Delete 的兩個常見坑

  1. 唯一性約束email UNIQUE 在 soft delete 後擋住相同 email 重新註冊。改成條件唯一索引:

    CREATE UNIQUE INDEX ON users (email) WHERE deleted_at IS NULL;
  2. JOIN 漏掉過濾:JOIN 其他表時,被 soft delete 的關聯資料可能漏掉過濾,需要確認 ORM 的 eager load 行為或手動加條件。


延伸閱讀