Aller au contenu

Les règles métier

L’étape 7 a posé un garde : check_in refuse de rendre un exemplaire qui n’est jamais sorti. Un garde surveille une transition.

Une règle métier surveille une écriture. « Un prêt ne peut pas être dû avant sa sortie » n’est pas une transition : c’est vrai à la création, vrai à la modification, vrai quand la donnée arrive par un import, par un flux ou par un script de reprise. Un garde ne dit rien de ces chemins.

Le geste

modules/library/rules.py :

"""Business invariants of the library module (ADR 0093)."""
from __future__ import annotations
from typing import Any
from kewos.validation import ValidationError, register_validator
def _due_after_taken(_session: Any, _classes: Any, loan: Any) -> None:
"""A loan cannot be due before it was taken out."""
if loan.taken_on is None or loan.due_on is None:
return
if loan.due_on < loan.taken_on:
raise ValidationError(
"A loan cannot be due before it was taken out.",
field="due_on",
key="library.error.due_before_taken",
)
def register_rules() -> None:
"""Declare the invariants. Idempotent: the key is (model, name)."""
register_validator("library_loan", "due_after_taken", _due_after_taken)

Et dans __init__.py, une ligne à côté des modèles :

def register(registry: Registry) -> None:
models.register_models(registry)
rules.register_rules() # ← ici
apply_module_acls(registry, _MODULE_DIR / "acl.json")

Une règle est une fonction de (session, classes, enregistrement). Les deux premiers paramètres servent aux règles qui ont besoin d’interroger la base ; celle-ci n’en a pas besoin et les ignore.

Ce que vous gagnez en la déclarant là

Fenêtre de terminal
curl -X POST http://127.0.0.1:8080/api/v1/library_loan \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"copy_id":2,"member_id":1,"taken_on":"2026-09-06","due_on":"2026-08-30"}'
HTTP/1.1 422 Unprocessable Entity
{"detail":{"field":"due_on","reason":"A loan cannot be due before it was taken out."}}

Le nom du champ est dans la réponse, donc l’écran sait où afficher le message, sous le bon contrôle, sans que personne n’ait écrit de code frontend.

Et surtout : la règle ne tourne pas dans l’endpoint. Elle tourne dans la session ORM, au moment où l’enregistrement part en base. Le même refus vient donc de l’import, du moteur de flux, d’un script de reprise et de l’API. C’est l’interdiction n°1 du projet appliquée à la validation : une règle posée dans un contrôleur HTTP ne protège que les appels qui passent par ce contrôleur, et il y en a toujours d’autres.

Une règle qui a besoin de la base

Le même exemplaire ne peut pas être sorti deux fois. Là, il faut regarder les autres prêts :

def _copy_lent_once(session: Any, classes: Any, loan: Any) -> None:
"""The same copy cannot be out twice at the same time."""
if loan.state != "out" or loan.copy_id is None:
return
loan_class = classes["library_loan"]
already_out = (
session.query(loan_class)
.filter(
loan_class.copy_id == loan.copy_id,
loan_class.state == "out",
loan_class.id != (loan.id or -1),
)
.first()
)
if already_out is not None:
raise ValidationError(
"This copy is already out on another loan.",
field="copy_id",
key="library.error.copy_already_out",
)

classes donne la classe mappée d’un modèle par son nom, et session est celle de l’écriture en cours : la règle voit ce que la transaction a déjà écrit, et son refus annule tout, pas seulement sa propre ligne.

Traduire le message

ValidationError prend une clé en plus du message. La clé est résolue dans la langue de la requête, et le message anglais reste le repli quand la clé n’est pas traduite.

La clé va dans translations/, pas dans i18n/. Ce sont deux dossiers différents, avec deux publics :

DossierQui le litCe qu’on y met
i18n/<locale>.jsonl’interfaceles libellés des modèles, des champs, des menus, des boutons
translations/<locale>.jsonle serveurles messages que le serveur produit, dont ceux des règles

modules/library/translations/fr.json :

{
"library.error.due_before_taken": "Un prêt ne peut pas être dû avant sa sortie.",
"library.error.copy_already_out": "Cet exemplaire est déjà sorti sur un autre prêt."
}

La même requête, avec Accept-Language: fr :

{"detail":{"field":"due_on","reason":"Un prêt ne peut pas être dû avant sa sortie."}}

Mettre la clé dans i18n/ ne produit aucune erreur : le serveur ne trouve pas la clé et sert l’anglais. C’est écrit ici parce que c’est exactement ce qui s’est passé en écrivant cette page.

Les tests

Une règle est une fonction, donc elle se teste comme une fonction. modules/library/tests/test_rules.py :

def test_it_refuses_a_due_date_before_the_loan() -> None:
with pytest.raises(ValidationError) as raised:
_due_after_taken(None, None, _loan(due_on=date(2026, 8, 20)))
assert raised.value.field == "due_on"
def test_a_create_that_sends_no_state_is_still_checked() -> None:
"""Une création qui n'envoie pas l'état arrive à la règle avec le défaut
déjà posé : le noyau matérialise les défauts déclarés avant de valider."""
with pytest.raises(ValidationError):
_copy_lent_once(_OneOtherLoan(), _CLASSES, _loan(state="out"))
$ pytest modules/library/tests/test_rules.py -q
..... [100%]
5 passed in 0.36s

La session est remplacée par un objet qui répond first(), parce que ce qu’on teste est la règle, pas SQLAlchemy. La preuve que le noyau l’appelle vraiment, c’est le 422 plus haut.

Ce qu’une règle ne fait pas

  • Elle ne remplace pas les droits. Qui a le droit d’écrire est l’affaire de l’étape 6. Une règle dit si la donnée est cohérente, pas si l’auteur est autorisé.
  • Elle ne s’écrit pas en YAML ni en KM. Une règle est du code du module, jamais une expression saisie par un utilisateur : exécuter du Python arbitraire venu d’une donnée est l’interdiction n°37, et un module de tenant vit en base. Le pendant sans code existe et c’est le moteur de flux de l’étape 12, qui réagit après coup au lieu de refuser.
  • Elle ne rattrape pas le passé. Une règle posée aujourd’hui s’applique aux lignes déjà en base dès qu’on les touche. Sur l’instance de ce tutoriel, deux prêts violaient déjà « un exemplaire, un prêt » : la règle les a refusés à la première modification. C’est le comportement voulu, mais il vaut mieux le savoir avant de le découvrir sur les données d’un client. Un module qui pose une règle sur des données existantes range le nettoyage dans une migration, pas dans un correctif à la main.

Et ensuite

Les montants ont leur propre règle, et elle n’est pas facultative : Les montants et les devises.