Một team nghe “feature flag” xong liền tạo một file flags.json trong repo, rồi đọc nó lúc boot, giống hệt cách đọc .env. Khi PM muốn bật tính năng cho một khách hàng cụ thể, dev phải sửa JSON, commit, deploy. Khi muốn tắt khẩn cấp, cũng phải deploy. Vài tháng sau, file JSON phình ra 80 flag, không ai dám xoá cái nào vì “biết đâu cần rollback”, code rải rác if (flags.FEATURE_X) khắp nơi. Team bắt đầu gọi feature flag là “nguồn phát sinh bug mới”.

Vấn đề không phải ở feature flag, mà ở chỗ team đang dùng nó như runtime config. Hai thứ này trông giống nhau bề ngoài (đều là “đọc giá trị rồi rẽ nhánh code”) nhưng khác nhau về bản chất: mục đích, vòng đời, người sở hữu, cách đo lường, và quan trọng nhất, cách dọn dẹp sau khi xong.

Bài này phân biệt rõ hai khái niệm, cách dùng từng loại flag trong project thật, và đặc biệt nói về phần mà hầu hết team bỏ quên: xoá flag sau khi xong việc.


Runtime configuration là gì

Runtime config là các tham số mà hệ thống cần để chạy, ổn định, ít thay đổi, và thường áp dụng cho toàn bộ service. Ví dụ điển hình: database URL, Redis connection string, timeout value, retry count, giới hạn quota mặc định, mode môi trường (dev/staging/prod).

Đặc điểm chung của runtime config: thường đọc một lần lúc boot hoặc reload, ít người đụng tới (thường là DevOps hoặc SRE), tập hợp giá trị nhỏ và dễ quản lý, đổi giá trị thường cần restart service hoặc dùng tool reload. Nơi lưu trữ thường là .env file, ConfigMap/Secret trong Kubernetes, Vault cho sensitive config, AWS Parameter Store hoặc GCP Secret Manager.

Xem runtime config là “cấu hình máy”, giống như bạn chỉnh nhiệt độ điều hoà: set một lần rồi để yên, thỉnh thoảng ai đó đến chỉnh lại. Không ai chỉnh nhiệt độ khác nhau cho từng người ngồi trong phòng, nó apply cho cả phòng.


Feature flag là gì, và tại sao nó khác

Feature flag là quyết định sản phẩm có thể thay đổi trong lúc chạy, thường khác nhau theo từng user hoặc context. Bật tính năng mới cho 5% user để test trước khi rollout toàn bộ. Cho tenant enterprise thấy tab “Analytics” mà tenant free không thấy. Tắt khẩn cấp một endpoint khi downstream service đang quá tải. A/B test giao diện checkout mới để đo conversion.

Khác với config, flag được đánh giá mỗi request (hoặc mỗi session), kết quả có thể khác nhau tuỳ user đang login. Thay đổi không cần deploy, PM hoặc engineer bật/tắt qua dashboard, hiệu lực ngay lập tức (hoặc trong vài giây). Và quan trọng nhất: flag đi kèm đo lường, bạn bật flag để đo xem metric có cải thiện không, rồi quyết định giữ hay rollback.

Nếu runtime config là “nhiệt độ điều hoà cho cả phòng” thì feature flag là “từng người có quạt riêng, bật tắt tuỳ ý, và bạn đo xem ai dùng quạt thì làm việc hiệu quả hơn”.

So sánh nhanh

Khía cạnhConfigFeature flag
Đối tượng thay đổiSRE, ít ngườiPM, eng, rollout owner
Phạm viToàn dịch vụPer-user/tenant/request
Tần suất đổiThấpCao, hằng ngày
Vòng đờiDàiNgắn, phải xoá sau khi xong
LưuConfigMap, VaultFlag service
Có SDK targeting?Không

Lẫn hai khái niệm này thì hoặc config dài dòng với targeting lung tung (vì bạn cố nhồi logic per-user vào .env), hoặc flag không bao giờ xoá (vì bạn coi nó như config vĩnh viễn). Cả hai đều dẫn đến nợ kỹ thuật.


Bốn loại feature flag

Không phải mọi flag đều giống nhau. Phân loại của Pete Hodgson (trên martinfowler.com) chia flag thành bốn họ, mỗi họ có vòng đời và người sở hữu khác nhau. Phân loại này rất thực dụng vì nó quyết định cách bạn viết code, cách test, và quan trọng nhất, khi nào phải xoá flag.

