Quatre règles, valables sur toutes nos stacks. Elles couvrent les erreurs de conception qui nous coûtent réellement du temps, pas des bonnes pratiques générales.
Ce document existe pour une raison : rendre les arbitrages d'architecture prévisibles. Si ta solution respecte ces quatre règles, elle passe, sans arbitrage descendant. Si elle les enfreint, tu le sais avant d'écrire la première ligne. Et si quelque chose n'est couvert par aucune règle, c'est ton jugement qui fait foi.
00
Le critère
On optimise pour la personne qui lira ce code dans dix-huit mois sans contexte. Pas pour l'élégance de celle qui l'écrit aujourd'hui.
Corollaire opérationnel : notre code doit ressembler à la documentation de la librairie ou du framework qu'il utilise. Un dev qui connaît React ou FastAPI doit pouvoir ouvrir n'importe lequel de nos fichiers et le lire sans apprendre notre lexique interne. Chaque fois qu'on s'en écarte, on facture au lecteur un apprentissage qu'il n'a pas demandé. Les quatre règles qui suivent ne sont que des cas particuliers de cette phrase.
01
Les règles
Quatre règles, pas davantage. Chacune porte deux exemples, React et Python, parce que c'est la même erreur des deux côtés de l'API, seule la syntaxe change. Elles se citent en revue par leur identifiant : « cf. doctrine R2 ».
Ni syntaxe maison, ni objet de configuration qui décrit ce que le code devrait simplement dire.
Un DSL maison est un langage sans documentation, sans autocomplétion, sans outillage et sans communauté. Une configuration qui contient des conditions ou des fonctions est la même chose déguisée en données : le flux de contrôle quitte le code pour aller vivre dans un objet, et on perd la pile d'appels, les points d'arrêt, la recherche d'usages. Le gain d'écriture est payé à chaque lecture, par tout le monde, pendant toute la vie du projet.
React
Refusé
// un mini-DSL de navigation
<AppLink
to={ROUTES.client.detail}
params={{ id: client.id }}
query={{ tab: 'billing' }}
/>
// et sa version "données" :const NAV = [
{ key:'clients', icon:'users',
visibleIf: (u) => u.isAdmin },
];
<NavRenderer items={NAV} />
Attendu
// du JSX. C'est déjà le langage.
<Link to={`/clients/${client.id}?tab=billing`}>
{client.name}
</Link>
<nav>
<Link to="/clients">Clients</Link>
{user.isAdmin &&
<Link to="/admin">Admin</Link>}
</nav>
// un Ctrl+F sur "/clients"// trouve tous les appelants.
Python
Refusé
# un DSL de requêtes maison
repo.find(Client, where={
"status__in": ["active"],
"created__gte": since,
}, order="-created")
# chaînes magiques, zéro type,# zéro autocomplétion, et une# faute de frappe = 0 résultat,# silencieusement.
Attendu
# SQLAlchemy, tel quel
stmt = (
select(Client)
.where(Client.status == "active")
.where(Client.created_at >= since)
.order_by(Client.created_at.desc())
)
session.execute(stmt).scalars().all()
# typé, vérifié, documenté# par la doc officielle.
Coût observé :projet + date à compléter, navigation illisible, paramètres silencieusement ignorés quand la clé ne correspondait pas, N bugs en production, N jours de refactorisation. Frontière acceptable : une table d'aiguillage dont les valeurs sont des fonctions nommées et sans condition reste de la donnée saine. Un point d'entrée unique configuré (client HTTP, datasource, instance i18n) aussi. Dès qu'une condition ou une lambda anonyme entre dans la structure, la règle s'applique.
La décision se prend chez l'appelant, qui a déjà l'information. L'appelé exécute, il ne redevine pas.
Une fonction, un composant ou un service « parapluie » qui détermine à l'exécution s'il est un A ou un B inverse la responsabilité : l'appelant savait ce qu'il voulait, il passe l'information brute, et l'appelé la redevine par une cascade de conditions. Résultat : deux comportements couplés dans un fichier, des paramètres valides pour l'un et absurdes pour l'autre, un typage qui ne garantit plus rien, et des tests obligés de fabriquer un état complet pour viser une seule branche. C'est aussi une violation directe du principe ouvert/fermé : chaque nouveau cas oblige à rouvrir le même fichier et à rallonger la cascade, au lieu d'ajouter une implémentation à côté. Une condition chez l'appelant coûte une ligne ; la même condition chez l'appelé coûte un fichier.
React
Refusé
<ItemOrDropdown
item={item}
children={item.children}
onSelect={handle}
/>
// dedans :if (children?.length > 0) …
if (children?.length === 1) …
if (item.href && !children) …
// props valides pour l'un,// ignorées pour l'autre.
Attendu
// le parent sait. Il décide.
{item.children.length > 0
? <Dropdown
label={item.label}
options={item.children}
onSelect={handle} />
: <Item
label={item.label}
href={item.href} />}
// deux composants, deux// contrats, zéro ambiguïté.
Python
Refusé
class PaymentProcessor:
def process(self, order):
if order.amount > 10_000:
return self._wire(order)
if order.customer.has_mandate:
return self._sepa(order)
return self._card(order)
# l'appelant ne sait pas ce# qui va se passer, et les# tests doivent monter un# order complet pour viser# une seule branche.
Attendu
# l'appelant a l'info. Il tranche.if order.amount > 10_000:
processor = WireTransfer()
elif order.customer.has_mandate:
processor = SepaDebit()
else:
processor = CardPayment()
processor.charge(order)
# chaque implémentation est# testable seule.
Coût observé :projet + date à compléter, N bugs liés à des paramètres ignorés selon la branche, impossibilité de typer correctement les variantes, N jours de refactorisation. Test rapide : si tu ne peux pas écrire une signature où chaque paramètre est requis et utilisé dans tous les cas, tu as deux implémentations distinctes. Frontière acceptable : un aiguillage qui traduit une valeur en rendu ou en action (<StatusAlert status={status} />) ne viole pas la règle : le contrat est unique, toutes les entrées servent à toutes les branches. Ce que la règle interdit, c'est de passer un objet brut pour faire redeviner en aval une décision que l'appelant avait déjà prise.
On écrit le balisage en clair, autant de fois qu'il le faut. On n'abstrait qu'à la troisième occurrence réelle.
Personne ne reproche à du HTML de répéter des balises : c'est ce qui le rend lisible. Deux ressemblances sont une coïncidence ; la troisième seulement révèle la forme véritable de l'abstraction. Abstraire à la deuxième, c'est deviner, et la classe mère devinée devient un carcan que la troisième fille contourne avec un booléen. Une mauvaise abstraction coûte plus cher que la duplication qu'elle remplace, parce qu'on ne la supprime jamais.
React
Refusé
// deux lignes se ressemblent,// donc on invente le wrapper
<InfoRow
label="Nom" value={client.name}
boldcopyable={false}
align="left" />
<InfoRow
label="SIRET" value={client.siret}
bold={false} copyablealign="right" />
// 5 props pour éviter 4 lignes// de JSX. Le 3e cas ajoutera// une 6e prop.
Attendu
// du balisage. Répété. Lisible.
<div className="row">
<span>Nom</span>
<strong>{client.name}</strong>
</div>
<div className="row">
<span>SIRET</span>
<CopyButton value={client.siret} />
</div>
// au 3e cas, on regarde ce qui// est VRAIMENT commun : souvent// une classe CSS, parfois un// hook, rarement un composant.
Python
Refusé
# deux services CRUD, donc# on invente la classe mèreclass BaseService(Generic[T]):
model: type[T]
schema: type[BaseModel]
soft_delete = True
audit = False
def list(self, **filters): …
def create(self, payload): …
# le 3e service aura un cas# particulier. On ajoutera un# attribut de classe, puis un# hook, puis un override.
Attendu
# deux services explicitesclass ClientService:
def list(self, status): …
def create(self, payload): …
class InvoiceService:
def list(self, period): …
def create(self, payload): …
# au troisième, ce qui sort est# souvent une fonction libre,# pas une classe mère.def paginate(stmt, page, size): …
Statut : règle préventive, pas encore d'incident chiffré. À confirmer ou retirer selon ce qu'on constate. Test rapide : compte les usages actuels, pas les usages imaginés. Deux, on duplique. Second test : si un grep sur le nom ne trouve plus les appelants, l'indirection est allée trop loin.
On passe ce dont l'appelé a besoin, jamais l'objet entier « au cas où ».
Quand on passe un gros objet, la signature ment : elle annonce une dépendance à tout, alors que l'appelé n'en utilise que trois champs. Personne ne peut plus savoir ce qui est réellement consommé sans lire le corps, chacun se sert au passage, et le couplage devient invisible. Conséquences concrètes : renommer un champ oblige à fouiller tout le projet, un test doit fabriquer un objet complet pour vérifier un calcul de trois lignes, et le jour où deux appelés lisent le même champ pour des raisons différentes, plus personne n'ose y toucher. Une signature honnête est la meilleure documentation qu'on puisse écrire, et la seule que le compilateur vérifie.
React
Refusé
<InvoiceRow
invoice={invoice}
client={client}
settings={settings} />
// dedans, chacun sa tambouille :
invoice.client.address.country
settings.ui.dateFormat
client.billing.vatMode
// impossible de savoir ce que// le composant consomme sans// le lire en entier.
Attendu
<InvoiceRow
number={invoice.number}
total={invoice.total}
dueDate={invoice.dueDate}
currency={settings.currency} />
// le contrat est dans l'appel.// Le composant est testable// avec 4 valeurs, pas 3 objets.
Python
Refusé
def compute_penalty(context: dict):
# context = tout : invoice, user,# settings, request, db…if context["settings"]["region"] == "MC":
rate = context["settings"]["mc_rate"]
days = (context["now"]
- context["invoice"]["due"]).days
…
# pour tester 3 lignes de calcul,# il faut fabriquer le monde.
Coût observé :projet + date à compléter, N jours perdus. Test rapide : ouvre l'appelé et compte les champs réellement lus. Si c'est moins de la moitié de ce qu'on lui passe, la signature ment. Frontière acceptable : passer une entité complète à quelque chose dont le métier est cette entité (un mapper, un sérialiseur, un dépôt) est légitime. Un objet dédié regroupant des paramètres qui voyagent toujours ensemble aussi, à condition qu'il n'existe que pour cet appel.
02
Déroger
Ces quatre règles ont des exceptions légitimes. Une règle sans porte de sortie se contourne en silence, ce qui est pire que la dérogation assumée. La procédure tient en dix minutes et se fait avant d'écrire le code, au moment où changer d'avis est encore gratuit.
ADR : un fichier dans /docs/adr/AAAAMMJJ-titre.md
Le problème : en deux phrases, sans solution.
La solution ennuyeuse : celle que la doctrine impose. Décris-la vraiment.
Pourquoi elle ne suffit pas : c'est le seul paragraphe qui compte.
Ce qu'on retient : ce qu'on accepte de payer en échange.
La trace dans le code : un commentaire en première ligne du fichier concerné, pointant vers l'ADR.
Cette dernière étape n'est pas de la paperasse : c'est la seule qui serve au lecteur. Un ADR rangé dans /docs ne sera jamais lu par celui qui tombe sur le code six mois plus tard et se demande pourquoi on a dérogé. Le commentaire met la réponse là où naît la question. Effet secondaire utile : grep -rn "adr :" src/ donne à tout moment la liste exhaustive des dérogations actives dans un projet. Si une ligne n'a plus de fichier ADR en face, la dérogation a survécu à sa justification et doit être reprise.
Validé par un pair, pas par la direction technique. Huit lignes suffisent. Le but n'est pas la traçabilité : c'est de te forcer à formuler ta justification à voix haute. La moitié des dérogations meurent à l'étape 2, quand on écrit la solution ennuyeuse et qu'on s'aperçoit qu'elle convient.
03
Ce sur quoi on ne légifère pas
Cette section est aussi importante que les règles. En dehors des quatre points ci-dessus, ton jugement fait foi et il n'y aura pas d'arbitrage descendant. Une revue peut discuter ces sujets, elle ne bloque pas dessus.
La structure interne d'une fonction ou d'une classe
Le nommage local des variables
Le découpage en sous-fonctions
La stratégie de tests d'un module
Boucle, compréhension de liste ou stream()
Les micro-optimisations
L'organisation des fichiers dans un module
La gestion d'erreur locale
Le formatage, c'est le rôle du linter
Si un arbitrage descendant tombe sur un sujet absent de ce document, c'est un défaut du document. Il faut alors soit ajouter une règle, soit retirer l'arbitrage. Pas de troisième option.
04
Journal
Ce document grandit par accrétion d'incidents réels, jamais par anticipation théorique. Une règle ne s'ajoute qu'après un coût constaté, et se retire si elle n'a bloqué personne pendant un an. Quatre règles est un plafond volontaire : en ajouter une devrait coûter quelque chose.
Date
Règle
Incident déclencheur
AAAA-MM
R1
Mini-DSL de navigation, projet à préciser
AAAA-MM
R2
Composant ItemOrDropdown, projet à préciser
AAAA-MM
R4
Objets globaux passés de fonction en fonction, projet à préciser
AAAA-MM
R3
Aucun incident chiffré à ce jour. Règle préventive, à confirmer ou retirer.
AAAA-MM
à venir
Incident back à documenter : les exemples Python sont pour l'instant illustratifs, pas vécus. À remplacer par nos vrais cas.