フィーチャーフラグとは、コードを書き換えずに機能のオン・オフを切り替えるための条件分岐と、その切り替え値の置き場所をまとめた仕組みです。 機能フラグ、フィーチャートグルとも呼ばれます。新しいコードを本番に置いたまま眠らせておき、準備ができたら設定だけで公開できるので、デプロイ(コードを本番に置くこと)とリリース(利用者に見せること)を別々の日に行えます。

この記事では、4種類の使い分けの早見表、環境変数から始めてDBテーブルまで育てる実装(PythonとTypeScript、すべて実行結果つき)、表計算ソフトに貼れる管理台帳のひな形、役目を終えたフラグの消し方までを1本にまとめます。例は架空の備品予約システムです。

確認環境(2026-10-05):Ubuntu 24.04 / Python 3.11.15(pytest 9.1.1、psycopg 3.3.6)/ Node.js 22.22.2(TypeScript 7.0.2)/ PostgreSQL 16.15。掲載したコードと出力は、この環境で実行したものです。

この記事の結論

  • フラグは「デプロイとリリースを切り離す」ための仕組みです。コードは先に本番へ置き、公開は設定で行います。
  • 作るときに「種類・担当者・削除期限」の3つを決めて台帳に書きます。 決めずに作ったフラグは、消してよいか誰にも判断できなくなります。
  • 役目が終わったら、if 分岐ごと消します。 オフ側の処理とそのテストも一緒に消して、初めて完了です。
  • 実装は環境変数 → 設定ファイル → DBテーブルの順に、必要になった分だけ育てれば足ります。
  • 段階公開は「フラグ名+利用者ID」のハッシュを100で割った余りで決めると、同じ人には毎回同じ結果が返ります。

フィーチャーフラグ4種類の早見表

4種類の分け方は、Pete Hodgson 氏の記事「Feature Toggles (aka Feature Flags)」(martinfowler.com、2017年10月9日公開)の整理に沿っています。寿命と判定のタイミングは同記事の記述、切り替える人と台帳の扱いは運用の目安として添えたものです。

種類 目的 寿命 切り替える人(目安) 判定のタイミング 削除期限
リリースフラグ 作りかけの機能を本番に置いたまま隠す 短い(数日〜数週間) 開発チーム デプロイごとに固定でよい 必ず決める
実験フラグ A案とB案を出し分けて比べる 短い(数時間〜数週間) 企画・分析の担当 リクエストごと(利用者で決まる) 必ず決める(集計の終了日)
運用フラグ 負荷が高いときに重い機能を一時的に止める 多くは短い。止めるスイッチとして常設するものもある 運用の担当 リクエストごと(すぐ反映できること) 常設なら見直し日を決める
権限フラグ 特定の利用者や組織だけに機能を出す 長い(年単位もある) 管理者・サポート担当 リクエストごと(利用者で決まる) 見直し日を決める

迷ったら次の表で種類を決めます。

聞くこと はい の場合
全員に公開したら、このフラグは不要になるか リリースフラグ
2つ以上の案の数字を比べるためのものか 実験フラグ
障害や高負荷のときに押すスイッチか 運用フラグ
「誰に見せるか」が公開後もずっと残る条件か 権限フラグ

1つのフラグに2つの役割を持たせないことが大切です。たとえばリリースフラグとして作ったものを、公開後に「一部の組織だけオフ」という権限の用途で使い続けると、寿命も切り替える人も変わります。役割が変わるときは、別の名前のフラグを作り直して台帳にも別の行を書きます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「フィーチャーフラグとは:4種類の使い分けと実装コード・管理台帳テンプレート」

フィーチャーフラグと設定値・権限管理・ブランチ運用の違い

フラグは便利なぶん、設定値や権限の仕組みと混ざりやすい道具です。違いは「寿命」と「消す前提かどうか」に表れます。

比べるもの 何を決めるか 寿命 変える人 終わり方
フィーチャーフラグ 新しい処理を通すか、従来の処理を通すか 短い(権限フラグを除く) 開発・企画・運用 分岐ごとコードから消す
設定値 タイムアウト秒数、1ページの件数、接続先など システムが続く限り 運用 消さない。値を変えるだけ
権限管理(ロール) だれが何をできるか システムが続く限り 管理者 消さない。業務のルールとして残る
ブランチ運用 未完成のコードをどこに置くか マージまで 開発 マージしてブランチを消す

どれを使うかは、次の条件で決められます。

条件 選ぶもの
未完成の機能を、長く分かれたブランチにせず本線へこまめに入れたい リリースフラグ
公開の日時を、デプロイの日時と別に決めたい リリースフラグ
数値や文字列を環境ごとに変えたいだけで、分岐は増えない 設定値
「管理者だけが削除できる」のように、業務上ずっと続く決まり 権限管理(ロール)
一部の利用者に先に試してもらい、いずれ全員に出す リリースフラグ+段階公開
一部の利用者だけに出す状態が、契約や組織の単位でずっと続く 権限フラグ、または権限管理に組み込む
数日で終わる小さな変更で、途中の状態を本番に置く必要がない フラグなし(短いブランチで足りる)