Release toggle

Đây là loại flag phổ biến nhất mà hầu hết team bắt đầu dùng. Mục đích là tách deploy khỏi release, merge code vào main, deploy lên production, nhưng tính năng mới vẫn tắt. Khi ready, bật dần cho 1%, 5%, 20%, 100%.

Vòng đời của release toggle phải ngắn, vài ngày đến vài tuần. Khi đã rollout 100% và ổn định, xoá flag và xoá code nhánh cũ là bắt buộc. Đây là bước mà hầu hết team bỏ quên, và sẽ nói kỹ hơn ở phần sau. Người sở hữu thường là engineer viết feature.

Experiment toggle

Dùng cho A/B test, chia user random theo nhóm, đo metric (conversion, revenue, engagement) xem variant nào thắng. Vòng đời trung bình, phải chạy đủ lâu để kết quả có ý nghĩa thống kê, thường 1-4 tuần tuỳ traffic.

Người sở hữu thường là PM hoặc data team. Khi có kết luận, áp dụng winner cho tất cả user rồi xoá flag. Sai lầm phổ biến: chạy experiment nhưng không đặt stop date, kết quả không rõ ràng nhưng cứ để đó, flag sống mãi.

Ops toggle (kill-switch)

Dùng cho điều khiển vận hành, tắt tính năng đắt tiền khi downstream hỏng, bật mode degrade, circuit breaker. Đây là loại flag có vòng đời dài nhất, có thể tồn tại vĩnh viễn vì nó là cơ chế an toàn cho production.

Người sở hữu là SRE hoặc on-call engineer. Ops toggle cần đáp ứng nhanh, bật/tắt phải có hiệu lực trong giây, không phải phút. Và phải có runbook kèm theo: ai được bật, khi nào bật, bật bao lâu, rollback thế nào.

Permission toggle

Dùng để phân quyền theo plan, tenant, hoặc beta program, tenant trả tiền cao thấy feature X, tenant free thì không. Vòng đời dài, gắn với product tier. Người sở hữu là product hoặc billing team.

Permission toggle thực ra gần với authorization hơn là feature flag, nếu logic phức tạp, nên xử lý ở tầng authz (RBAC/ABAC) thay vì nhồi vào flag system.

Nhầm loại là nguồn bug

Hầu hết vấn đề phát sinh khi team nhầm loại: dùng release toggle như permission (không xoá, để mãi), dùng permission như ops (dư thừa logic rẽ nhánh), dùng experiment như release (không đo metric). Khi tạo flag mới, bước đầu tiên nên là xác định nó thuộc loại nào, quyết định này ảnh hưởng đến mọi thứ sau đó.


Kiến trúc triển khai

Các thành phần cơ bản

Một hệ thống feature flag đầy đủ gồm bốn phần. Flag store lưu rule và giá trị, có thể là database, Redis, hoặc SaaS provider. Control plane / UI là nơi PM hoặc engineer sửa flag, xem audit log, quản lý vòng đời. SDK/Client trong app đánh giá flag dựa trên user context. Event pipeline ghi lại mỗi lần flag được evaluate để debug và đo lường.

Không nhất thiết phải dùng SaaS đắt tiền từ đầu. Team nhỏ có thể bắt đầu với bảng flag trong database + admin UI đơn giản. Nhưng khi số flag và team tăng lên, tool chuyên dụng (LaunchDarkly, Unleash, Flagsmith) tiết kiệm effort đáng kể vì đã giải quyết sẵn targeting, audit, SDK performance.

Server-side vs client-side evaluation

Đây là quyết định kiến trúc quan trọng. Server-side evaluation an toàn hơn, client không biết flag nào tồn tại, không thể bypass, quyết định dựa vào user context tin cậy từ server. Đây là default nên chọn.

Client-side evaluation (web/mobile SDK) nghĩa là rule phải gửi đến client. Ai inspect bundle JavaScript hoặc decompile app đều thấy tên flag và có thể đoán logic. Tuyệt đối không để quyền bảo mật (admin access, payment feature) phụ thuộc vào flag client-side, user có thể override.

Pattern tốt nhất: server trả về kết quả (bật/tắt) cho mỗi flag, không phải rule. Client chỉ biết “tính năng X: on” mà không biết logic quyết định phía sau.

Caching và performance

Mỗi request evaluate flag không được gọi API đến SaaS bên ngoài, latency thêm vài chục đến vài trăm ms là không chấp nhận được. SDK tốt sẽ stream rule xuống local rồi evaluate in-process, LaunchDarkly, Unleash đều làm vậy.

