部署不等於發布

傳統的 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'] });
}

延伸閱讀