段階1:環境変数で切り替える

最初の1本は、環境変数と if 文だけで十分です。フラグが3本以下で、切り替えのたびに再起動してよいならこの形で困りません。

# flags_env.py
"""段階1: 環境変数だけで切り替える"""
import os

TRUE_WORDS = {"1", "true", "on", "yes"}


def env_flag(name: str, default: bool = False) -> bool:
    raw = os.environ.get(f"FLAG_{name.upper()}")
    if raw is None:
        return default
    return raw.strip().lower() in TRUE_WORDS


def search(keyword: str) -> str:
    if env_flag("new_search"):
        return f"新しい検索: {keyword}(あいまい一致)"
    return f"従来の検索: {keyword}(前方一致)"


if __name__ == "__main__":
    print(search("プロジェクター"))
python3 flags_env.py
FLAG_NEW_SEARCH=on python3 flags_env.py
FLAG_NEW_SEARCH=0 python3 flags_env.py
従来の検索: プロジェクター(前方一致)
新しい検索: プロジェクター(あいまい一致)
従来の検索: プロジェクター(前方一致)

ポイントは2つです。環境変数が無いときの既定値をオフ(従来の動き)にすることと、「on」「true」「1」など受け付ける書き方を1つの関数に閉じ込めることです。os.environ.get("FLAG_X") をそのまま if に渡すと、"0" や "false" も空でない文字列なのでオンと判定されます。

段階2:設定ファイルと、利用者IDのハッシュによる10%公開

フラグが増えてきたら、JSON の設定ファイルにまとめます。ここで「全員」ではなく「10%の利用者だけ」に出す段階公開も入れます。

{
  "new_search": {
    "kind": "release",
    "enabled": true,
    "rollout_percent": 10,
    "allow_users": ["u0007"],
    "owner": "sato",
    "created_on": "2026-09-24",
    "expires_on": "2026-11-30"
  },
  "calendar_view": {
    "kind": "release",
    "enabled": false,
    "rollout_percent": 100,
    "allow_users": [],
    "owner": "suzuki",
    "created_on": "2026-10-01",
    "expires_on": "2026-12-15"
  }
}
# flags_file.py
"""段階2: 設定ファイル(JSON)+利用者IDのハッシュで段階公開"""
import hashlib
import json
from pathlib import Path


def bucket(flag: str, user_id: str) -> int:
    """フラグ名と利用者IDから 0〜99 の番号を決める(何度呼んでも同じ値)"""
    digest = hashlib.sha256(f"{flag}:{user_id}".encode("utf-8")).hexdigest()
    return int(digest[:8], 16) % 100


def decide(rule: dict | None, flag: str, user_id: str | None) -> bool:
    if rule is None or not rule.get("enabled", False):
        return False                      # 未定義・停止中は必ずオフ
    if user_id is not None and user_id in rule.get("allow_users", []):
        return True                       # 先行して使う人
    percent = rule.get("rollout_percent", 100)
    if percent >= 100:
        return True
    if user_id is None:
        return False                      # 利用者が分からないときはオフ側
    return bucket(flag, user_id) < percent


class FileFlags:
    def __init__(self, path: str = "flags.json"):
        self.rules = json.loads(Path(path).read_text(encoding="utf-8"))

    def is_enabled(self, flag: str, user_id: str | None = None) -> bool:
        return decide(self.rules.get(flag), flag, user_id)


if __name__ == "__main__":
    flags = FileFlags()
    users = [f"u{n:04d}" for n in range(1, 1001)]
    on = [u for u in users if flags.is_enabled("new_search", u)]
    print("対象 1000人中 オン:", len(on), "人")
    print("先頭5人:", on[:5])
    print("u0007(先行利用者):", flags.is_enabled("new_search", "u0007"))
    print("u0001 の番号:", bucket("new_search", "u0001"),
          "→", flags.is_enabled("new_search", "u0001"))
    print("同じ人を3回判定:", [flags.is_enabled("new_search", "u0013") for _ in range(3)])
    print("未定義のフラグ:", flags.is_enabled("no_such_flag", "u0001"))
    print("停止中のフラグ:", flags.is_enabled("calendar_view", "u0001"))

    # 10% → 30% に広げても、10% のときオンだった人はオンのまま
    ten = {u for u in users if bucket("new_search", u) < 10}
    thirty = {u for u in users if bucket("new_search", u) < 30}
    print("10%:", len(ten), "人 / 30%:", len(thirty), "人 / 10%の人が30%に含まれる:", ten <= thirty)

    # フラグ名をハッシュに混ぜているので、別のフラグでは別の人が選ばれる
    other = {u for u in users if bucket("calendar_view", u) < 10}
    print("別フラグの10%:", len(other), "人 / 両方に入る人:", len(ten & other), "人")