Ngoài ra cần cache kết quả per-user trong cùng request để tránh tính lại nhiều lần. Và quan trọng nhất: hệ thống phải ổn khi flag service chết, default value rõ ràng cho mỗi flag, SDK failsafe trả default khi không kết nối được provider.

Hay gặp incident vì flag service timeout 5 giây, toàn bộ request chờ evaluate flag rồi mới xử lý tiếp. SDK cần có timeout ngắn (vài trăm ms max) và fallback ngay, flag service down không được kéo app chết theo.

Consistency trong một session

Một user trong cùng session không nên thấy flag lật đi lật lại, lần đầu thấy UI mới, refresh thấy UI cũ, refresh lần nữa lại UI mới. Dùng hash theo user_id để đảm bảo sticky: cùng user luôn nhận cùng kết quả cho đến khi rule thay đổi có chủ đích.

Khi đổi rule (từ 5% → 10%), user đã trong nhóm “bật” không bị văng ra, chỉ thêm user mới vào. Đây gọi là sticky bucketing và hầu hết flag SDK đã hỗ trợ sẵn.


Targeting: bật cho ai

Targeting là phần khiến feature flag khác biệt hoàn toàn so với config. Thay vì “bật cho tất cả hoặc tắt cho tất cả”, bạn có thể kiểm soát chính xác ai thấy gì.

Một rule flag thực tế có thể trông như thế này:

flag: new_checkout_ui
default: off
rules:
  - if: user.tenant in ["acme", "globex"]
    value: on
  - if: user.country == "VN" and random(user.id) < 0.1
    value: on
  - value: off

Các khoá targeting phổ biến: user.id cho per-user rollout phần trăm, user.tenant hoặc user.org cho rollout theo khách hàng enterprise, user.country cho rollout theo vùng địa lý, user.plan cho phân biệt free/pro/enterprise, env cho bật riêng dev/staging/prod.

Một nguyên tắc quan trọng: giữ context tối thiểu đủ để rule chạy. Đừng đẩy PII (email, phone, tên thật) vào context, ngoài vi phạm privacy, log evaluate của flag service sẽ chứa PII, và khi cần comply GDPR thì phải purge data ở thêm một chỗ nữa.


Đo lường: flag không có metric là flag mù

Bật flag mà không đo thì không khác gì “deploy rồi cầu nguyện”. Đo lường là lý do flag tồn tại, để bạn biết thay đổi có tốt hơn không trước khi commit cho tất cả user.

Exposure logging

Mỗi lần flag evaluate cho một user, ghi lại (async, không block request):

{
  "user_id_hash": "a1b2c3",
  "flag": "new_checkout_ui",
  "variant": "on",
  "ts": 1712800000
}

Gửi vào data warehouse (BigQuery, Snowflake) hoặc analytics pipeline. Đây là nền tảng để so sánh metric giữa nhóm “on” vs “off”, không có exposure log thì không A/B test được.

Metric gắn với flag

Trong Prometheus, không thêm flag làm label metric, nếu flag có nhiều variant thì cardinality tăng, series phình. Tốt hơn: log exposure ở data pipeline, join với event (conversion, revenue, click) offline trong warehouse. Phân tích A/B trong warehouse chính xác hơn và không ảnh hưởng monitoring system.

Guardrail metrics

Khi rollout, ngoài metric mục tiêu (conversion, revenue) cần theo dõi guardrail: error rate, latency P99, crash rate. Nếu guardrail vượt ngưỡng thì tự động rollback, dù metric mục tiêu có tốt hơn cũng không được sacrifice stability.

Nhiều flag service hỗ trợ automated rollback khi guardrail alert. Đây là tính năng bắt buộc cho production rollout, rollback thủ công lúc 2 giờ sáng là rủi ro không cần thiết.


Pattern code sạch với flag

Cách viết code flag quyết định bạn sẽ dễ hay khó xoá flag sau này. Có hai pattern hoạt động tốt.

Wrapper / gate pattern

Thay vì rải if (flag.enabled("new_checkout")) khắp nơi trong code, tập trung logic rẽ nhánh vào một chỗ:

class CheckoutService {
  checkout(ctx: Ctx, cart: Cart): Result {
    if (this.flags.isOn("new_checkout", ctx.user)) {
      return this.newCheckout(ctx, cart);
    }
    return this.oldCheckout(ctx, cart);
  }
}

