Notes sur la construction de systèmes qui marchent et d'autres choses... peut-être ?

Encode l'invariant, ne le documente pas

· 5 min

La documentation, c'est un conseil. Une erreur de type, non.

C'est tout l'argument, mais il m'a fallu longtemps avant d'y croire vraiment. Pendant des années, j'ai écrit les règles. « Toujours valider le tenant id. » « Ne jamais stocker de l'argent dans un float. » « Utilise le helper de soustraction sûr. » Je les mettais dans les README, dans les commentaires de revue de code, dans la doc d'onboarding. Et les gens les cassaient quand même, pas par négligence, mais parce que personne ne relit un README avant d'écrire une ligne de code. Une règle qu'il faut retenir est une règle qu'on finira par oublier.

Du coup la question que j'ai commencé à me poser, c'est : est-ce que le compilateur peut la retenir à ma place ?

L'argent n'est jamais un float

Voici la règle sur laquelle tout le monde est d'accord et que la moitié du code viole. La solution n'est pas une doc. C'est un type que le compilateur accepte et un float qu'il rejette.

// illustrative
pub struct Money(i64); // minor units, e.g. cents

impl Money {
    pub fn from_minor(units: i64) -> Self {
        Money(units)
    }
}

fn charge(amount: Money) { /* ... */ }

Maintenant l'erreur ne compile plus.

// illustrative
charge(19.99);            // error: expected Money, found f64
charge(Money::from_minor(1999)); // fine

Il n'y a aucun float près de l'API. Tu ne peux pas passer 19.99 par accident, parce que la fonction ne l'accepte pas. La doc qui disait « l'argent n'est jamais un float » est maintenant redondante, et c'est tout l'intérêt. La meilleure documentation est celle que tu peux supprimer parce que le code la remplace.

Les montants ne peuvent pas faire d'underflow

La règle suivante est plus subtile, parce que la chose dangereuse a l'air parfaitement normale. Une soustraction, c'est juste -. Personne ne signale un - en revue. Mais un solde qui passe en négatif parce que deux débits se sont croisés, c'est un vrai incident, et l'opérateur qui l'a causé est le caractère le plus banal du clavier.

Le conseil habituel, c'est « pense à vérifier avant de soustraire ». Ce conseil ne vaut rien. Alors cache l'opérateur prêt à paniquer derrière un opérateur sûr, et arrête d'exposer la version brute.

// illustrative
impl Money {
    pub fn checked_sub(self, other: Money) -> Option<Money> {
        self.0.checked_sub(other.0).map(Money)
    }
}

Si Money n'implémente pas Sub, alors balance - amount ne compile pas. Il n'y a pas d'opérateur moins à attraper. La seule façon de soustraire est celle qui te rend un Option, et maintenant tu dois gérer le cas vide avant de pouvoir continuer.

// illustrative
let remaining = balance
    .checked_sub(amount)
    .ok_or(Error::InsufficientFunds)?;

L'underflow n'est pas devenu impossible parce que quelqu'un a été discipliné. Il est devenu impossible parce que le chemin dangereux n'a jamais été construit. Tu ne peux pas oublier de vérifier quand il n'y a rien d'autre que tu aies le droit de faire.

Chaque requête porte un tenant id

Les deux premiers cas concernent les types. Celui-ci concerne la construction, qui est la même idée pointée vers une couche différente. Dans un système multi-tenant, le péché capital, c'est une requête qui oublie à quel tenant elle appartient, parce que c'est comme ça qu'un client finit par lire les données d'un autre.

Tu peux écrire « chaque requête doit porter un tenant id » en haut de chaque fichier de handler. Les gens copieront un handler à qui il manque justement ce bout, et la règle s'évapore.

À la place, rends une requête nue impossible à construire. Le client partagé exige l'id, et le seul constructeur public le prend en paramètre.

// illustrative
type Request struct {
    tenantID string // unexported, no way to build a zero value from outside
    body     []byte
}

func NewRequest(tenantID string, body []byte) (*Request, error) {
    if tenantID == "" {
        return nil, errors.New("tenant id required")
    }
    return &Request{tenantID: tenantID, body: body}, nil
}

Il n'y a pas de littéral Request{} que tu puisses écrire depuis un autre package, parce que le champ n'est pas exporté. La seule porte d'entrée vers le type vérifie l'invariant au passage. Même mouvement côté serveur : un interceptor dans la bibliothèque partagée lit l'id sur chaque appel entrant et rejette ceux qui n'en ont pas, donc un handler ne peut pas tourner sur une requête sans scope, même si quelqu'un en a écrit une.

// illustrative
func TenantInterceptor(next Handler) Handler {
    return func(ctx context.Context, r *Request) error {
        if r.tenantID == "" {
            return status.Error(codes.InvalidArgument, "missing tenant id")
        }
        return next(WithTenant(ctx, r.tenantID), r)
    }
}

Maintenant la règle vit à un seul endroit, tourne sur chaque requête, et ne peut pas être contournée en copiant le mauvais fichier. Oublier n'est plus une option.

Le motif derrière les trois

Chaque exemple remplace une phrase par une structure. « L'argent n'est jamais un float » devient un type qui rejette les floats. « Les montants ne peuvent pas faire d'underflow » devient un opérateur absent. « Chaque requête porte un tenant id » devient un constructeur et un interceptor qui refusent de produire le mauvais état.

Le fil commun, c'est que la mauvaise chose doit cesser d'être atteignable. Pas découragée, pas signalée en revue, pas écrite quelque part. Inatteignable. Si la seule façon d'exprimer l'opération est la bonne, alors la correction n'est plus quelque chose que quelqu'un doit retenir.

Ça coûte plus cher au départ. Un newtype, c'est plus de travail qu'un commentaire. Un constructeur, c'est plus de travail qu'un littéral de struct. Un interceptor, c'est plus de travail qu'une note dans le wiki. Tu paies ce coût une fois, quand tu construis la barrière. La doc a l'air moins chère parce que son coût est caché : il arrive plus tard, réparti sur chaque personne qui l'ignore, dans des incidents que personne ne relie au paragraphe jamais lu.

Une règle qui ne vit que dans la prose se fait ignorer comme la doc se fait ignorer, parce que c'est une doc. Mets-la là où elle ne peut pas être ignorée. Un build qui échoue se fiche que tu sois pressé.