python3 flags_file.py
対象 1000人中 オン: 105 人
先頭5人: ['u0007', 'u0013', 'u0017', 'u0023', 'u0046']
u0007(先行利用者): True
u0001 の番号: 62 → False
同じ人を3回判定: [True, True, True]
未定義のフラグ: False
停止中のフラグ: False
10%: 104 人 / 30%: 316 人 / 10%の人が30%に含まれる: True
別フラグの10%: 109 人 / 両方に入る人: 13 人

仕組みは単純です。「フラグ名:利用者ID」を SHA-256 にかけ、先頭8桁を整数にして100で割った余り(0〜99)を、その人の番号にします。 番号が rollout_percent より小さければオンです。

性質 上の出力で分かること
同じ人は毎回同じ結果 3回判定して3回とも True
おおよそ指定した割合になる 10% の指定で 1000人中 104人(先行利用者の u0007 を足して 105人)
割合を広げても、先にオンだった人はオンのまま 10% の 104人は、30% の 316人にすべて含まれる
フラグごとに選ばれる人が変わる 2つのフラグの両方で10%に入る人は 13人だけ

4つ目は、ハッシュにフラグ名を混ぜているから得られる性質です。利用者IDだけをハッシュにすると、どのフラグでも同じ人たちが先に新機能を受け取ることになります。

Python の組み込み hash() が段階公開に向かない理由

hash("new_search:u0001") % 100 と書くほうが短く済みますが、文字列の hash() はプロセスを起動するたびに値が変わります。同じ1行を3回実行した結果です。

python3 -c "print(hash('new_search:u0001') % 100)"
96
5
65

これでは、サーバーを再起動するたびに公開対象が入れ替わります。段階公開の番号は、hashlib のように入力だけで値が決まる関数で作ります。

段階3:DBテーブルに置き、再起動なしで切り替える

設定ファイルは、書き換えのたびにデプロイか再起動が必要です。運用フラグのように「今すぐ止めたい」ものが出てきたら、DBテーブルに移します。 テーブルには、台帳の項目(種類・担当者・削除期限・削除の条件)も一緒に持たせます。

CREATE TABLE feature_flags (
    name            text PRIMARY KEY CHECK (name ~ '^[a-z][a-z0-9_]{2,39}$'),
    kind            text NOT NULL CHECK (kind IN ('release', 'experiment', 'ops', 'permission')),
    enabled         boolean NOT NULL DEFAULT false,
    rollout_percent integer NOT NULL DEFAULT 100 CHECK (rollout_percent BETWEEN 0 AND 100),
    allow_users     text[] NOT NULL DEFAULT '{}',
    owner           text NOT NULL,
    created_on      date NOT NULL DEFAULT current_date,
    expires_on      date,
    remove_when     text NOT NULL,
    updated_at      timestamptz NOT NULL DEFAULT now(),
    -- 運用フラグ・権限フラグ以外は削除期限を必ず入れる
    CHECK (kind IN ('ops', 'permission') OR expires_on IS NOT NULL)
);

INSERT INTO feature_flags (name, kind, enabled, rollout_percent, allow_users, owner, created_on, expires_on, remove_when) VALUES
('new_search',     'release',    true,  10,  '{u0007}', 'sato',   '2026-09-24', '2026-11-30', '100%公開から2週間、問い合わせの増加なし'),
('calendar_view',  'release',    false, 100, '{}',      'suzuki', '2026-10-01', '2026-12-15', '全員に公開して1スプリント経過'),
('top_banner_ab',  'experiment', true,  50,  '{}',      'tanaka', '2026-09-29', '2026-10-27', '4週間ぶんの集計が終わり採用案が決まる'),
('disable_export', 'ops',        false, 100, '{}',      'ito',    '2026-08-18', NULL,         '常設(半年ごとに要否を見直す)');

制約を付けておくと、決めるべきことを決めないまま登録するのを DB が止めてくれます。次の3つの文を流した結果です。

INSERT INTO feature_flags (name, kind, owner, remove_when) VALUES ('tmp_flag', 'release', 'sato', 'あとで決める');
INSERT INTO feature_flags (name, kind, owner, expires_on, remove_when) VALUES ('NewSearch2', 'release', 'sato', '2026-12-01', '全員公開後');
UPDATE feature_flags SET rollout_percent = 150 WHERE name = 'new_search';
ERROR:  new row for relation "feature_flags" violates check constraint "feature_flags_check"
ERROR:  new row for relation "feature_flags" violates check constraint "feature_flags_name_check"
ERROR:  new row for relation "feature_flags" violates check constraint "feature_flags_rollout_percent_check"

1つ目は削除期限なしのリリースフラグ、2つ目は命名規則(英小文字・数字・アンダースコア)に合わない名前、3つ目は 100 を超える割合です。

アプリ側は、判定のたびに DB を読みにいくと遅くなるので、一定時間だけメモリに持ちます。 判定の中身は段階2の decide をそのまま使います。

# flags_db.py
"""段階3: DBテーブルから読み、一定時間だけメモリに持つ"""
import os
import time

import psycopg

from flags_file import decide

SQL = "SELECT name, enabled, rollout_percent, allow_users FROM feature_flags"


