約束放錯層,每張新功能票都附一張 migration 票

約束放錯層,每張新功能票都附一張 migration 票

500 不是邏輯問題

通知發出去,API 回 500。不是 try/catch 漏了,不是業務邏輯寫錯。是資料庫的 ENUM 欄位看到一個陌生字串——group_expense_updated——直接拒收。錯誤發生在應用層底下,連 catch 都來不及。

師傅在騎樓下用白板列服務項目,想加一條「Gogoro 電池系統檢修」,麥克筆補上去就好。但如果那張清單是刻在門口水泥牆上的石牌,每次加一個項目就得停業、找人來鑿、重新開張。ENUM 就是那塊石牌。字型很正式,改起來比看起來貴。

約束住在哪一層

ENUM 的設計意圖是保護資料一致性:只有清單裡的值才能寫進去。這個邏輯本身沒錯。問題是它把這個約束放進了 DB schema,而不是應用層。

這兩件事的差距是:schema 的約束要改,必須開 migration ticket、跑 migration、重新 deploy,整條流程才能通。應用層的約束要改,改一個字串就好。業務加通知類型的速度,遠高於願意去改 schema 的速度——結果就是每次新功能都附一張看起來很小但擋路的 migration 票。

更壞的狀況是,這個成本不透明。第一次碰到 500 的工程師,通常不會第一時間想到「ENUM 忘了更新」,因為程式碼看起來完全正確。除錯時間會先花在邏輯、在 request payload、在網路層,最後才繞回來看資料庫定義。

改成 VARCHAR 之後

migration 010 直接把通知類型欄位從 ENUM 改成 VARCHAR。改完之後,新增通知類型只需要在程式碼裡多寫一個字串,驗證邏輯住在應用層,隨時可以改、可以測試、可以 rollback。

這不是說 ENUM 不好用。ENUM 在欄位值真的很少變動、且資料庫本身就是真相來源的場合是合適的。性別欄位、狀態機裡的固定狀態、類型已知且穩定的分類——這些放 ENUM 有道理。

通知類型不是這種東西。通知是業務行為的副產品,業務每變一次,通知類型清單就可能長一條。把這種頻繁變動的清單刻進 schema,等於把最容易擴展的那一層變成最難動的那一層。

值得記下來的一個問題

下次設計一個「清單型」欄位——狀態、類型、標籤、角色——值得先問:這份清單,誰最常改它?改的人用什麼工具改?改錯了怎麼回退?

如果答案是「工程師,在程式碼裡,靠 deploy 回退」,那約束就應該住在應用層。如果答案是「DBA,在 schema 裡,有完整的 migration 流程管控」,ENUM 才有它的位置。

約束本身不是問題,約束住在哪裡才是。

— 邱柏宇

延伸閱讀


Put the Constraint in the Wrong Layer and Pay Every Time

The 500 Wasn’t a Logic Error

A new notification type goes out. The API returns 500. No missing try/catch. No logic mistake. The database’s ENUM column encountered an unfamiliar string — group_expense_updated — and refused it at the storage layer, below where any application-level catch could reach.

Think of a repair shop with a handwritten whiteboard listing today’s services. Adding “Gogoro battery system inspection” takes a marker and ten seconds. But if that list is chiseled into the concrete wall out front, adding one item means closing for a day and hiring someone to recut the stone. ENUM is that wall. It looks solid and official. Changing it costs more than it appears.

Which Layer Holds the Constraint

ENUM’s design intent is correct: enforce data consistency by only allowing values from a known list. The problem isn’t the intent — it’s the location. A constraint living in DB schema requires a migration ticket, a migration run, and a full redeploy before a single new value can be written. A constraint living in application code requires changing a string.

Business logic adds notification types faster than anyone wants to open migration tickets. So every new feature quietly ships with a small, annoying migration attached. The cost isn’t obvious either. When the 500 hits, the first instinct is to check application logic, request payloads, network — not to audit the database enum definition. Debug time disappears before the real cause surfaces.

After the VARCHAR Migration

Migration 010 swapped the notification type column from ENUM to VARCHAR. After that, adding a notification type means writing one string in application code. Validation logic lives in the layer that’s fastest to change, test, and roll back.

ENUM still has its place — fixed state machines, stable classification fields where the database itself is the source of truth, values that almost never change. Those are fine candidates.

Notification types aren’t that. Notifications are byproducts of business behavior. Every time the product adds a behavior, the list of notification types can grow. Chiseling a frequently-changing list into schema turns the most extensible layer into the hardest one to touch.

One Question Worth Asking Next Time

When designing any “list-type” column — status, type, tag, role — it’s worth asking first: who changes this list most often, and with what tools? If the answer is “engineers, in code, rolled back via deploy,” the constraint belongs in the application layer. If the answer is “through a managed migration process with full audit,” ENUM earns its place.

The constraint itself isn’t the issue. Where it lives is.

— 邱柏宇

Related Posts