部署不等於發布
傳統的 deployment flow 是:merge → deploy → 功能立刻對所有用戶生效。這個模型有幾個問題:
- 需要同步發布的多個功能(前後端、行銷頁面、推播通知)很難做到同一秒 ready
- 新功能 deploy 後如果有問題,回滾要 revert commit + redeploy,需要時間
- 想做 A/B test 或 Canary release(先開給 5% 用戶),沒有機制
Feature toggle 解的是「讓 code deploy 和功能發布解耦」——code 已經在 prod,但功能藏在 toggle 後面,什麼時候開給誰看是獨立的決定。
三種 Toggle 的適用場景
靜態 Toggle:環境變數
// config.ts
const features = {
newCheckoutFlow: process.env.FEATURE_NEW_CHECKOUT === 'true',
darkMode: process.env.FEATURE_DARK_MODE === 'true',
};
// 使用
if (config.features.newCheckoutFlow) {
return newCheckoutHandler(req, res);
} else {
return legacyCheckoutHandler(req, res);
}適合:環境級的開關(dev 開、prod 關)、長期的環境差異(staging 有 debug panel,prod 沒有)。
不適合:需要在不重啟 server 的情況下切換;需要按用戶或群組控制。
JSON 檔案 Toggle:不想進 DB 的中間地帶
不想引入 DB 表,但又需要比 env var 更靈活(可以列出所有 toggle、有結構、方便 code review)——JSON 檔是個合理的中間選項:
// config/feature-flags.json(進 git)
{
"feature/new-checkout-flow": {
"enabled": false,
"owner": "@alice",
"expires": "2026-05-15",
"ticket": "ENG-892"
},
"ops/maintenance-mode": {
"enabled": false,
"owner": "@ops-team",
"expires": null
}
}// 啟動時讀入,全局可用
import flags from '../config/feature-flags.json';
export function isEnabled(flagName: string): boolean {
return flags[flagName]?.enabled ?? false;
}切換方式:直接改 JSON 檔、commit、deploy——不需要 DB migration,toggle 狀態有 git history 可查,PR review 時一眼看到誰改了什麼。
缺點:每次切換都要 deploy(和 env var 一樣);不能做 per-user rollout;JSON 欄位沒有型別保護(可以用 Zod 在啟動時驗)。
適合:小型專案、early stage、團隊想要 toggle 有明確結構但不想維護 DB schema 的場景。不適合需要即時切換(不 deploy)或 per-user 控制的場景。
JSON toggle 的型別驗證(推薦加上):
import { z } from 'zod';
import rawFlags from '../config/feature-flags.json';
const flagSchema = z.record(z.object({
enabled: z.boolean(),
owner: z.string(),
expires: z.string().nullable(),
ticket: z.string().optional(),
}));
const flags = flagSchema.parse(rawFlags); // 啟動時驗,格式錯誤立刻發現
export function isEnabled(flagName: string): boolean {
return flags[flagName]?.enabled ?? false;
}DB-based Toggle:動態開關
CREATE TABLE feature_flags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) UNIQUE NOT NULL,
enabled BOOLEAN DEFAULT FALSE,
rollout_percentage INT DEFAULT 0, -- 0-100,0 = 全關,100 = 全開
enabled_user_ids TEXT[], -- 白名單:只開給這些 user
description TEXT,
owner VARCHAR(100), -- 負責人(email 或 Slack handle)
expires_at DATE, -- ops toggle 填 NULL;feature toggle 一定要填
adr_link VARCHAR(255), -- 指向對應 ADR 的連結(若有)
updated_at TIMESTAMPTZ DEFAULT NOW()
);class FeatureFlagService {
private cache: Map<string, boolean> = new Map();
private cacheExpiry: Map<string, number> = new Map();
private CACHE_TTL = 60 * 1000; // 1 分鐘
async isEnabled(flagName: string, userId?: string): Promise<boolean> {
const cacheKey = `${flagName}:${userId ?? 'global'}`;
const cached = this.cache.get(cacheKey);
if (cached !== undefined && Date.now() < (this.cacheExpiry.get(cacheKey) ?? 0)) {
return cached;
}
const flag = await FeatureFlag.findOne({ where: { name: flagName } });
if (!flag || !flag.enabled) {
this.setCache(cacheKey, false);
return false;
}
// 白名單用戶
if (userId && flag.enabledUserIds?.includes(userId)) {
this.setCache(cacheKey, true);
return true;
}
// 百分比 rollout(依 userId hash 決定,同一個 user 永遠看到同樣結果)
if (flag.rolloutPercentage > 0 && userId) {
const bucket = parseInt(userId.slice(-2), 16) % 100;
const result = bucket < flag.rolloutPercentage;
this.setCache(cacheKey, result);
return result;
}
// 全開
this.setCache(cacheKey, flag.rolloutPercentage === 100);
return flag.rolloutPercentage === 100;
}
private setCache(key: string, value: boolean) {
this.cache.set(key, value);
this.cacheExpiry.set(key, Date.now() + this.CACHE_TTL);
}
}適合:需要在不重啟的情況下切換;需要按用戶 / 百分比 rollout;需要 admin panel 控制。
注意:每個 request 都查 DB 很貴——一定要加 cache(TTL 30 秒–1 分鐘),接受最終一致性。
服務型 Toggle:LaunchDarkly / Unleash
import LaunchDarkly from 'launchdarkly-node-server-sdk';
const ldClient = LaunchDarkly.init(process.env.LD_SDK_KEY);
// 按 user context 決定 toggle
const context = {
kind: 'user',
key: user.id,
email: user.email,
plan: user.subscriptionPlan, // 可以用 plan 做條件
};
const showNewDashboard = await ldClient.variation('new-dashboard', context, false);適合:大型組織、需要複雜 targeting rule(按地區、plan、公司規模)、需要即時推送 toggle 更新(WebSocket,不等 cache 過期)、需要完整的 audit log 和 A/B test 數據。
不適合:小型專案(LaunchDarkly 的費用對小專案不划算);自建 Unleash 需要維護成本。
Toggle 的命名慣例
feature/ → 功能 toggle(最終會刪掉)
experiment/ → A/B test(有時間限制)
ops/ → 操作用開關(long-lived,不刪)
permission/ → 用戶授權(其實是 RBAC 的事,不要混在這裡)
例:
feature/new-checkout-flow
experiment/checkout-cta-color
ops/maintenance-mode
ops/disable-email-notifications
Toggle 的壽命與清理
Feature toggle 最大的問題不是建立,是刪不掉。
一個功能全量 rollout 之後,toggle 應該在 1–2 個 sprint 內移除。但實務上:
- 沒人知道這個 toggle 還有沒有用
- 刪掉會不會有什麼 edge case
- 沒有 owner,沒有清理流程
幾個對策:
1. Toggle 加過期日:
ALTER TABLE feature_flags ADD COLUMN expires_at DATE;CI 加一個 check:超過 expires_at 的 toggle 發出 warning 或 block deploy。
2. Code 裡的 TODO comment 標準格式:
TODO comment 最常見的問題是沒有 owner 和截止日期,兩個月後沒人敢動。標準格式要帶三個資訊:
// TODO(owner): description — expires: YYYY-MM-DD — ticket: PROJ-123
if (await featureFlags.isEnabled('new-checkout-flow', req.user.id)) {範例:
// TODO(@alice): Remove after new-checkout-flow reaches 100% rollout
// expires: 2026-05-15 — ticket: ENG-892
if (await featureFlags.isEnabled('feature/new-checkout-flow', req.user.id)) {
return newCheckoutHandler(req, res);
} else {
return legacyCheckoutHandler(req, res);
}owner:誰負責刪(Slack handle 或 email),不是寫這行的人,是對這個功能有決策權的人expires:全量 rollout 的預計日期,過了這天 PR review 要擋ticket:對應的 issue,讓接手的人有上下文
CI lint rule(eslint-plugin-todo-expired 或自建)可以掃 expires 日期,過期就 warning / error。
3. 限制 toggle 數量:
有些團隊的規則是「同時不超過 10 個 active feature toggle」——超過了就強制清理一個舊的才能開新的。
ADR:什麼時候要寫、格式長什麼樣
Feature toggle 是架構決策——為什麼選 DB-based 而不是 LaunchDarkly?為什麼這個功能要藏在 toggle 後面而不是直接發布?這類「為什麼」的問題,三個月後你會忘記,新人進來不知道,PR description 也找不到。
**ADR(Architecture Decision Record)**是用來記錄這個「為什麼」的輕量文件。不是每個 toggle 都要寫——只有「這個決定有爭議」或「這個決定影響大,未來可能要改」的場景才需要。
觸發 ADR 的情況:
- 選擇 DB-based toggle 還是 LaunchDarkly(有費用、維護成本的 trade-off)
- 決定某個高風險功能用 toggle 而不是 feature branch
- 選擇 rollout 策略(全量 vs 百分比 vs 白名單)
最簡 ADR 格式(不需要複雜,一個 .md 檔就夠):
# ADR-007: 採用 DB-based Feature Toggle 而非 LaunchDarkly
**日期**:2026-04-22
**狀態**:Accepted
**負責人**:@alice
## 背景(Context)
團隊需要在 prod 動態開關功能,不想每次都 deploy。評估了 LaunchDarkly 和自建 DB-based toggle。
## 決策(Decision)
採用 DB-based toggle(PostgreSQL + Redis cache),不用 LaunchDarkly。
## 理由
- LaunchDarkly 月費 $400+,目前規模不划算
- 我們的 targeting 需求單純(百分比 rollout + 白名單),不需要複雜 rule engine
- DB-based 方案完全可控,不依賴外部服務
## 後果(Consequences)
- 需要自建 admin panel 管理 toggle(估計 2 天工)
- 沒有 LaunchDarkly 的 audit log 和 A/B test 分析功能
- 若未來需要複雜 targeting,要評估遷移到 Unleash 或 LaunchDarkly
## 重新評估時機
月活超過 100 萬、或需要按地區 / plan 做細緻 targeting 時。ADR 放在 repo 的 docs/adr/ 目錄,用遞增編號(ADR-001.md)。DB schema 的 adr_link 欄位填對應的 GitHub 連結。
Feature Toggle 是 Production Seed 的一部分
如果你用 DB-based toggle,initial toggle state 要有 Essential seeder:
// database/seeders/essential/05-feature-flags.ts
const featureFlags = [
{
name: 'ops/maintenance-mode',
enabled: false,
rolloutPercentage: 0,
description: '維護模式,開啟後所有非 admin 請求返回 503',
expiresAt: null, // ops toggle 不過期
},
{
name: 'ops/disable-email-notifications',
enabled: false,
rolloutPercentage: 0,
description: '緊急情況關閉所有 email 發送',
expiresAt: null,
},
];
// 幂等:ON CONFLICT DO NOTHING
for (const flag of featureFlags) {
await FeatureFlag.upsert(flag, { conflictFields: ['name'] });
}