class DbFlags:
    def __init__(self, dsn: str, ttl_seconds: float = 30.0):
        self.dsn = dsn
        self.ttl = ttl_seconds
        self.rules: dict[str, dict] = {}
        self.loaded_at = float("-inf")
        self.queries = 0

    def _load(self) -> None:
        if time.monotonic() - self.loaded_at < self.ttl:
            return
        try:
            with psycopg.connect(self.dsn, connect_timeout=2) as conn:
                rows = conn.execute(SQL).fetchall()
        except psycopg.Error as exc:
            # DBに届かないときは、最後に読めた内容のまま続ける
            print("フラグの再読み込みを見送り:", type(exc).__name__)
            self.loaded_at = time.monotonic()   # 次の再試行も ttl 秒後にする
            return
        self.rules = {
            name: {"enabled": enabled, "rollout_percent": percent, "allow_users": allow}
            for name, enabled, percent, allow in rows
        }
        self.loaded_at = time.monotonic()
        self.queries += 1

    def is_enabled(self, flag: str, user_id: str | None = None) -> bool:
        self._load()
        return decide(self.rules.get(flag), flag, user_id)


if __name__ == "__main__":
    dsn = os.environ["FLAGS_DSN"]
    flags = DbFlags(dsn, ttl_seconds=1.0)
    users = [f"u{n:04d}" for n in range(1, 1001)]

    def count() -> int:
        return sum(flags.is_enabled("new_search", u) for u in users)

    print("10%:", count(), "人 / DBへの問い合わせ:", flags.queries, "回")

    with psycopg.connect(dsn) as conn:
        conn.execute("UPDATE feature_flags SET rollout_percent = 50, updated_at = now() WHERE name = 'new_search'")
    print("更新直後:", count(), "人(まだメモリの内容)")
    time.sleep(1.1)
    print("1.1秒後:", count(), "人 / DBへの問い合わせ:", flags.queries, "回")

    with psycopg.connect(dsn) as conn:
        conn.execute("UPDATE feature_flags SET enabled = false, updated_at = now() WHERE name = 'new_search'")
    time.sleep(1.1)
    print("enabled=false にした後:", count(), "人")

    flags.dsn = dsn.replace("5432", "9990")   # 届かない接続先に変えてみる
    time.sleep(1.1)
    print("DBに届かないとき:", count(), "人(最後に読めた内容)")
FLAGS_DSN="host=127.0.0.1 port=5432 dbname=appdb user=app password=********" python3 flags_db.py
10%: 105 人 / DBへの問い合わせ: 1 回
更新直後: 105 人(まだメモリの内容)
1.1秒後: 512 人 / DBへの問い合わせ: 2 回
enabled=false にした後: 0 人
フラグの再読み込みを見送り: OperationalError
DBに届かないとき: 0 人(最後に読めた内容)

(接続先の DB 名とユーザー名は、一般的な名前に置き換えて掲載しています。)

1000人ぶんの判定で DB への問い合わせは1回だけです。切り替えが反映されるまでの時間は、メモリに持つ秒数(ttl)で決まります。 運用フラグを数秒で効かせたいなら ttl を短く、リリースフラグだけなら30秒〜数分で足ります。DB に届かないときは最後に読めた内容で動き続け、次の再試行も ttl 秒後にしています。

3つの段階の選び方

条件 選ぶ段階
フラグが数本で、切り替えはデプロイのついででよい 段階1:環境変数
段階公開をしたい。変更履歴は Git のレビューで残したい 段階2:設定ファイル
再起動なしで切り替えたい。開発者以外も切り替える 段階3:DBテーブル
複数のサービス・複数の言語から同じフラグを見る 段階3、または専用のフラグ管理サービス

TypeScript での実装

同じ JSON を TypeScript から読みます。フラグ名を文字列の型で縛っておくのが TypeScript らしい工夫です。

// flags.ts
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

export type FlagKind = "release" | "experiment" | "ops" | "permission";

export interface FlagRule {
  kind: FlagKind;
  enabled: boolean;
  rollout_percent: number;
  allow_users: string[];
  owner: string;
  created_on: string;
  expires_on: string | null;
}

// フラグ名は型で縛る。台帳から消したら、使っている箇所がコンパイルで見つかる
export type FlagName = "new_search" | "calendar_view";

export function bucket(flag: string, userId: string): number {
  const digest = createHash("sha256").update(`${flag}:${userId}`, "utf8").digest("hex");
  return parseInt(digest.slice(0, 8), 16) % 100;
}

export function decide(rule: FlagRule | undefined, flag: string, userId?: string): boolean {
  if (!rule || !rule.enabled) return false;
  if (userId !== undefined && rule.allow_users.includes(userId)) return true;
  if (rule.rollout_percent >= 100) return true;
  if (userId === undefined) return false;
  return bucket(flag, userId) < rule.rollout_percent;
}

export interface Flags {
  isEnabled(flag: FlagName, userId?: string): boolean;
}

export class FileFlags implements Flags {
  private rules: Record<string, FlagRule>;