Tách branch vào method riêng, khi xoá flag sau này, chỉ cần xoá method cũ và remove if. Nếu if rải 15 chỗ trong code thì xoá flag là nightmare.

Có quy tắc: mỗi flag chỉ nên xuất hiện ở một hoặc hai chỗ trong codebase. Nếu bạn thấy cùng flag check ở 5 file khác nhau, refactor lại trước khi merge.

Strategy / DI

Khi flag có nhiều variant (không chỉ on/off) hoặc là permission toggle lâu dài:

const strategy = flags.variant("checkout_algo", ctx.user);
const impl = checkoutStrategies[strategy] ?? checkoutStrategies.default;
impl.run(cart);

Mỗi variant là một implementation class, thêm variant mới không cần sửa code cũ, xoá variant cũ chỉ cần remove class.

Test với flag

Unit test cho cả hai nhánh, mock flag on và mock flag off, chạy cả hai. Integration test cũng vậy: matrix {flag=on, flag=off}. Nếu flag là release toggle sắp xoá trong tuần, không cần sa đà viết quá nhiều test cho nhánh cũ, nó sẽ bị xoá.

Nên viết test case cho nhánh mới kỹ hơn nhánh cũ, vì nhánh mới là thứ sẽ tồn tại lâu dài sau khi flag bị xoá.


Vòng đời flag và chống tech debt

Đây là phần quan trọng nhất của bài này, và cũng là phần mà gần như mọi team đều làm kém: xoá flag sau khi xong việc.

Vấn đề: không ai xoá

Flag dễ tạo, khó xoá. Tạo flag mất 5 phút. Xoá flag cần: verify đã 100% production đủ lâu, remove code nhánh cũ, update test, xoá exposure logging, cập nhật tài liệu. Effort gấp 10 lần tạo. Kết quả: flag tích tụ.

Hay gặp những project có hơn 200 flag, nhưng chỉ khoảng 30 flag thực sự “sống”, 170 flag còn lại là zombie: 100% on hoặc 100% off từ nhiều tháng, code nhánh cũ vẫn nằm đó, không ai dám xoá vì “biết đâu cần rollback”. Mỗi lần refactor phải đọc hiểu cả code nhánh cũ lẫn mới, test matrix phình to, engineer mới join team mất thêm ngày chỉ để hiểu “flag này bật hay tắt, cho ai”.

Đặt ngày hết hạn

Mỗi flag tạo mới phải có ownerexpected removal date trong metadata. Flag service tốt cho phép set expiry, quá hạn thì alert. Convention hợp lý: release toggle có deadline 30 ngày sau khi rollout 100%, experiment toggle có deadline = ngày kết luận + 7 ngày buffer.

Báo cáo flag health

Định kỳ (nên chạy weekly) review dashboard: flag nào đã 100% on/off liên tục hơn 30 ngày? Flag nào quá expected removal date? Flag nào không có owner (người tạo đã rời team)? Đây đều là ứng viên xoá.

CI linter cũng hữu ích: quét code tìm flag name, nếu flag đã bị xoá khỏi flag store mà code vẫn reference thì fail build.

Quy trình xoá rõ ràng

Quy trình 5 bước cho mỗi flag xoá:

Xác nhận flag ở 100% (hoặc 0%) trong production đủ lâu, ít nhất 2 tuần không ai đụng. Xoá flag khỏi flag store hoặc đánh dấu archive. Tạo PR: remove if/else, giữ nhánh mới (hoặc xoá cả hai nếu flag 0%), xoá code cũ. Remove exposure logging và test liên quan đến flag. Cập nhật tài liệu nếu có mention flag.

PR xoá flag thường là PR dễ review nhất, chỉ xoá code, không thêm logic mới. Nhưng đó cũng là lý do nó hay bị deprioritize: “có task quan trọng hơn”. Vì vậy nên đưa nó vào definition of done của feature: feature chưa xong nếu flag chưa được xoá.

Hạn mức flag

Một trick hiệu quả: đặt quota “không quá N flag sống cùng lúc cho team X”. Khi chạm trần, buộc phải dọn flag cũ trước khi tạo mới. Nghe cứng nhắc nhưng nó tạo áp lực lành mạnh để team giữ flag count thấp.


Khi nào không cần feature flag

Flag không phải viên đạn bạc, mỗi flag là một trạng thái bạn phải test, log, và sau này xoá. Nếu giá trị mang lại nhỏ hơn chi phí quản lý, đừng dùng.

