Anti-pattern 一:Controller 塞商業邏輯

這是最常見的問題,在任何框架都會出現:

// ❌ 錯誤:Controller 變成上帝 class
class UserController {
  async store(req: Request, res: Response) {
    const { name, email, password } = req.body;
 
    // 業務規則直接在 Controller
    const existing = await db.query('SELECT * FROM users WHERE email = ?', [email]);
    if (existing.length > 0) {
      return res.status(409).json({ error: 'Email already exists' });
    }
 
    const hashedPassword = await bcrypt.hash(password, 10);
 
    // 直接操作 DB
    const [id] = await db.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',
      [name, email, hashedPassword]
    );
 
    // 發送 email 也在這裡
    await sendWelcomeEmail(email, name);
 
    // 更新快取也在這裡
    await redis.del(`user:${id}`);
 
    res.status(201).json({ id, name, email });
  }
}

這個 Controller 做了五件事:驗重複 email、hash 密碼、插入 DB、發 email、清 cache。

為什麼這是問題

  • 無法 unit test(測試要模擬 DB、Redis、Email 三個外部依賴)
  • 複用不可能(另一個地方也要「建立 user」就要複製貼上)
  • 改一個步驟的邏輯,要在 Controller 裡找到對的地方
// ✅ 正確:Controller 只做 HTTP 的事
class UserController {
  constructor(private userService: UserService) {}
 
  async store(req: Request, res: Response) {
    const dto = this.validate(req); // 驗 HTTP input
    const user = await this.userService.create(dto); // 業務邏輯在 Service
    res.status(201).json(user);
  }
}

Controller 的工作只有兩件:解析 HTTP input、呼叫 Service、格式化 HTTP response。所有業務邏輯在 Service 層。


Anti-pattern 二:DI Container 當 Service Locator

Service Locator 是一個反 DI 的 pattern——不是「依賴被注入進來」,而是「需要的時候主動去容器裡拿」:

// ❌ Service Locator:主動去 container 拿
class UserService {
  async create(dto: CreateUserDto) {
    const emailService = Container.get(EmailService); // ← 主動取
    const cacheService = Container.get(CacheService); // ← 主動取
 
    const user = await this.userRepo.create(dto);
    await emailService.sendWelcome(user.email);
    await cacheService.invalidate(`user:${user.id}`);
    return user;
  }
}

這比真正的 DI 差在哪:

  1. 依賴不可見:看 class signature 不知道它需要哪些依賴
  2. 測試困難:要 mock Container.get 而不是直接注入 mock 物件
  3. 耦合 Container 實作:換 DI container 就要改所有地方
// ✅ 正確 DI:依賴在 constructor 宣告
class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService, // ← 依賴在 signature 清楚可見
    private cacheService: CacheService,
  ) {}
}

Anti-pattern 三:全部靠 Annotation Magic,看不見流程

NestJS / Spring 大量使用 decorator / annotation,這讓行為「神奇地發生了」但你不知道為什麼:

// ❌ Annotation 堆疊,流程不清楚
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
@UseInterceptors(CacheInterceptor, LoggingInterceptor, TransformInterceptor)
@UsePipes(ValidationPipe)
export class UserController {
  @Get(':id')
  @Roles('admin', 'user')
  @CacheKey('user')
  @CacheTTL(60)
  @HttpCode(200)
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.userService.findById(id);
  }
}

這個 endpoint 有 auth guard、roles check、cache、logging、response transform、validation、TTL——全部靠 annotation 堆疊。出了問題,你很難從這裡判斷「請求到底按什麼順序被哪些東西處理了」。

這不是說不要用 annotation,而是:annotation 應該讓意圖更清楚,不是讓行為更神秘。每加一個 annotation,問自己:「如果這個 annotation 消失了,會出什麼問題?這個問題顯而易見嗎?」


Anti-pattern 四:忽略 Graceful Shutdown

在 Kubernetes 裡滾動更新時,舊的 Pod 會收到 SIGTERM,然後有一段時間(terminationGracePeriodSeconds)讓它完成現有請求再退出。

如果你沒有處理 SIGTERM:

// ❌ 沒有 graceful shutdown
const server = app.listen(3000);
// K8s 送 SIGTERM → process 直接結束 → in-flight request 被切斷 → 用戶看到 502

後果是每次部署都有一小段時間的 502 錯誤。

// ✅ 正確:處理 SIGTERM
const server = app.listen(3000);
 
process.on('SIGTERM', async () => {
  server.close(async () => {
    await db.close();
    await redis.quit();
    process.exit(0);
  });
  // 超時保護:25 秒後強制退出
  setTimeout(() => process.exit(1), 25000);
});

23. Lifecycle 與 Hook 有各框架的 graceful shutdown 實作對照。


Anti-pattern 五:Production 自動跑 Migration

在 app 啟動時自動執行 migration:

// ❌ 危險:app 啟動自動 migrate
async function bootstrap() {
  await dataSource.runMigrations(); // ← 每次啟動都跑
  await app.listen(3000);
}

問題一:K8s 同時起多個 Pod,多個 Pod 同時執行 migration,race condition。

問題二:Migration 失敗讓 app 無法啟動,整個服務停掉。

問題三:無法做 rollback(migration 跑了就跑了)。

正確做法:Migration 是獨立的部署步驟,在 app 起動之前的 CI/CD pipeline 裡執行(K8s Job),而不是在 app 啟動時執行。


Anti-pattern 六:DB Connection Pool 設太大

// ❌ 把 pool size 設很大以為效能更好
const dataSource = new DataSource({
  type: 'postgres',
  poolSize: 100, // ← 100 個連線
});

PostgreSQL 預設 max_connections 是 100。如果你起了 5 個 Pod,每個 pool 100 個連線,就是 500 個連線——超過 PostgreSQL 的上限,後面的連線請求全部排隊,整個 pool 卡死。

正確做法:pool size = max_connections / Pod 數量 × 0.8(留 20% buffer)。如果 PostgreSQL max_connections 是 100,你有 5 個 Pod,每個 pool 應該設 16 個連線左右。


延伸閱讀