  constructor(path = "flags.json") {
    this.rules = JSON.parse(readFileSync(path, "utf8"));
  }

  isEnabled(flag: FlagName, userId?: string): boolean {
    return decide(this.rules[flag], flag, userId);
  }
}
// main.ts
import { FileFlags, bucket } from "./flags.ts";

const flags = new FileFlags();
const users = Array.from({ length: 1000 }, (_, i) => `u${String(i + 1).padStart(4, "0")}`);
const on = users.filter((u) => flags.isEnabled("new_search", u));

console.log("対象 1000人中 オン:", on.length, "人");
console.log("先頭5人:", on.slice(0, 5));
console.log("u0001 の番号:", bucket("new_search", "u0001"));
node main.ts
対象 1000人中 オン: 105 人
先頭5人: [ 'u0007', 'u0013', 'u0017', 'u0023', 'u0046' ]
u0001 の番号: 62

Python 版と人数も顔ぶれも一致しました。 SHA-256 は言語が違っても同じ値になるので、サーバー側が Python、画面側が TypeScript という構成でも、同じ利用者に同じ結果を返せます。Node.js 22.22.2 は .ts ファイルをそのまま実行できるため、ビルドの手順は入れていません(package.json に "type": "module" を指定)。

フラグ名を打ち間違えると、tsc --noEmit が実行前に知らせてくれます。

console.log(flags.isEnabled("new_serch", "u0001"));
typo.ts(4,29): error TS2345: Argument of type '"new_serch"' is not assignable to parameter of type 'FlagName'.

フラグを消すときも同じ仕組みが役に立ちます。FlagName から名前を外すと、そのフラグを見ている箇所がすべてコンパイルエラーとして一覧になります。

フラグのオンとオフ、両方を通すテスト

フラグを入れると、コードの通り道が2本になります。オン側だけ、オフ側だけのテストでは、切り替えた瞬間に初めて通る道が残ります。 同じテストを、オンとオフの両方で回す形にしておきます。

フラグを見る側のコードは、判定役を引数で受け取るようにします。

# search_service.py
"""フラグを見る場所は1か所にまとめ、判定役は引数で受け取る"""


def search(items: list[str], keyword: str, flags, user_id: str) -> list[str]:
    if flags.is_enabled("new_search", user_id):
        return [x for x in items if keyword in x]        # 新: 部分一致
    return [x for x in items if x.startswith(keyword)]   # 従来: 前方一致
# test_flags.py
import datetime
import json
import os
from pathlib import Path

import pytest

from flags_file import FileFlags, bucket, decide
from search_service import search

ITEMS = ["会議室A", "大会議室", "プロジェクター", "会議用マイク"]


class FixedFlags:
    """テスト用: 指定したフラグだけオンにする"""

    def __init__(self, *on: str):
        self.on = set(on)

    def is_enabled(self, flag, user_id=None):
        return flag in self.on


# オンとオフの両方を、同じテストで必ず通す
@pytest.mark.parametrize(
    "flags, expected",
    [
        (FixedFlags(), ["会議室A", "会議用マイク"]),
        (FixedFlags("new_search"), ["会議室A", "大会議室", "会議用マイク"]),
    ],
    ids=["new_search=off", "new_search=on"],
)
def test_search_both_sides(flags, expected):
    assert search(ITEMS, "会議", flags, "u0001") == expected


def test_bucket_is_stable():
    assert bucket("new_search", "u0001") == 62          # 値が変わると公開対象が入れ替わる
    assert all(0 <= bucket("new_search", f"u{n}") < 100 for n in range(1000))


def test_rollout_edges():
    rule = {"enabled": True, "rollout_percent": 0, "allow_users": []}
    assert not any(decide(rule, "f", f"u{n}") for n in range(1000))
    rule["rollout_percent"] = 100
    assert all(decide(rule, "f", f"u{n}") for n in range(1000))
    assert decide(None, "f", "u1") is False               # 未定義はオフ


def test_no_expired_flags():
    """削除期限を過ぎたフラグが残っていたらテストを止める"""
    today = datetime.date.fromisoformat(os.environ.get("TODAY", datetime.date.today().isoformat()))
    rules = json.loads(Path("flags.json").read_text(encoding="utf-8"))
    expired = [
        f"{name}(期限 {r['expires_on']}・担当 {r['owner']})"
        for name, r in rules.items()
        if r.get("expires_on") and datetime.date.fromisoformat(r["expires_on"]) < today
    ]
    assert not expired, "削除期限を過ぎたフラグ: " + ", ".join(expired)
python3 -m pytest test_flags.py -v
test_flags.py::test_search_both_sides[new_search=off] PASSED             [ 20%]
test_flags.py::test_search_both_sides[new_search=on] PASSED              [ 40%]
test_flags.py::test_bucket_is_stable PASSED                              [ 60%]
test_flags.py::test_rollout_edges PASSED                                 [ 80%]
test_flags.py::test_no_expired_flags PASSED                              [100%]
============================== 5 passed in 0.01s ===============================