Thay đổi cực nhỏ, đổi text label, fix bug một dòng, update dependency, deploy thẳng. Thêm flag cho thay đổi trivial chỉ tạo overhead mà không giảm rủi ro gì.

Migration backend không có user-visible change, refactor internal code, đổi implementation mà API không đổi, nếu test tốt thì deploy thẳng. Flag thêm complexity cho thứ user không nhìn thấy.

Thử nghiệm ở staging/test environment, dùng env config là đủ, không cần flag system. Flag service chủ yếu có giá trị ở production, nơi bạn cần kiểm soát rollout cho user thật.


Feature flag cho database migration

Đây là một ứng dụng mà flag thực sự toả sáng, expand-migrate-contract pattern cho schema migration an toàn:

Bước 1: deploy code mới có flag (off). Code mới biết ghi cả schema cũ và mới (dual-write), nhưng flag tắt nên vẫn dùng schema cũ.

Bước 2: bật flag cho internal team, bắt đầu dual-write. Backfill dữ liệu từ schema cũ sang mới.

Bước 3: bật flag rộng hơn, đọc từ schema mới, vẫn ghi cả hai. Verify data consistency.

Bước 4: flag 100%, stop ghi schema cũ. Monitor vài tuần.

Bước 5: drop schema cũ. Xoá flag.

Flag cho phép bật từng bước, rollback dễ dàng ở bất kỳ bước nào, migration an toàn hơn hẳn so với “deploy one shot rồi cầu nguyện”. Pattern này đã được dùng cho nhiều migration lớn (đổi từ single table sang partitioned, đổi schema billing) và giảm stress đáng kể.


Cạm bẫy hay gặp

Dùng flag cho bảo mật. Flag là UX toggle, không phải quyền. User có thể inspect client-side flag, modify request, bypass. Dùng authorization đúng cách (RBAC/ABAC) ở server, flag chỉ quyết định UI hiển thị gì, không quyết định user có quyền gì.

Flag tạo spaghetti state. 5 flag đồng thời cho cùng một luồng code = 32 tổ hợp trạng thái. Trong thực tế không phải tất cả tổ hợp đều xảy ra, nhưng đủ để QA “không biết user đang ở cấu hình nào”. Hạn chế số flag đồng thời ảnh hưởng cùng một feature, nên giữ dưới 3.

Không có audit log. Khi incident xảy ra, câu hỏi đầu tiên thường là “ai đổi flag gì lúc nào?” Nếu flag service không log mọi thay đổi (who, what, when, old value, new value), bạn đang điều tra mù. Audit log là tính năng bắt buộc, không phải nice-to-have.

Client vs server không đồng bộ. Mobile app cache flag từ session trước, user mở app lên vẫn thấy flag cũ cho đến khi SDK refresh. Nếu bạn đổi rule flag mà không tính đến grace period cho client cũ, có thể gây UX inconsistent. Đặc biệt khi flag liên quan đến API contract, client gửi request theo logic cũ nhưng server đã đổi logic mới.


Tóm tắt

Runtime config và feature flag khác nhau về mục đích, vòng đời, và người sở hữu. Config là tham số hệ thống, ổn định, ít đổi. Flag là quyết định sản phẩm, per-user, thay đổi thường xuyên. Trộn hai khái niệm là cách nhanh nhất để tạo ra code khó maintain.

Bốn loại flag (release, experiment, ops, permission) có vòng đời riêng, release toggle phải xoá sau vài tuần, ops toggle có thể sống mãi, experiment toggle sống đến khi có kết luận. Biết loại flag trước khi tạo giúp bạn viết code đúng và dọn dẹp đúng lúc.

Server-side evaluation là default an toàn. Targeting dựa trên context tối thiểu, sticky bucketing cho consistency. Exposure logging và guardrail metrics để rollout an toàn, bật flag mà không đo thì chỉ là “deploy rồi cầu nguyện” có thêm bước trung gian.

Wrapper/strategy pattern giúp code sạch và dễ xoá flag. Quản lý vòng đời bằng owner, deadline, và quota, không có kỷ luật thì flag thành nợ kỹ thuật nhanh hơn bạn nghĩ.

Feature flag đúng cách là công cụ giảm rủi ro. Dùng sai thì nó là nguồn bug mới và code chết tích tụ. Sự khác biệt nằm ở kỷ luật dọn dẹp, không phải ở tool bạn dùng, dù SaaS đắt tiền hay database table đơn giản.


Tham khảo