Aller au contenu
Lecture : 0 %

Feature flags : la dette invisible

Pourquoi chaque flag double l'espace des états que vous n'avez jamais testés, et la discipline minimale (type, propriétaire, échéance) pour qu'un flag meure à la date prévue.

Par Elias Varen7 min de lecture

Vingt flags booléens dans une application, cela fait 2^20 configurations possibles, soit 1 048 576. La CI en exécute une, parfois deux. La production en exécute autant que vous avez de tenants réglés différemment. Entre ces deux nombres, il y a du code qui tourne chez des clients sans que personne ne l'ait jamais vu tourner.

Un feature flag est un if dont la condition vit hors du code. C'est ce qui le rend utile, puisqu'on déploie sans activer et qu'on active sans déployer. C'est aussi ce qui le rend invisible : le compilateur ne le voit pas, la revue de code ne voit que la branche qu'on lui montre, et le jour où plus personne ne sait pourquoi il existe, il n'y a aucune erreur, aucun test rouge, rien. La dette d'un flag reste muette tant qu'on ne touche pas à l'interrupteur.

Release, expérience, ops, droit d'accès : quatre espérances de vie

Le premier défaut que je vois dans les systèmes de flags, c'est une seule table, un seul mécanisme, et dedans quatre choses qui n'ont rien à voir.

TypeÀ quoi il sertDurée de vie attendueQui le bascule
ReleaseLivrer du code inachevé sans l'exposerQuelques jours à quelques semainesL'équipe qui développe
ExpérienceComparer deux variantesLe temps de la mesureLe produit
OpérationnelCouper une fonction coûteuse en incidentPermanent, mais rarement utiliséL'astreinte
Droit d'accèsOuvrir une fonction à un plan ou un tenantPermanentLe commerce, la facturation

Les deux premiers sont temporaires par nature, et leur état final est d'être supprimés. Les deux derniers sont permanents, et le quatrième relève en réalité du modèle métier. Un droit lié à un abonnement doit vivre avec l'abonnement, être facturable, auditable, et survivre à un changement d'outil de flags. Le ranger dans la même table que « nouveau_tunnel_paiement_v2 » garantit qu'un jour quelqu'un fera le ménage dans les vieux flags et retirera une fonction payée par un client.

Mélanger les types a une conséquence mécanique. Comme certains flags sont légitimement éternels, plus personne ne peut dire d'un flag ancien s'il est oublié ou voulu, et l'âge cesse d'être un signal.

La combinatoire que personne ne teste

Chaque flag multiplie par deux le nombre de chemins. En pratique, tous les flags n'interagissent pas, et c'est ce qui sauve la plupart des équipes. Mais l'absence d'interaction reste une hypothèse, fausse dès que deux flags touchent le même flux : le nouveau calcul de taxe et le nouveau format de facture, le nouvel écran d'inscription et la nouvelle vérification d'adresse mail.

Avec dix flags actifs sur le tunnel de commande, on a 1 024 variantes du tunnel. La suite de tests fixe une valeur pour chacun, généralement celle de l'environnement de développement, où tout est activé. La production tourne avec une autre combinaison, et chaque tenant en accès anticipé en ajoute une.

Dans un SaaS multi-tenant, le problème gagne une dimension. Un flag par tenant devient une colonne de configuration plus qu'un interrupteur. Avec 300 tenants et 12 flags activables par tenant, rien n'empêche d'avoir 300 produits différents en production. Le support reçoit un ticket, et la première question devient « quelle version de l'application ce client voit-il ? ».

Je ne connais pas de moyen de tester toutes les combinaisons. On peut en revanche réduire l'espace :

  • en limitant le nombre de flags temporaires vivants en même temps sur un même flux (chez nous, trois par contexte, et le quatrième attend) ;
  • en testant explicitement les deux états de chaque flag de release, tous les autres étant à leur valeur de production plutôt qu'à celle du développement. Cela fait 2n exécutions au lieu de 2^n, et couvre le cas qui casse le plus souvent, le chemin désactivé que plus personne n'entretient.

La branche morte qu'on réveille en incident

Soit un flag de release qui protège un nouveau moteur de calcul. Il est activé pour tout le monde depuis huit mois. L'ancien chemin est toujours dans le code, derrière le else. Entre-temps, le schéma a changé, une colonne a été renommée, un service appelé par l'ancien chemin a changé de contrat. Rien de tout cela n'a cassé un test, puisque les tests tournent flag activé.