最後の test_no_expired_flags は、削除期限を過ぎたフラグが残っているとテストを止める仕掛けです。日付を 2026-12-01 に進めて実行すると、次のように知らせてくれます。

TODAY=2026-12-01 python3 -m pytest -q test_flags.py
>       assert not expired, "削除期限を過ぎたフラグ: " + ", ".join(expired)
E       AssertionError: 削除期限を過ぎたフラグ: new_search(期限 2026-11-30・担当 sato)
…(省略)…
1 failed, 4 passed in 0.02s

期限を延ばすなら、台帳と設定ファイルの日付を理由つきで書き換えます。「黙って残る」状態が無くなることが、この仕掛けのねらいです。

TypeScript では Node.js 標準の node:test で同じ形にできます。

// flags.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { bucket, decide, type FlagName, type FlagRule, type Flags } from "./flags.ts";

const items = ["会議室A", "大会議室", "プロジェクター", "会議用マイク"];

function search(list: string[], keyword: string, flags: Flags, userId: string): string[] {
  return flags.isEnabled("new_search", userId)
    ? list.filter((x) => x.includes(keyword))
    : list.filter((x) => x.startsWith(keyword));
}

const fixed = (...on: FlagName[]): Flags => ({ isEnabled: (flag) => on.includes(flag) });

// オンとオフの両方を同じ形で通す
const cases: [string, Flags, string[]][] = [
  ["new_search=off", fixed(), ["会議室A", "会議用マイク"]],
  ["new_search=on", fixed("new_search"), ["会議室A", "大会議室", "会議用マイク"]],
];
for (const [name, flags, expected] of cases) {
  test(`search ${name}`, () => {
    assert.deepEqual(search(items, "会議", flags, "u0001"), expected);
  });
}

test("Python 版と同じ番号になる", () => {
  assert.equal(bucket("new_search", "u0001"), 62);
});

test("0% は全員オフ、100% は全員オン", () => {
  const rule: FlagRule = {
    kind: "release", enabled: true, rollout_percent: 0, allow_users: [],
    owner: "sato", created_on: "2026-09-24", expires_on: "2026-11-30",
  };
  const ids = Array.from({ length: 1000 }, (_, i) => `u${i}`);
  assert.equal(ids.some((u) => decide(rule, "f", u)), false);
  assert.equal(ids.every((u) => decide({ ...rule, rollout_percent: 100 }, "f", u)), true);
});
node --test flags.test.ts
ok 1 - search new_search=off
ok 2 - search new_search=on
ok 3 - Python 版と同じ番号になる
ok 4 - 0% は全員オフ、100% は全員オン
# tests 4
# pass 4
# fail 0

(出力は結果の行だけを抜き出しています。)

フラグが増えると、オン・オフの組み合わせは2の本数乗になります。全部は回せないので、前掲の Pete Hodgson 氏の記事が挙げる次の3通りを基本にすると現実的です。

回す組み合わせ ねらい
本番で予定している設定(今の設定+次に公開するフラグをオン) 次のリリース後の姿を確かめる
次に公開するフラグをオフに戻した設定 切り戻したときの姿を確かめる
すべてオン 先々の組み合わせで動きが変わらないことを確かめる

フラグ管理台帳テンプレート

フラグは「作る」より「覚えておく」ほうが難しい道具です。1フラグ1行の台帳を用意し、作るときに全部の列を埋めます。

列の定義

列 書くこと 記入のコツ
フラグ名 コードで使う名前そのまま 英小文字とアンダースコア。機能の名前にする(日付や担当者名を入れない)
種類 リリース/実験/運用/権限 1つだけ選ぶ
用途 何を切り替えるかを1文で 「オンで何が起きるか」を書く
既定値 設定が読めないときの値 基本はオフ(従来の動き)
担当者 消すところまで責任を持つ人 チーム名ではなく個人名。異動時は引き継ぐ
作成日 フラグを入れた日 —
削除期限 この日までに分岐を消す リリース・実験は必須。常設は「見直し日」を書く
削除の条件 何が満たされたら消してよいか 数字や期間で書く(「落ち着いたら」は避ける)
状態 作成/一部公開/全公開/削除済み 削除済みの行も消さずに残す

記入例(Markdown)

フラグ名 種類 用途 既定値 担当者 作成日 削除期限 削除の条件 状態
new_search リリース オンで備品検索が部分一致になる オフ 佐藤 2026-09-24 2026-11-30 100%公開から2週間、問い合わせの増加なし 一部公開(10%)
calendar_view リリース オンで予約状況をカレンダー表示にする オフ 鈴木 2026-10-01 2026-12-15 全員に公開して1スプリント経過 作成
top_banner_ab 実験 オンでトップのお知らせ枠をB案にする オフ 田中 2026-09-29 2026-10-27 4週間ぶんの集計が終わり採用案が決まる 一部公開(50%)
disable_export 運用 オンでCSV出力を一時的に止める オフ 伊藤 2026-08-18 見直し 2027-02-18 常設(半年ごとに要否を見直す) 全公開
beta_org_report 権限 オンの組織だけ月次レポートを表示 オフ 高橋 2026-07-06 見直し 2027-01-06 全組織に提供する、または権限管理に移す 一部公開
old_login_form リリース オンで新しいログイン画面を出す オフ 佐藤 2026-06-02 2026-07-31 全公開から2週間経過 削除済み(2026-07-22)

表計算ソフトに貼れる形(タブ区切り)

次のブロックをコピーして、表計算ソフトの A1 セルに貼り付けると列に分かれます。2行目以降は記入例なので、自分のフラグに書き換えて使ってください。

フラグ名    種類  用途  既定値 担当者 作成日 削除期限    削除の条件   状態
new_search  リリース    オンで備品検索が部分一致になる オフ  佐藤  2026-09-24  2026-11-30  100%公開から2週間、問い合わせの増加なし  一部公開(10%)
calendar_view   リリース    オンで予約状況をカレンダー表示にする  オフ  鈴木  2026-10-01  2026-12-15  全員に公開して1スプリント経過 作成
top_banner_ab   実験  オンでトップのお知らせ枠をB案にする  オフ  田中  2026-09-29  2026-10-27  4週間ぶんの集計が終わり採用案が決まる 一部公開(50%)
disable_export  運用  オンでCSV出力を一時的に止める    オフ  伊藤  2026-08-18  見直し 2027-02-18  常設(半年ごとに要否を見直す) 全公開
beta_org_report 権限  オンの組織だけ月次レポートを表示    オフ  高橋  2026-07-06  見直し 2027-01-06  全組織に提供する、または権限管理に移す 一部公開

段階3の DB テーブルを使っている場合は、テーブルそのものが台帳になります。期限を過ぎたフラグは SQL で一覧にできます(基準日を 2026-12-01 として実行)。

SELECT name, kind, owner, expires_on, DATE '2026-12-01' - expires_on AS overdue_days
FROM feature_flags
WHERE expires_on < DATE '2026-12-01'
ORDER BY expires_on;
     name      |    kind    | owner  | expires_on | overdue_days 
---------------+------------+--------+------------+--------------
 top_banner_ab | experiment | tanaka | 2026-10-27 |           35
 new_search    | release    | sato   | 2026-11-30 |            1
(2 rows)

運用ルールのひな形

ルール 内容
作るとき 台帳の全列を埋めてからコードを書く。削除の作業も同時に課題として登録する
名前 機能を表す英小文字の名前。一度使った名前を別の用途に使い回さない
既定値 設定が読めないとき・未定義のときはオフ(従来の動き)
分岐の場所 フラグを見る if は入口の1か所にまとめる。あちこちの関数で同じフラグを見ない
入れ子 フラグの中で別のフラグを見ない(組み合わせが増えるため)
切り替え いつ・誰が・何に変えたかを残す(設定ファイルならコミット、DBなら更新日時と操作ログ)
棚卸し 週1回または隔週で台帳を見て、期限が近いものを確認する
上限 同時に生きているリリースフラグの本数に上限を決める(例:チームで5本)

役目を終えたフラグの削除手順

削除は「コードが先、設定があと」の順で行います。順番を逆にして設定の行を先に消すと、未定義のフラグはオフと判定されるため、公開済みの機能が従来の動きに戻ります。

手順 やること 確かめること
1 台帳の「削除の条件」を満たしたか確認する 100%公開からの経過期間、問い合わせの件数など
2 フラグ名でコードを検索し、使っている箇所を洗い出す アプリ・テスト・設定・SQL のすべて
3 if 分岐を外し、オン側の処理だけを残す。オフ側の処理も消す オフ側でしか呼ばれない関数が残っていないか
4 オフ側のテストを消し、オン側のテストはフラグ指定なしで通す テストがすべて通る
5 デプロイする 動きが変わらないこと
6 設定ファイル・DBテーブルからフラグの行を消す 手順5のあとに行う
7 台帳の状態を「削除済み(日付)」にする 行は残す(名前の使い回しを避けるため)

手順2の検索は、次の1行で足ります。