Survient un incident sans rapport. Quelqu'un cherche un levier, voit le flag, pense « rollback » et le désactive. L'ancien code s'exécute pour la première fois depuis huit mois, contre un schéma qu'il ne connaît pas. Il y avait un incident, il y en a deux, et le second écrit des données fausses.

Un flag de release laissé en place ressemble à un coupe-circuit, mais à un coupe-circuit qui n'a jamais été essayé. Un vrai flag opérationnel se teste, en bascule réelle, à intervalle régulier ; un flag de release oublié promet un rollback que personne n'a vérifié.

La variante sournoise est la réutilisation de nom. Un flag supprimé du code mais pas du stockage, puis un nouveau flag créé avec le même identifiant, et celui-ci naît dans l'état de son prédécesseur, activé pour des tenants que personne n'a choisis.

Une échéance que la CI fait respecter

La seule discipline qui tient dans la durée est celle que la CI fait respecter. Je déclare les flags dans le code plutôt que dans une interface d'administration, avec leur type, leur propriétaire et, pour les temporaires, une échéance.

PHP
enum FlagKind { case Release; case Experiment; case Ops; }

#[Attribute(Attribute::TARGET_CLASS_CONSTANT)]
final class Flag
{
    public function __construct(
        public FlagKind $kind,
        public string $owner,
        public ?string $expires = null,
    ) {}
}

enum Feature: string
{
    #[Flag(FlagKind::Release, owner: 'billing', expires: '2026-11-15')]
    case NewInvoiceLayout = 'new_invoice_layout';

    #[Flag(FlagKind::Ops, owner: 'platform')]
    case DisablePdfExport = 'disable_pdf_export';
}

Un test parcourt les cas de l'enum par réflexion et échoue quand un flag Release ou Experiment n'a pas d'échéance, ou quand elle est dépassée. Le build rouge est désagréable, et c'est voulu, car prolonger une échéance devient un commit signé par quelqu'un au lieu d'un oubli.

Le dispositif se complète de quelques règles :

  • la PR qui introduit un flag de release contient déjà le ticket de suppression, assigné ;
  • supprimer un flag, c'est supprimer la branche morte, ses tests, et la ligne dans le stockage, dans cet ordre ;
  • un flag inconnu du code mais présent dans le stockage fait échouer le démarrage en préproduction.

Les droits d'accès sortent complètement de ce mécanisme et vont dans le modèle d'abonnement.

Une demi-journée par flag, et les cas où elle ne rapporte rien

Cette discipline a un prix. Chaque flag de release coûte deux PR au lieu d'une, une double exécution de tests, et une interruption quand l'échéance tombe au mauvais moment. Sur une équipe de cinq personnes, il faut compter une demi-journée par flag sur l'ensemble de sa vie, à comparer avec ce que le flag achète.

Dans plusieurs cas il n'achète rien, et je refuse alors d'en poser un :

  • le changement est petit et réversible par un déploiement. Si vous déployez en dix minutes, le rollback par déploiement est aussi rapide qu'un flag, et il est testé à chaque livraison ;
  • le changement touche le schéma ou les données. Un flag ne protège pas une migration. Les données écrites par le nouveau chemin restent quand on désactive ; l'ancien chemin doit savoir les lire, et c'est rarement vérifié ;
  • le flag sert à repousser une décision produit. « On le met derrière un flag et on verra » produit un flag permanent sans propriétaire ;
  • la personnalisation par client. Un flag par tenant pour satisfaire une demande particulière est un fork déguisé. Soit c'est une option du produit, modélisée et documentée, soit la réponse est non.

Les questions avant d'écrire le if

Si l'une de ces questions reste sans réponse, le flag n'est pas prêt.

  • De quel type est-il ? S'il s'agit d'un droit d'accès, il va dans le modèle métier.
  • Qui en est propriétaire, nommément quelle équipe ?
  • À quelle date est-il supprimé, et le ticket existe-t-il ?
  • Les deux états sont-ils testés avec les autres flags à leur valeur de production ?
  • Combien de flags temporaires vivent déjà sur ce flux ? Au-delà de trois, on en retire un d'abord.
  • Si on le désactive dans six mois, l'ancien chemin fonctionnera-t-il encore ? Si personne ne sait répondre, il ne sert pas de coupe-circuit et doit disparaître dès l'activation complète.
  • Le changement écrit-il des données que l'ancien chemin ne sait pas lire ? Dans ce cas le flag n'offre aucun rollback, et l'astreinte doit le savoir.
Feature flags : la dette invisible · Deepstack