grep -rn "new_search" --include='*.py' --include='*.ts' --include='*.json' --include='*.sql' --exclude-dir=node_modules .
./flags.json:2:  "new_search": {
./flags.ts:17:export type FlagName = "new_search" | "calendar_view";
./search_service.py:5:    if flags.is_enabled("new_search", user_id):
./test_flags.py:31:    ids=["new_search=off", "new_search=on"],
./schema.sql:17:('new_search',     'release',    true,  10,  '{u0007}', 'sato',   …
…(省略)…

手順3〜4のあと、search_service.py は次の形になります。引数の flags と user_id も不要になるので一緒に外します。

def search(items: list[str], keyword: str) -> list[str]:
    return [x for x in items if keyword in x]

フラグ名を文字列の組み立てで作らないこと("new_" + name など)も、この検索を確実にするための決まりです。

if 分岐で済ませるか、専用ライブラリを入れるか

自前の実装は、ここまで見てきたとおり数十行で書けます。一方、フラグの数や切り替える人が増えると、管理画面・変更履歴・複数言語への対応が欲しくなります。

そこで候補になるのが OpenFeature です。公式ドキュメントは OpenFeature を、フラグ管理ツールの種類によらず使える、ベンダーに依存しない API を定めたオープンな仕様と説明しています(Cloud Native Computing Foundation の incubating プロジェクト)。アプリのコードは共通の API を呼び、実際の判定は「プロバイダー」と呼ばれる部品がフラグ管理の仕組みへ橋渡しします。利用者IDなどの判定材料は「評価コンテキスト」として渡します。つまり、アプリ側の書き方を変えずに、裏のフラグ管理の仕組みを差し替えられるのが利点です。

条件 選ぶもの
フラグは数本。切り替えるのは開発者だけ 自前の if 分岐(段階1〜2)
再起動なしの切り替えが要るが、1つのアプリの中で完結する 自前の DB テーブル(段階3)
開発者以外が画面から切り替える。変更履歴や承認が要る フラグ管理のサービス・ツール
複数の言語・複数のサービスで同じフラグを見る フラグ管理のサービス・ツール+OpenFeature
将来フラグ管理の仕組みを替える可能性がある OpenFeature の API で呼び出しをそろえておく
実験フラグで、割り当てと集計を結び付けたい 実験の機能を持つサービス・ツール

自前で始める場合も、この記事のコードのように判定役を is_enabled(フラグ名, 利用者ID) という1つの入口にまとめておくと、あとからライブラリに差し替えるときに書き換える場所が1か所で済みます。

確かめて分かったこと

  • 未定義のフラグをオフにする作りでは、設定を先に消すと機能が元に戻ります。 削除は「コードが先、設定があと」の順にします。
  • "0" や "false" という環境変数は、そのまま if に渡すとオンになります。 受け付ける書き方を関数で決めておくと、FLAG_NEW_SEARCH=0 が期待どおりオフになりました。
  • Python の hash() は起動のたびに値が変わりました(96、5、65)。 段階公開には hashlib を使います。
  • 10% の指定で選ばれたのは 1000人中 104人でした。 ハッシュによる振り分けは、ぴったり10%ではなく、おおよその割合になります。人数が少ない段階では幅が出る前提で見ます。
  • 同じ式なら Python と TypeScript で同じ番号(u0001 は 62)になりました。 言語をまたいでも公開対象をそろえられます。
  • DB に届かないときの再試行に間隔を入れないと、判定のたびに接続を試みます。 最初の版では判定のたびに再読み込みを試みていたため、見送ったときも次の再読み込みを ttl 秒後にする1行を足しました。

よくある質問

Q. フィーチャーフラグとフィーチャートグルの違いは? A. 同じものを指します。機能フラグ、フィーチャースイッチと呼ばれることもあり、前掲の Pete Hodgson 氏の記事も題名に両方の呼び名を並べています。

Q. フィーチャーフラグと設定値の違いは? A. いずれ消す前提の分岐がフラグ、ずっと残る値が設定値です。タイムアウト秒数のように「値を変えるだけで分岐が増えない」ものは設定値として扱い、台帳には載せません。

Q. フラグはいつ消せばよいですか? A. 作るときに決めた「削除の条件」を満たしたら消します。リリースフラグなら「100%公開から2週間」のように期間で決めておき、期限を過ぎたらテストが知らせる仕掛けにしておくと残りません。

Q. フラグが増えてテストしきれないときは? A. すべての組み合わせではなく、本番で予定している設定・切り戻した設定・すべてオンの3通りに絞ります。あわせて、同時に生きているフラグの本数に上限を決めます。

Q. 段階公開の割合を広げると、対象の人は入れ替わりますか? A. 入れ替わりません。番号が割合より小さい人をオンにする方式なので、10% でオンだった人は 30% でもオンのままです(本文の実行結果で確認)。

まとめ

  • フィーチャーフラグは、デプロイとリリースを切り離すための条件分岐です。
  • 種類はリリース・実験・運用・権限の4つ。寿命と切り替える人が違うので、1つのフラグに2つの役割を持たせません。
  • 実装は環境変数 → 設定ファイル → DBテーブルと、必要になった分だけ育てます。
  • 段階公開は「フラグ名+利用者ID」のハッシュで決め、同じ人に同じ結果を返します。
  • テストはオンとオフの両方を同じ形で回し、期限切れのフラグを知らせるテストを1本足します。
  • 台帳には種類・担当者・削除期限・削除の条件を、作るときに書きます。
  • 消すときはコードが先、設定があと。オフ側の処理とテストも一緒に消します。

参考・出典

  • Pete Hodgson「Feature Toggles (aka Feature Flags)」martinfowler.com(2017年10月9日)https://martinfowler.com/articles/feature-toggles.html
  • OpenFeature 公式ドキュメント「Introduction」https://openfeature.dev/docs/reference/intro

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「フィーチャーフラグとは:4種類の使い分けと実装コード・管理台帳テンプレート」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?