# Maedow Arch Standard d'architecture logicielle pour TypeScript, React et Next.js. Le domaine métier reste séparé de l'interface, l'infrastructure est interchangeable, et les frontières sont vérifiées par un linter plutôt que tenues par la mémoire de l'équipe. Documentation : https://maedow-arch-docs.vercel.app Dépôt : https://github.com/maedow-arch/maedow-arch-docs Outillage : npx create-maedow-arch-app mon-projet génère un projet conforme npx maedow-arch check audite un projet existant eslint-config-maedow-arch applique les frontières Ce fichier rassemble le corpus entier. Il est dérivé de la documentation, jamais recopié : il ne peut pas se périmer sans qu'elle se périme aussi. ====================================================================== # Adopter le standard sur un projet existant ====================================================================== Le reste de cette documentation s'adresse aux projets qui n'existent pas encore. Celle-ci s'adresse aux autres, qui sont la majorité : personne n'adopte une architecture en repartant de zéro. ## Savoir ce que ça coûte, avant de décider ```bash npx maedow-arch check ``` La commande **ne modifie rien, n'installe rien**, et n'a besoin ni des `node_modules` ni d'un projet qui compile. C'est délibéré : un audit qu'il faudrait préparer ne serait jamais lancé par l'équipe qui hésite encore, c'est-à-dire par la seule à qui il s'adresse. Elle lit votre code et rend un rapport par règle du [registre](/docs/rules). ## Ce que l'audit examine, et ce qu'il laisse de côté L'en-tête du rapport le dit avant tout le reste : ``` 37 fichiers lus, 32 dans une couche du standard 5 hors des couches, non examinés (scripts, configuration, actifs) ``` **Seuls les fichiers appartenant à une couche du standard sont examinés.** Un fichier qui ne vit ni dans `app/`, ni dans `features/`, ni dans `core/`, ni dans `components/`, ni dans `lib/` n'est pas du code Maedow Arch : vos scripts de build, vos tests de bout en bout, vos fichiers de configuration et ce que vos outils déposent dans `public/` ne reçoivent aucun reproche. C'est une liste blanche, et le choix est délibéré. Une liste d'exclusions laisserait passer par défaut et ne pourrait être complète qu'après coup : elle signalerait le prochain dossier que personne n'avait prévu. La liste blanche, elle, ne se périme pas, puisque c'est celle que le corpus énonce. Le second chiffre est un **renseignement, pas un reproche**. Sur un projet en migration, savoir combien de fichiers vivent hors des couches vous dit où en est le découpage, et cela n'a rien à voir avec une dette à corriger. ## Ce que la migration coûte, et ce qu'on ne peut pas vous dire C'est la première question d'une équipe qui évalue, et elle n'a pas de réponse générale : le coût dépend de ce que l'audit trouve chez vous, pas de la taille du projet. Ce qu'on peut dire, c'est **quelle part se corrige mécaniquement et quelle part demande une décision**. | Ce qui remonte | Qui le corrige | Ce que ça demande | | :--- | :--- | :--- | | `TS-STRICT` | `--fix` | rien, hors les erreurs de typage que les options révèlent ensuite | | MA-005, MA-006 | vous, fichier par fichier | mécanique, mais proportionnel au nombre d'occurrences | | MA-004 | vous | déplacer des fichiers, sans décider où va la logique | | MA-001, MA-002, MA-003 | vous | **décider où va le code**, et c'est là qu'est le coût réel | | MA-007 | vous | défaire un cycle demande souvent d'extraire un troisième module | Les trois règles de frontière sont les seules qui ne se corrigent pas en série. Une violation vous dit qu'un fichier est au mauvais endroit ; elle ne dit pas où il doit aller, et cette décision-là ne se délègue ni à un outil ni à un stagiaire. **Le premier geste utile est de lancer l'audit et de lire le total, pas cette page.** Il chiffre votre dette réelle en quelques secondes, sans rien installer : ```bash npx maedow-arch check --json > audit.json ``` Un projet qui rend beaucoup de `TS-STRICT` et peu de frontières s'adapte en quelques jours. Un projet qui range tout dans `app/` n'a pas une dette à corriger : il a un découpage à concevoir, et c'est un autre travail. **Ce que nous n'avons pas.** Aucun retour public de migration complète chiffrée à ce jour. Le standard a été éprouvé sur dix-neuf lots d'un projet réel construit avec lui depuis le départ, ce qui valide les règles mais ne dit rien du coût d'une reprise d'existant. Si vous conduisez cette migration, [le dépôt](https://github.com/maedow-arch/maedow-arch-docs/issues) est l'endroit où le dire, et ce serait le premier. ## Lire le rapport Il est ordonné **par ce qui débloque le reste**, jamais par gravité, et chaque section dit ce que la correction rend possible. ``` 1. Activer le typage strict · TS-STRICT · 2 violations Sans strict, les couches basses ne peuvent pas garantir ce qu'elles annoncent, et les corrections suivantes reposeraient sur des types qui mentent. tsconfig.json noUncheckedIndexedAccess absent ou faux ``` Un projet qui découvre deux cents violations doit voir un chemin, pas un verdict. Le total n'apparaît qu'à la fin, une fois que vous savez par où commencer. ### L'ordre de migration, et sa raison | Rang | Ce qu'on corrige | Pourquoi d'abord | | :--- | :--- | :--- | | 1 | le typage strict | les corrections suivantes reposeraient sinon sur des types qui mentent | | 2 | MA-004, l'interface hors de `core/` | un domaine sans JSX devient testable sans DOM | | 3 | MA-001, le sens du flux | tant qu'il remonte, séparer les features n'a pas d'effet durable | | 4 | MA-002, les features entre elles | chacune devient supprimable et déplaçable seule | | 5 | MA-003, le partagé transverse | `features/_shared` cesse d'être un fourre-tout | | 6 | MA-007, les cycles | deux modules en cycle ne se testent ni ne se déplacent séparément | | 7 et 8 | MA-005 et MA-006, le typage | chaque `any` retiré rend réelle une garantie déjà promise | ### Si le rapport ne signale aucune frontière Il vous le dira explicitement : ``` Ce projet n'a pas de features ni de core. Les règles de frontière n'ont donc rien pu vérifier : leur silence ci-dessus ne dit pas qu'elles sont respectées, mais qu'il n'y avait rien à examiner. ``` C'est le cas le plus fréquent sur un projet qui range tout dans `app/` et `components/`. Le [découpage en couches](/docs/architecture) est alors le premier geste, avant tout le reste. ### Si le rapport ne signale rien sur le typage `TS-STRICT` ne juge que ce qu'il a lu. Sans `tsconfig.json`, il ne compte rien, et distingue deux situations qui n'ont pas le même sens : ``` Aucun tsconfig.json, et aucun fichier TypeScript : TS-STRICT ne s'applique pas à ce projet. Son silence ci-dessus ne dit pas que le typage est strict, mais qu'il n'y avait rien à examiner. ``` ``` Aucun tsconfig.json n'a été trouvé, alors que ce projet contient du TypeScript. TS-STRICT n'a donc rien pu vérifier, et c'est un problème en soi : sans fichier de configuration, rien ne garantit sous quelles options ce code est compilé. ``` Le second cas se corrige avant tout le reste : tant qu'aucun fichier ne dit sous quelles options ce code est compilé, aucune des corrections suivantes ne repose sur du solide. ## Mesurer une progression, pas rougir un pipeline ```bash npx maedow-arch check --seuil 50 ``` Sans seuil, la commande réussit toujours. Avec un seuil, elle échoue au-delà. Une équipe en migration a besoin de **voir sa dette baisser**, pas d'un pipeline rouge au premier jour : posez le seuil au-dessus de votre compte actuel, puis descendez-le à mesure que vous corrigez. ```bash npx maedow-arch check --json > audit.json ``` La sortie machine porte le total, l'ordre de migration et le détail par code, de quoi tracer une courbe dans votre intégration continue. ### Découvrir avec `npx`, suivre avec une dépendance Les deux commandes ci-dessus ne s'emploient pas dans le même contexte, et l'outil ne s'installe pas de la même façon selon le cas. Pour **découvrir** ce que l'adoption coûterait, `npx` est le bon geste : rien à installer, rien à décider, et c'est précisément l'intérêt sur un projet qu'on hésite encore à migrer. Pour **suivre** une migration lot après lot, épinglez-le : ```bash npm install --save-dev maedow-arch ``` `npx` peut réutiliser une version mise en cache. Deux postes rendent alors deux rapports différents sur le même code, sans que rien n'explique l'écart, et c'est exactement ce qu'il ne faut pas quand le compte sert à mesurer une progression. Une dépendance épinglée fige la version pour toute l'équipe et pour l'intégration continue. ## Ce que `--fix` corrige, et ce qu'il ne touche pas ```bash npx maedow-arch check --fix ``` Il ajoute les options manquantes du `tsconfig.json`, en préservant vos commentaires. **Rien d'autre.** Les déplacements de fichiers sont proposés, jamais appliqués : un outil qui déplace du code sans qu'on le lui demande ne se fait pardonner qu'une fois. Et les violations de frontière ne se corrigent pas mécaniquement, chacune demandant de décider où va le code, ce qu'un outil ne sait pas faire à votre place. ## Ce que l'audit ne fait pas **Il dénombre, il ne garantit pas.** Il lit les chemins d'import sans compiler le projet, ce qui lui permet de tourner sur une cible non installée qui ne compile pas encore. Le prix est quelques cas limites, et il est assumé. Une fois la migration faite, c'est [`eslint-config-maedow-arch`](https://www.npmjs.com/package/eslint-config-maedow-arch) qui tient les frontières, à chaque commit : ```bash npm install --save-dev eslint-config-maedow-arch eslint-plugin-boundaries eslint-plugin-import-x eslint-import-resolver-typescript ``` ```js // eslint.config.mjs import maedowArch from "eslint-config-maedow-arch"; export default [...maedowArch]; ``` L'entrée par défaut porte les règles de **frontière**. Quand elles passent, l'entrée `strict` ajoute la discipline de typage : ```js import maedowArch from "eslint-config-maedow-arch"; import maedowArchStrict from "eslint-config-maedow-arch/strict"; export default [...maedowArch, ...maedowArchStrict]; ``` L'ordre compte : adopter les deux d'un coup sur une base existante remonte tout en même temps, et c'est le meilleur moyen de tout désactiver le vendredi suivant. ## Se dire conforme Le registre définit [les quatre conditions](/docs/rules) qu'un projet doit satisfaire, et ce qui constitue une rupture du standard. `check` en mesure deux : il ne décerne pas la conformité, il dit ce qui vous en sépare. ## Ce qui aide vraiment ce standard Les frictions rencontrées pendant une migration réelle. Une règle trop stricte, un cas limite qu'elle n'avait pas prévu, un rapport qui compte mal : ces retours passent par les [issues du dépôt](https://github.com/maedow-arch/maedow-arch-docs/issues) et alimentent `FRICTIONS.md`, le journal de ce qui a résisté en conditions réelles. ====================================================================== # Architecture, les quatre couches et le flux ====================================================================== > **Standard d'Architecture Logicielle Modulaire, Découplée & Agnostique pour Applications Web Modernes (TypeScript / React / Next.js / etc.)** > **Maedow Arch** (*maedow-arch*) définit un pattern de conception robuste, agnostique de vos choix de base de données, d'authentification ou d'outils tiers, conçu pour garantir une maintenabilité, une évolutivité et une testabilité optimales. --- ## Vision et Piliers Fondamentaux de Maedow Arch **Maedow Arch** repose sur 6 principes cardinaux : 1. **Séparation Stricte du Domaine Métier et de l'UI (Zéro Modèle dans le JSX)** : Les règles métier, les interfaces de données, les schémas de validation et les transitions d'état sont 100 % isolés du code de présentation React/HTML. 2. **Architecture en Couches à Dépendance Unidirectionnelle** : Le flux de dépendance est strict (`app -> features -> core -> lib`). Le domaine métier (`core`) n'a aucune connaissance des composants d'interface (`features` / `components`). 3. **Agnosticisme de l'Infrastructure (Pattern Ports & Adapters / Hexagonal)** : La base de données, l'authentification et les API tierces sont masquées derrière des contrats d'interfaces. Changer de base (Postgres, SQLite, MongoDB, IndexedDB, Prisma, Drizzle) ou d'auth (BetterAuth, Auth.js, Supabase, Firebase, JWT custom) ne touche jamais le cœur métier ni l'UI. 4. **Erreurs Typées & Prévisibles (Result Pattern)** : Les refus attendus et les échecs fonctionnels sont retournés comme des données discriminées (`{ ok: false, error: "..." }`) et non levés comme des exceptions silencieuses. 5. **Garde-fous Outillés (Machine-Enforced Architecture)** : Les frontières architecturales ne reposent pas sur la simple discipline humaine mais sont vérifiées par le compilateur TypeScript (`strict`) et des règles de linter (`eslint-plugin-boundaries`). 6. **Pragmatisme & Composition Évolutive** : L'architecture évite l'over-engineering grâce à l'inférence de types, des générateurs de code et un pattern de composants partagés (`features/_shared/`). --- ## Vue d'Ensemble des Couches Maedow Arch ```mermaid flowchart TD APP["1 · APP ROUTING ET ENTRY
app/ ou pages/
Pages, layouts, API route handlers"] FEAT["2 · FEATURES, LES SURFACES UI
features/[feature]/ : écrans, hooks locaux, composants dédiés
features/_shared/ : composants métier transverses partagés"] CORE["3 · CORE, LE DOMAINE PUR
core/[module]/ : types, services
Modèles et entités purs, moteurs d'états, contrats de ports
ZÉRO JSX, ZÉRO dépendance UI"] LIB["4 · COMPONENTS ET LIB
components/ui/, lib/utils.ts
Primitives UI agnostiques, fonctions d'aide pures
ZÉRO logique métier"] APP -->|assemble| FEAT FEAT -->|consomme| CORE FEAT -->|consomme| LIB ``` --- ## Matrice des Rôles par Couche | Couche | Répertoire | Responsabilité | Ce qu'elle CONTIENT | Ce qu'elle NE CONTIENT JAMAIS | | :--- | :--- | :--- | :--- | :--- | | **App** | `app/` | Point d'entrée, routing, injection de dépendances. | Les fichiers d'amorçage et de routing du framework hôte. Sous Next.js : `page.tsx`, `layout.tsx`, `route.ts`, middlewares. Voir [Maedow Arch hors Next.js](#maedow-arch-hors-nextjs). | Logique métier détaillée, requêtes directes non encapsulées. | | **Features** | `features//` | Écrans et modules fonctionnels utilisateur. | Composants `.tsx`, hooks React dédiés, adaptateurs de vue. | Définitions de modèles de données partagés, logique de persistance brute. | | **Shared Features** | `features/_shared/` | Composants UI métier utilisés par au moins deux features. | Composants composites métier partagés (`UserAvatarCard`, `AddressPicker`). | Primitives UI agnostiques (qui vont dans `components/ui/`). | | **Core** | `core//` | Cœur métier, domaine pur, persistance, contrats. | Types/Interfaces purs (`.ts`), machines d'états, validateurs, interfaces de repositories. | **ZÉRO fichier `.tsx`**, aucun import React/DOM. | | **UI Primitives** | `components/ui/` | Composants atomiques réutilisables (Design System). | Boutons, Modales, Inputs, Dropdowns, etc. | Types métier, appels API, état applicatif global. | | **Lib** | `lib/` | Utilitaires transverses non liés au métier. | Formatage, manipulation de chaînes/dates, helpers CSS (`cn`). | Types spécifiques au métier, règles de gestion. | --- ## Agnosticisme Technique : Adapters & Infrastructure dans Maedow Arch > **Règle de Lazy Abstraction (Introduction Différée des Contrats)** : un `contract.ts` + un système d'adapters ne doit être introduit **qu'au moment où une deuxième implémentation réelle est nécessaire** (migration de base, multi-tenant avec fournisseurs différents, besoin de mock avancé en test). Tant qu'un seul fournisseur (une seule DB, un seul provider d'auth) est utilisé et qu'aucun changement n'est prévu à court terme, l'accès direct dans `core//repository.ts` est conforme à Maedow Arch. Abstraire par anticipation sans second cas d'usage concret est un anti-pattern Maedow Arch : ça ajoute de l'indirection sans bénéfice mesurable. ### La Lazy Abstraction ne dispense pas de la testabilité La règle ci-dessus autorise l'accès direct au client de base de données tant qu'il n'y a qu'un fournisseur. Prise à la lettre, elle produit ceci : ```typescript // ❌ core/orders/service.ts import { db } from "@/core/server/db"; export async function annulerCommande(id: string) { const commande = await db.order.findUnique({ where: { id } }); // ... } ``` Ce code est conforme à la Lazy Abstraction, et il **casse la promesse centrale du standard** : `core/` ne se teste plus sans simuler `db`. Deux exigences du corpus se contredisent, et le développeur pressé tranchera en faveur de celle qu'il vient de lire. **Le service reçoit son dépôt en paramètre, décrit par un type structurel écrit sur place :** ```typescript // ✅ core/orders/service.ts import type { Result } from "@/core/common/result"; import type { Commande } from "./types"; type DepotCommandes = { trouverParId(id: string): Promise; changerStatut(id: string, statut: Commande["statut"]): Promise; }; export async function annulerCommande( depot: DepotCommandes, id: string ): Promise> { const commande = await depot.trouverParId(id); if (commande === null) return { ok: false, error: { kind: "introuvable" } }; if (commande.statut === "annulee") return { ok: false, error: { kind: "deja-annulee" } }; return { ok: true, data: await depot.changerStatut(id, "annulee") }; } ``` **Ce n'est pas un `contract.ts` déguisé**, et la distinction est le cœur du sujet. La Lazy Abstraction interdit d'ouvrir un fichier de contrat et un jeu d'adaptateurs pour un fournisseur unique : de l'indirection, des fichiers, une inversion de dépendance à maintenir. Un type écrit à côté de la fonction qui l'emploie n'est rien de tout cela. Il ne décrit pas le dépôt, il décrit **ce dont cette fonction a besoin**, ce qui est en général trois méthodes sur les quarante que le client expose. Ce que ça change, concrètement : | | Import direct | Type structurel en paramètre | | :--- | :--- | :--- | | Test du service | simuler le client entier | un objet littéral de trois méthodes | | Fichiers créés | aucun | aucun | | Changement de fournisseur | réécrire le service | réécrire le seul appelant | | Conforme à Lazy Abstraction | oui | oui | Le `repository.ts` reste l'implémentation concrète, celle qui parle au client réel et qui convertit ses exceptions avec `fromThrowable`. C'est `app/` qui les assemble, et c'est précisément son rôle : les points d'entrée injectent les dépendances. **Quand introduire un vrai `contract.ts` malgré tout ?** À la deuxième implémentation réelle, comme la règle le dit. Le type structurel n'anticipe rien : il existe parce que la fonction a besoin d'un paramètre, pas parce qu'un second fournisseur pourrait apparaître un jour. ### Gestion de l'Authentification (Auth Agnostic) Le code applicatif interagit avec une abstraction d'identité : ```typescript // core/auth/types.ts export interface UserSession { userId: string; email?: string; role: "admin" | "member" | "guest"; } export interface AuthService { getSession(req: Request): Promise; requireUser(req: Request): Promise; } ``` * **Implémentations interchangeables** : * `core/auth/better-auth.adapter.ts` (BetterAuth) * `core/auth/supabase.adapter.ts` (Supabase) * `core/auth/authjs.adapter.ts` (NextAuth / Auth.js) * `core/auth/jwt.adapter.ts` (API backend externe / JWT custom) ### Gestion de la Persistance (Database Agnostic) Le domaine définit ses interfaces de Repository (Ports) : ```typescript // core/users/repository.contract.ts import type { UserEntity, CreateUserInput } from "./types"; export interface UserRepository { findById(id: string): Promise; create(input: CreateUserInput): Promise; update(id: string, partial: Partial): Promise; } ``` * **Implémentations concrètes (Adapters)** : * `core/users/postgres.repository.ts` (SQL brut / `pg`) * `core/users/prisma.repository.ts` (Prisma ORM) * `core/users/drizzle.repository.ts` (Drizzle ORM) * `core/users/dexie.repository.ts` (IndexedDB / Local-first) * `core/users/firestore.repository.ts` (Firebase / Firestore) --- ## Composition de Features & Éléments Partagés (`features/_shared/`) Pour respecter la règle de Maedow Arch « *Une feature n'importe pas une autre feature* », les éléments d'interface composites transverses sont placés dans `features/_shared/` : ``` features/ ├── checkout/ # Feature autonome │ └── CheckoutScreen.tsx # Consomme _shared/AddressPicker ├── account/ # Feature autonome │ └── ProfileScreen.tsx # Consomme _shared/AddressPicker └── _shared/ # Composants métier partagés └── AddressPicker.tsx # Dépend de core/, mais réutilisable ``` ### Règle de Dégradation de `features/_shared/` (Anti Fourre-Tout) Pour empêcher `features/_shared/` de devenir un dépotoir avec le temps, deux règles s'appliquent : 1. **Extraction a posteriori uniquement** : un composant n'est **jamais créé directement** dans `_shared/`. Il naît dans une feature, et n'est déplacé vers `_shared/` que lorsqu'une **deuxième feature en a réellement besoin**. 2. **Revue périodique d'usage** : à intervalle régulier (ex. tous les X sprints, ou via un script listant les imports réels de chaque fichier de `_shared/`), tout composant qui n'est plus consommé que par une seule feature **redescend** dans cette feature. --- ## Règle de Dépendance et Frontières Maedow Arch (`eslint-plugin-boundaries`) Ces frontières portent les codes **MA-001**, **MA-002** et **MA-003** du [registre des règles](./rules.md), et ce sont les codes que citent les messages de lint. Le registre dit, pour chacune des neuf règles du standard, si elle est vérifiée par la machine ou tenue par l'équipe : c'est là qu'il faut regarder avant de se demander si une exigence sera attrapée automatiquement. ```javascript // eslint.config.mjs import boundaries from "eslint-plugin-boundaries"; export default [ { plugins: { boundaries }, settings: { "boundaries/elements": [ { type: "app", pattern: "app/**" }, { type: "feature", pattern: "features/*", capture: ["feature"] }, { type: "shared-feature", pattern: "features/_shared/**" }, { type: "core", pattern: "core/**" }, { type: "components", pattern: "components/**" }, { type: "lib", pattern: "lib/**" }, ], }, rules: { "boundaries/dependencies": [ "error", { default: "disallow", policies: [ { from: { element: { type: "app" } }, allow: { to: { element: { types: { anyOf: ["feature", "shared-feature", "core", "components", "lib"] } } } } }, { from: { element: { type: "feature" } }, allow: { to: { element: { types: { anyOf: ["shared-feature", "core", "components", "lib"] } } } } }, { from: { element: { type: "feature" } }, allow: { to: { element: { type: "feature", captured: { feature: "{{from.feature}}" } } } } }, { from: { element: { type: "shared-feature" } }, allow: { to: { element: { types: { anyOf: ["core", "components", "lib"] } } } } }, { from: { element: { type: "core" } }, allow: { to: { element: { types: { anyOf: ["core", "lib"] } } } } }, { from: { element: { type: "components" } }, allow: { to: { element: { types: { anyOf: ["components", "lib"] } } } } }, { from: { element: { type: "lib" } }, allow: { to: { element: { type: "lib" } } } }, ], }, ], }, }, ]; ``` --- ## Structure Recommandée d'un Projet sous Maedow Arch ```text src/ ├── app/ # Routing & Points d'entrée │ ├── (auth)/ │ ├── (dashboard)/ │ ├── api/ │ ├── layout.tsx │ └── page.tsx │ ├── core/ # Domaine & Logique Métier Pure (100% .ts) │ ├── / # Ex: billing, users, catalog... │ │ ├── types.ts # Entités et modèles │ │ ├── validation.ts # Schémas Zod │ │ ├── service.ts # Cas d'usage │ │ ├── contract.ts # Seulement à la 2e implémentation, voir Lazy Abstraction │ │ └── repository.ts # Adapter de persistance │ ├── auth/ # Abstraction d'authentification │ └── server/ # Infra serveur (DB Pool, Env) │ ├── features/ # Écrans & Surfaces UI │ ├── _shared/ # Composants composites métier partagés │ ├── / │ │ ├── components/ # Composants .tsx dédiés │ │ ├── hooks/ # Hooks React locaux (.ts) │ │ ├── Screen.tsx # Composant principal d'écran (.tsx) │ │ └── types.ts # Types d'affichage locaux (.ts) │ └── / │ ├── components/ # Primitives UI Agnostiques │ └── ui/ # Design System (Button, Modal, Input...) │ ├── lib/ # Utilitaires Génériques Transverses │ ├── utils.ts │ └── dates.ts │ └── tests/ # Tests automatisés ├── unit/ # Tests unitaires du core/ (ultra rapides) ├── integration/ # Tests adaptateurs & DB └── e2e/ # Tests bout en bout (Playwright) ``` --- ### Installer une base de composants sans sortir des couches `components/ui/` est l'endroit prévu pour une base de composants, shadcn ou une autre. Un point de configuration mérite pourtant d'être corrigé avant la première installation. L'assistant de shadcn écrit un `components.json` qui déclare, par défaut : ```json { "aliases": { "components": "@/components", "hooks": "@/hooks" } } ``` **`@/hooks` n'est aucune des cinq couches.** Tout composant qui embarque un hook, et il y en a, le dépose alors dans `src/hooks/`, où il échappe à deux garde-fous à la fois : l'audit ne l'examine pas, et `eslint-plugin-boundaries` ne lui applique aucune politique, faute de correspondre à un type déclaré. Un hook posé là peut importer n'importe quoi, y compris remonter le flux, sans que rien ne le signale. Faites pointer l'alias vers un répertoire couvert avant d'installer quoi que ce soit : ```json { "aliases": { "components": "@/components/ui", "hooks": "@/lib" } } ``` Un hook réellement transverse appartient à `lib/`. Un hook qui sert un seul écran appartient au `hooks/` de sa feature, et c'est là qu'il faut le déplacer quand la base de composants en dépose un. `npx maedow-arch check` signale les fichiers rangés sous `src/` hors de toute couche, ce qui rattrape le cas s'il se produit quand même. ## Règle de Logique Extraite : ce que `hooks/` reçoit L'arborescence ci-dessus place un dossier `hooks/` dans chaque feature. Il reçoit **la logique de vue de l'écran** : son état, ses appels, ses dérivations. Le composant qui l'utilise se réduit alors au rendu. > **Règle de Logique Extraite** : dès qu'un écran porte plus de trois états ou déclenche un appel réseau, sa logique de vue appartient à un hook de la feature, et le composant se réduit au rendu. Ce n'est pas une préférence de style. C'est ce qui fait entrer la testabilité dans `features/`. ### Le problème que cela résout Le standard met la testabilité au premier rang de ses arguments, et la Pyramide de Tests promet des tests de domaine rapides parce que `core/` ne dépend d'aucun DOM. C'est vrai, et c'est insuffisant : **rien n'est dit de ce qui se passe dans les écrans**, où la logique s'accumule sans qu'aucune règle ne s'y oppose. Un écran de quatre cents lignes portant douze `useState` et ses appels réseau respecte les neuf règles du registre. Le flux ne remonte pas, aucune feature n'en importe une autre, `core/` reste pur : le lint est vert, l'audit est vert. Il n'est simplement pas testable sans monter un arbre React, alors qu'une logique extraite dans un hook `.ts` l'est. Autrement dit, sans cette section, la testabilité s'arrête à la frontière de `features/` et le corpus ne dit pas comment l'y faire entrer. ### Quand extraire Extrayez dans un hook dès que l'écran remplit **l'une** de ces deux conditions : - il porte **plus de trois états**, ou - il déclenche **un appel réseau**. Le seuil est indicatif et il est là pour éviter que chaque équipe invente le sien. En dessous, un `useState` dans le composant ne coûte rien et l'extraire ajouterait un fichier sans rien rendre plus clair : la Règle de Lazy Abstraction s'applique ici comme ailleurs. ### La forme ```ts // features/panier/hooks/usePanier.ts import { useEffect, useState } from "react"; import type { LigneVM } from "../types"; import { chargerPanier } from "@/core/panier/service"; type EtatPanier = | { statut: "chargement" } | { statut: "erreur"; message: string } | { statut: "pret"; lignes: readonly LigneVM[]; total: number }; export function usePanier(clientId: string): EtatPanier { const [etat, setEtat] = useState({ statut: "chargement" }); useEffect(() => { let vivant = true; void chargerPanier(clientId).then((resultat) => { if (!vivant) return; setEtat( resultat.ok ? { statut: "pret", lignes: resultat.value.lignes, total: resultat.value.total } : { statut: "erreur", message: resultat.error.message } ); }); return () => { vivant = false; }; }, [clientId]); return etat; } ``` L'écran n'a plus qu'à distinguer les trois cas : ```tsx // features/panier/Screen.tsx import { usePanier } from "./hooks/usePanier"; export function PanierScreen({ clientId }: { clientId: string }) { const etat = usePanier(clientId); if (etat.statut === "chargement") return

Chargement…

; if (etat.statut === "erreur") return

{etat.message}

; return (
    {etat.lignes.map((ligne) => (
  • {ligne.libelle}
  • ))}
); } ``` Trois choses valent d'être remarquées. L'état est **une union discriminée** plutôt que trois booléens indépendants, ce qui rend impossible l'état « en chargement et en erreur ». Le hook rend un état, jamais des setters : l'écran ne peut pas contourner la logique. Et l'effet annule proprement sa mise à jour si le composant disparaît avant la réponse, ce qui est exactement le genre de détail qu'on écrit une fois dans un hook plutôt que douze fois dans des écrans. ### Le cas d'un cache client Une bibliothèque de cache de données, TanStack Query ou SWR, ne change rien à la règle : `useQuery` et `useMutation` vivent dans le `hooks/` de la feature, et leur `queryFn` appelle une fonction de `core//service.ts`. ```ts // features/panier/hooks/usePanier.ts export function usePanier(clientId: string) { return useQuery({ queryKey: ["panier", clientId], queryFn: () => chargerPanier(clientId), // vit dans core/panier/service.ts }); } ``` Le hook orchestre le cache, il ne porte aucune règle métier. C'est ce qui permet de changer de bibliothèque de cache, ou de s'en passer, sans toucher au domaine. Un `queryFn` qui contiendrait un calcul de remise remonterait la logique dans `features/`, ce que MA-001 interdit et que le linter ne verra pas puisqu'il n'y a pas d'import fautif. ### Ce que cela change pour les tests `usePanier` se teste avec un moteur de rendu de hooks, sans écran. Ce que le composant garde, le choix entre trois branches, se vérifie d'un coup d'œil. C'est la seule façon de faire remonter la Pyramide de Tests au-dessus de `core/`. Un projet qui applique cette section voit son nombre de tests croître sans que son domaine change, et ce n'est pas de la couverture pour la métrique : c'est de la logique qui était intestable et qui ne l'est plus. ### Une règle tenue par l'équipe Aucun code `MA` ne porte cette section, et aucun linter ne la vérifie. Compter les `useState` d'un fichier produirait un seuil facile à contourner et des faux positifs sur les écrans qui ont de bonnes raisons d'être longs. Elle appartient aux **Règles de conception**, que [le registre](./rules.md) recense à part des neuf codes `MA` : celles-ci demandent un jugement là où un code se constate. Le seuil est indicatif, et un écran à quatre états ne viole rien, il mérite un regard. Le standard préfère le dire plutôt que de laisser croire qu'un outil la vérifie. --- ## Génération Rapide de Code (Scaffolding Anti-Boilerplate) Pour accélérer le développement sous Maedow Arch : ```bash # Générer une nouvelle feature avec toute son arborescence npm run generate:feature orders # Générer un nouveau domaine core avec contrat et types npm run generate:domain billing ``` --- ## Mode Light vs Mode Full : Quand Appliquer Maedow Arch Intégralement La Maedow Arch complète (4 couches, contracts, adapters, générateurs) est conçue pour des produits qui grandissent dans le temps (SaaS, applications métier durables). Elle est disproportionnée pour un site vitrine, un prototype ou une landing page. Pour éviter la sur-ingénierie, **Maedow Arch définit deux profils explicites** : | Critère | **Maedow Arch Light** | **Maedow Arch Full** | | :--- | :--- | :--- | | Type de projet | Site vitrine, prototype, MVP jetable, landing page | SaaS, produit avec cycle de vie long, produit multi-clients | | Durée de vie prévue | Courte (< 6 mois) ou usage unique | Longue, avec évolutions régulières | | Logique métier | Faible ou inexistante | Significative (règles, calculs, transitions d'état) | | Structure recommandée | `app/`, `features/`, `components/` et `lib/`, avec la logique directement dans la feature et **sans couche `core/`** | Structure complète : `app/ → features/ → core/ → lib/` | | Contracts / Adapters | Aucun, accès direct à la donnée | Introduits uniquement via la Règle de Lazy Abstraction, voir [Agnosticisme technique](#agnosticisme-technique--adapters--infrastructure-dans-maedow-arch) | | Result Pattern | Optionnel | Recommandé, avec helpers, voir « Helpers obligatoires pour le Result Pattern » dans `conventions.md` | ### Ce que Light conserve Light retire la couche domaine, rien d'autre. `components/ui/` et `lib/` restent légitimes : un site vitrine a des boutons et des helpers de formatage sans que cela constitue de la sur-ingénierie, et les garder à part rend la bascule ultérieure indolore. Les **règles de frontières s'appliquent identiquement dans les deux profils**, voir [Règle de dépendance et frontières](#règle-de-dépendance-et-frontières-maedow-arch-eslint-plugin-boundaries). Une feature n'importe jamais une autre feature, `components/` demeure présentationnel, `lib/` ne dépend de rien. Ces contraintes ne coûtent rien et valent quelle que soit la taille du projet. Les deux profils ne sont donc pas deux jeux de règles, mais une relation d'inclusion : Light ne peuple pas `core/`, il ne l'autorise pas différemment. Conséquence pratique : `eslint-config-maedow-arch` sert les deux profils sans configuration distincte. ### Passer de Light à Full La bascule n'est pas une migration, c'est une addition. Rien de ce qui existe ne bouge : `app/`, `features/`, `components/` et `lib/` gardent leur place et leurs règles, et la couche domaine apparaît à côté. Elle se produit au moment où un domaine devient nécessaire, et le générateur la porte : ```bash npm run generate:domain order-item ``` Sur un projet Light, cette commande crée `core/common/result.ts` avant d'écrire le domaine, puis annonce le changement de profil. Il n'y a rien d'autre à faire, et surtout rien à déplacer. C'est pour cette raison que `core/` reste vide en Light plutôt que d'être livré avec un Result Pattern inutilisé : une couche présente mais vide invite à y écrire du domaine par anticipation, ce que la Règle de Lazy Abstraction interdit. La couche naît de son premier habitant. **Cette phrase vise le profil Light, et elle ne dit pas que `result.ts` serait une abstraction anticipée.** En mode Full, le générateur le livre avec la couche, et c'est cohérent : la Règle de Lazy Abstraction porte sur les contrats et les adaptateurs, c'est-à-dire sur l'indirection qu'on ajoute pour une deuxième implémentation qui n'existe pas encore. Le Result Pattern n'est pas une indirection, c'est le type de retour que toute la couche emploie dès sa première fonction. Ce qui naît de son premier habitant, c'est la couche `core/`, pas le vocabulaire qu'elle parle. Le mouvement inverse n'est pas outillé, et c'est délibéré : retirer une couche domaine peuplée demande de décider où va chacune de ses règles, et cette décision n'appartient pas à un générateur. ### Choisir son profil à l'installation ```bash npx create-maedow-arch-app mon-projet --mode full # les quatre couches npx create-maedow-arch-app mon-projet --mode light # sans couche core ``` Sans drapeau, la CLI pose la question lorsqu'elle est lancée depuis un terminal, et retient `full` sinon. Chaque profil se décline en deux contenus : `--template demo` livre un compteur borné illustrant le profil choisi, `--template blank` livre l'arborescence seule. Le style se choisit de la même façon, `--style css` pour du CSS natif sans aucune dépendance, `--style tailwind` pour Tailwind CSS 4 configuré. Le défaut est `css` : le standard revendique l'agnosticisme d'infrastructure, et la démonstration doit prouver qu'aucun framework de style n'est nécessaire pour livrer quelque chose de soigné. Générer la même démonstration dans les deux profils et comparer les arborescences est le moyen le plus court de saisir ce que la séparation apporte, et ce qu'elle coûte. **Règle de bascule** : un projet démarré en Mode Light qui gagne en complexité (nouvelle feature qui duplique de la logique, besoin de tester le métier indépendamment de l'UI, montée en charge du produit) doit migrer progressivement vers le Mode Full, domaine par domaine, jamais en un seul refactor global. --- ## Maedow Arch hors Next.js Le standard revendique l'agnosticisme technique. Il doit donc valoir au-delà du framework qui a servi à le formuler. **Trois couches sur quatre ne bougent pas, à une condition.** `features/`, `core/`, `components/` et `lib/` ne connaissent ni routeur, ni convention de fichiers, ni rendu serveur. Elles sont portables telles quelles **si les écrans reçoivent leurs données au lieu d'aller les chercher**. Cette condition n'est pas une réserve de style : c'est elle qui fait tout tenir. Un projet dont les écrans appellent eux-mêmes leurs sources verra le coût du portage se répandre dans `features/`, et l'affirmation ci-dessus sera fausse pour lui. Le [chargement de données côté serveur](#le-chargement-de-données-côté-serveur) donne le test à faire sur votre propre code avant de vous lancer. **Seule la couche `app/` s'adapte.** Sa définition reste inchangée : point d'entrée, routing, injection de dépendances. Ce sont ses fichiers qui diffèrent, parce que chaque framework déclare ses routes à sa façon. | Responsabilité | Next.js App Router | React sur Vite | | :--- | :--- | :--- | | Point d'entrée | `app/layout.tsx` | `app/main.tsx` | | Coquille et injection | `app/layout.tsx` | `app/App.tsx` | | Déclaration des routes | l'arborescence de `app/` | `app/routes.tsx` | L'interdiction, elle, ne change jamais : aucune logique métier détaillée dans cette couche, quel que soit le framework. ### Le chargement de données côté serveur C'est le point où le portage se joue vraiment, et la question difficile de cette section. Les Server Components et les Server Actions n'ont pas d'équivalent hors Next.js. **La condition qui fait tenir l'affirmation.** Les trois couches ne bougent pas *à condition que vos écrans reçoivent leurs données au lieu d'aller les chercher*. Ce n'est pas le framework qui rend le portage possible, c'est la règle « zéro modèle dans le JSX » appliquée avant lui. Le test à faire sur votre propre code, avant de vous lancer : > Ouvrez trois écrans de `features/`. Reçoivent-ils leurs données en props, ou appellent-ils eux-mêmes une source ? Si les écrans reçoivent, ce qui suit vous coûtera une couche. **S'ils vont chercher, le portage vous coûtera les quatre**, parce que chaque écran devra être repris, et l'affirmation ci-dessus ne vaut pas pour votre projet. #### Deux chemins, deux destins La donnée lue par une route API et la donnée lue dans un Server Component ne se portent pas du tout pareil. **La route API se porte presque telle quelle.** Le corps de la fonction est identique, seuls son emplacement et sa signature d'export changent : ```typescript // Next : app/api/catalogue/route.ts export async function GET() { const resultat = verifierCatalogue(SOURCE); if (!resultat.ok) return Response.json({ erreur: resultat.error }, { status: 404 }); return Response.json(resultat.data); } ``` Le même corps se pose dans un gestionnaire Express, Hono ou Fastify. Rien à repenser. **Le Server Component, lui, n'a pas d'équivalent.** Il faut le remplacer, et le remplacement change de nature : | | Server Component | Effet client, hors Next | | :--- | :--- | :--- | | Moment de la lecture | pendant le rendu | après le montage | | États à exprimer | aucun | trois : chargement, erreur, données | | Route intermédiaire | inutile | obligatoire | | Annulation | rien à gérer | à gérer au démontage | | Premier rendu | avec les données | sans les données | | Coût mesuré | 27 lignes | 41 lignes | Les vingt-sept et quarante et une lignes ne sont pas une estimation : ce sont les deux versions du même écran, écrites et compilées pour rédiger cette section. ```tsx // Next : app/catalogue.tsx · la lecture se fait pendant le rendu export default async function Page() { const resultat = verifierCatalogue(SOURCE); if (!resultat.ok) return

Catalogue indisponible

; return ; } ``` ```tsx // Vite : app/CataloguePage.tsx · la lecture devient un effet, avec ses états export function CataloguePage() { const [articles, setArticles] = useState(null); const [erreur, setErreur] = useState(null); useEffect(() => { let annule = false; fetch("/api/catalogue") .then((r) => (r.ok ? r.json() : Promise.reject(new Error("indisponible")))) .then((donnees: Article[]) => { if (!annule) setArticles(donnees.map(versVue)); }) .catch(() => { if (!annule) setErreur("Catalogue indisponible"); }); return () => { annule = true; }; }, []); if (erreur !== null) return

{erreur}

; if (articles === null) return

Chargement…

; return ; } ``` **`CatalogueScreen` est le même fichier dans les deux projets, à l'octet près.** C'est tout l'objet du standard : l'écran ne sait pas d'où viennent ses données, donc il ne bouge pas quand leur provenance change. #### Ce que le compromis coûte vraiment Puisque la route API se porte facilement, la tentation est de tout y faire passer. **Ce serait acheter la portabilité au prix d'un aller-retour réseau permanent.** Un Server Component lit la donnée là où elle se trouve, pendant le rendu, sans que le navigateur n'ait rien à demander. Le remplacer par un `fetch` vers votre propre serveur ajoute un aller-retour, un temps d'affichage vide, et un état d'erreur à traiter, pour chaque écran converti. Sur une application de contenu, cela se voit. Le choix se pose donc à l'endroit habituel, et le standard ne le tranche pas à votre place : - **Vous restez sur Next et l'assumez** : gardez les Server Components, le portage éventuel coûtera cette conversion. - **La portabilité prime** : traitez les Server Components comme une optimisation locale, et gardez les routes API comme chemin par défaut. Vous payez le réseau tout de suite plutôt que la conversion plus tard. Dans les deux cas, la règle reste la même : **le chargement appartient à `app/`, jamais à l'écran.** C'est elle qui garantit que le choix ci-dessus se rejoue à un seul endroit, et non dans chaque feature. #### Ce qui a été vérifié Les deux projets de cette section ont été générés par la CLI, complétés du même domaine et du même écran, puis compilés et lintés. Après portage, `core/`, `features/` et `lib/` sont **identiques fichier pour fichier**, et tout ce qui diffère est dans `app/`. ### Ce que cela implique pour les frontières Rien. Les [règles de frontières](#règle-de-dépendance-et-frontières-maedow-arch-eslint-plugin-boundaries) portent sur des répertoires, pas sur des fichiers : `app` peut tout importer, une feature n'en importe pas une autre, `core` ignore l'interface. Ces énoncés ne mentionnent aucun framework. `eslint-config-maedow-arch` sert donc les deux, sans configuration distincte. C'est le même constat que dans [Ce que Light conserve](#ce-que-light-conserve) pour les profils Light et Full : les axes de variation d'un projet ne changent pas ses frontières. ### Choisir son framework à l'installation ```bash npx create-maedow-arch-app mon-projet --framework next # Next.js App Router npx create-maedow-arch-app mon-projet --framework vite # React sur Vite ``` Sans drapeau, la CLI pose la question lorsqu'elle est lancée depuis un terminal, et retient `next` sinon. ### Porter Maedow Arch vers un autre framework La démarche tient en trois questions, à poser dans cet ordre. 1. **Où démarre l'application ?** Ce fichier appartient à `app/`. 2. **Où sont déclarées les routes ?** Ce fichier appartient à `app/`. 3. **Où sont injectées les dépendances, contextes et fournisseurs ?** Ce fichier appartient à `app/`. Tout le reste va dans les couches inférieures, sans adaptation. Si l'une de ces trois réponses vous conduit à écrire de la logique métier dans `app/`, c'est le signe que cette logique appartenait à `core/` ou à la feature. ====================================================================== # Modélisation, zéro modèle dans le JSX ====================================================================== > **Guide de Conception des Types et Modèles de Données sous Maedow Arch** > Ce document formalise la séparation absolue entre les modèles de données métier et les composants d'interface graphique (JSX/TSX), tout en appliquant le principe de pragmatisme typé de Maedow Arch. --- ## Le Principe Maedow Arch : Pourquoi Séparer les Modèles du JSX ? Dans de nombreuses bases de code, les interfaces TypeScript sont écrites directement au-dessus des composants React (`.tsx`). **Maedow Arch** proscrit cette pratique pour 3 raisons fondamentales : 1. **Couplage Fort UI / Métier** : Un composant d'affichage devient responsable de la structure des données du domaine. Si l'UI change, le modèle risque d'être altéré. 2. **Impossibilité de Réutiliser ou Tester** : Pour tester une règle métier ou un calcul sur un modèle, il faut importer un fichier `.tsx`, ce qui charge inutilement l'environnement React/DOM. 3. **Pollution du Scope Visuel** : Le fichier `.tsx` doit se concentrer sur une seule responsabilité : **l'affichage et la gestion des événements de rendu**. --- ## La Typologie des Modèles dans Maedow Arch Maedow Arch distingue 5 catégories de modèles claires : ```mermaid flowchart TD ENT["1 · ENTITÉ MÉTIER
core/[domaine]/types.ts
Identité et règles"] PERS["2 · MODÈLE DE PERSISTANCE
core/db/schema.ts"] DTO["3 · DTO, LE CONTRAT D'API
core/[domaine]/dto.ts"] VUE["4 · MODÈLE DE VUE
features/[feature]/types.ts"] PROPS["5 · PROPS DE COMPOSANT
Composant.tsx"] ENT --> PERS ENT --> DTO ENT --> VUE VUE --> PROPS ``` En Mode Light, la couche `core/` n'existe pas : les trois premières catégories, qui y vivent, n'ont pas lieu d'être. Deux formes subsistent, et la règle « zéro modèle dans le JSX » vaut identiquement. ```mermaid flowchart TD VUE["MODÈLE DE VUE
features/[feature]/types.ts"] PROPS["PROPS DE COMPOSANT
Composant.tsx"] VUE --> PROPS ``` Les types métier d'un projet Light vivent dans `features//types.ts`, au plus près de leur usage. Basculer vers le Mode Full consiste alors à les extraire vers `core/`, domaine par domaine, jamais en un seul refactor global. ### L'Entité Métier (`core//types.ts`) Représente l'objet métier pur au sein du système. ```typescript // core/products/types.ts export interface Product { id: string; sku: string; name: string; priceCents: number; currency: "EUR" | "USD"; status: "draft" | "published" | "archived"; stockAvailable: number; } ``` ### Le Modèle de Persistance (`core/storage/` ou `core/database/`) Représente la structure physique en base de données, quand elle diffère de l'entité. **Quand elle ne diffère pas, ne la dupliquez pas.** Une table dont les colonnes reprennent les champs de l'entité au nommage près ne justifie pas un second type : c'est exactement le cas où la [Règle du Pragmatisme Typé](#règle-du-pragmatisme-typé-maedow-arch-anti-over-engineering) demande de s'abstenir. Un `ProductRow` qui n'apporte que le passage de `priceCents` à `price_cents` coûte un fichier, un mapper et deux endroits à modifier, sans rien protéger. La séparation se justifie quand **l'entité n'a pas la forme d'une table**, ce qui arrive dès qu'un agrégat en occupe plusieurs. ```typescript // core/orders/types.ts // L'agrégat, tel que le domaine le manipule : un objet, avec ses lignes. export interface Customer { id: string; name: string; email: string; } export interface OrderLine { sku: string; label: string; quantity: number; unitPriceCents: number; } export interface Order { id: string; placedAt: Date; customer: Customer; lines: OrderLine[]; totalCents: number; } ``` ```typescript // core/orders/rows.ts // Les trois tables, telles que la base les renvoie : à plat, liées par des // identifiants, et sans le total, qui se calcule. export interface OrderRow { id: string; customer_id: string; placed_at: string; } export interface OrderLineRow { order_id: string; sku: string; label: string; quantity: number; unit_price_cents: number; } export interface CustomerRow { id: string; full_name: string; email: string; } ``` La séparation n'a de sens que si quelque chose reconstruit l'un depuis les autres. C'est ce mapper qui porte la valeur, et non les types : ```typescript // core/orders/mapper.ts import type { Order, OrderLine } from "./types"; import type { CustomerRow, OrderLineRow, OrderRow } from "./rows"; function toLine(row: OrderLineRow): OrderLine { return { sku: row.sku, label: row.label, quantity: row.quantity, unitPriceCents: row.unit_price_cents, }; } export function toOrder(row: OrderRow, customer: CustomerRow, lineRows: OrderLineRow[]): Order { const lines = lineRows.filter((line) => line.order_id === row.id).map(toLine); return { id: row.id, placedAt: new Date(row.placed_at), customer: { id: customer.id, name: customer.full_name, email: customer.email }, lines, totalCents: lines.reduce((total, line) => total + line.quantity * line.unitPriceCents, 0), }; } ``` Trois choses se voient ici, et aucune ne se verrait sur un exemple 1:1. **Le domaine ignore le nombre de tables.** Un service qui reçoit un `Order` ne sait pas s'il vient d'une jointure, de trois requêtes ou d'un cache. Changer ce point ne touche que le mapper. **Le total n'existe pas en base, il se calcule.** Une entité peut porter des champs dérivés que la persistance ne stocke pas, et c'est précisément ce qu'un type de ligne ne saurait pas exprimer. **Le mapper est une fonction pure, donc testable sans base de données.** C'est là que se logent les erreurs de reconstruction, et c'est la seule partie qu'il vaut la peine de tester dans cette couche. ### Le DTO / Data Transfer Object avec Inférence Zod (`core//dto.ts`) Définit la charge utile validée aux frontières (API, formulaires, webhooks) en tirant parti de `z.infer` : ```typescript // core/products/dto.ts import { z } from "zod"; export const CreateProductSchema = z.object({ sku: z.string().min(3).max(20), name: z.string().min(1), priceCents: z.number().positive(), currency: z.enum(["EUR", "USD"]), }); // ✅ Inférence automatique du type DTO à partir du validateur export type CreateProductDTO = z.infer; ``` ### Le DTO de Sortie (`core//dto.ts`) Le DTO d'entrée décrit ce que le client a le droit d'**envoyer**. Le DTO de sortie décrit ce qu'il a le droit de **recevoir**, et il manque presque toujours. La fuite la plus courante n'est pas un secret dans un journal, c'est une entité rendue telle quelle : ```typescript // ❌ Ce qui part vers le client dépend de la forme de l'entité export async function getUser(id: string) { const user = await repository.findById(id); return user; // passwordHash et internalNotes partent avec } ``` Rien n'échoue. Le type de retour est correct, les tests passent, et le champ qu'on vient d'ajouter à l'entité pour un besoin interne se retrouve dans la réponse HTTP le jour même. C'est ce qui rend le défaut durable : il ne se manifeste jamais du côté où on le cherche. Le remède est le symétrique exact du DTO d'entrée. Un schéma décrit la sortie, et la fonction de sérialisation passe par lui : ```typescript // core/users/types.ts export interface User { id: string; name: string; email: string; passwordHash: string; internalNotes: string; role: "member" | "admin"; } ``` ```typescript // core/users/dto.ts import { z } from "zod"; import type { User } from "./types"; // Entrée : ce que le client a le droit d'envoyer. export const CreateUserSchema = z.object({ name: z.string().min(1), email: z.email(), }); export type CreateUserDTO = z.infer; // Sortie : ce que le client a le droit de recevoir. Décrit, jamais déduit. export const PublicUserSchema = z.object({ id: z.string(), name: z.string(), email: z.email(), role: z.enum(["member", "admin"]), }); export type PublicUserDTO = z.infer; export function toPublicUser(user: User): PublicUserDTO { return PublicUserSchema.parse(user); } ``` **La règle : ce qui sort vers le client est décrit, jamais laissé au hasard de la forme de l'entité.** Trois conséquences valent d'être dites. **Le schéma ne fait pas que valider, il filtre.** `parse` ne conserve que les clés qu'il décrit : `passwordHash` et `internalNotes` ne traversent pas, même si l'appelant les a oubliés dans l'objet transmis. **Un champ ajouté à l'entité ne sort pas tout seul.** C'est l'inverse d'un `Omit`, qui laisse passer par défaut et n'exclut que ce dont on s'est souvenu. Une liste d'exclusions est en retard d'un champ en permanence ; une liste d'inclusions ne l'est jamais. **Le retrait d'un champ de la sortie devient visible.** Il se lit dans le schéma, à un seul endroit, au lieu de se déduire de ce que chaque route renvoie. ### Le ViewModel d'Affichage (`features//types.ts`) Adapté aux besoins d'un écran spécifique. ```typescript // features/products-list/types.ts export interface ProductListItemView { id: string; title: string; formattedPrice: string; // Ex: "49,00 €" badgeTone: "success" | "warning" | "danger"; isOutOfStock: boolean; } ``` ### Les Props de Composant (`Composant.tsx`) Seul type autorisé directement dans un fichier `.tsx`, décrivant uniquement les paramètres d'entrée du composant. ```tsx // features/products-list/ProductCard.tsx import type { ProductListItemView } from "./types"; interface ProductCardProps { product: ProductListItemView; onSelect: (productId: string) => void; className?: string; } export function ProductCard({ product, onSelect, className }: ProductCardProps) { return (
onSelect(product.id)}>

{product.title}

{product.formattedPrice}
); } ``` --- ## Règle du Pragmatisme Typé Maedow Arch (Anti Over-Engineering) 1. **Cas Simple (1:1)** : Si la vue utilise exactement les mêmes champs que l'entité du domaine, utilisez un type alias direct ou `Pick` : ```typescript // features/user-details/types.ts import type { User } from "@/core/users/types"; // ✅ Pas de duplication inutile de champs export type UserDetailsView = Pick; ``` 2. **Inférence Zod Systématique** : Ne définissez jamais une interface manuelle en double d'un schéma Zod. Utilisez `z.infer`. 3. **Types de Formulaires** : Dérivez les types de formulaires depuis le schéma de validation du domaine (`type FormValues = z.infer`). --- ## Matrice de Localisation des Fichiers Maedow Arch | Type de Donnée / Logique | Extension | Dossier Cible | Exemple de Fichier | | :--- | :--- | :--- | :--- | | **Entités du Domaine** | `.ts` | `core//` | `core/user/types.ts` | | **Contrats de Services / Repositories** | `.ts` | `core//` | `core/user/repository.contract.ts` | | **Schémas de Validation (Zod/Valibot)** | `.ts` | `core//` | `core/user/validation.ts` | | **Machines d'États & Réducteurs Purs** | `.ts` | `core//` | `core/cart/reducer.ts` | | **ViewModels Spécifiques à un Écran** | `.ts` | `features//` | `features/checkout/types.ts` | | **Composants Métier Partagés** | `.tsx` | `features/_shared/` | `features/_shared/UserAvatar.tsx` | | **Hooks React Métier** | `.ts` | `features//hooks/` | `features/checkout/hooks/useCheckout.ts` | | **Composants d'Interface (Rendu)** | `.tsx` | `features//` | `features/checkout/CheckoutForm.tsx` | --- ## Exemple Comparatif Avant / Après ### ❌ Anti-Pattern : Modèle mélangé dans le JSX ```tsx // features/profile/UserProfile.tsx import React, { useState } from "react"; // ❌ Définition métier dans le composant d'UI export interface UserAccount { id: string; email: string; balance: number; } export function UserProfile() { const [user, setUser] = useState(null); return
{user?.email}
; } ``` ### ✅ Clean Pattern Maedow Arch : Découplage complet 1. **Modèle de Domaine (`core/user/types.ts`)** : ```typescript export interface UserAccount { id: string; email: string; balanceCents: number; } ``` 2. **Hook & ViewModel (`features/profile/hooks/useUserProfile.ts`)** : ```typescript import { useState } from "react"; import type { UserAccount } from "@/core/user/types"; export interface UserProfileView { email: string; formattedBalance: string; isVip: boolean; } export function useUserProfile(userId: string) { const [profile, setProfile] = useState(null); // Logique de chargement et transformation du domaine vers la vue return { profile, isLoading: false }; } ``` 3. **Composant Visuel Pur (`features/profile/UserProfile.tsx`)** : ```tsx import type { UserProfileView } from "./hooks/useUserProfile"; interface UserProfileProps { profile: UserProfileView; } export function UserProfile({ profile }: UserProfileProps) { return (

{profile.email}

Solde : {profile.formattedBalance}

{profile.isVip && VIP}
); } ``` ====================================================================== # Conventions, typage strict et Result Pattern ====================================================================== > **Standards de Développement et Règles de Qualité Logicielle sous Maedow Arch** > Ce document définit les conventions applicables à tout projet suivant les principes de Maedow Arch (*maedow-arch*). --- ## Règle d'Or Maedow Arch : Isolation des Responsabilités 1. **Les fichiers `.tsx` ne contiennent que du code de présentation** (JSX, layout, binding d'événements UI légers). 2. **Les fichiers `.ts` encapsulent toute la logique** (types, transformations, appels de services, validateurs, hooks). 3. **Les composants ne font pas d'appels directs à des bases de données ou API tierces** : ils consomment un hook ou un service d'abstraction. --- ## Conventions de Nommage et Extensions Maedow Arch | Type d'Élément | Format | Extension | Exemple | | :--- | :--- | :--- | :--- | | **Composant React d'Écran** | PascalCase | `.tsx` | `OrderSummaryCard.tsx` | | **Composant Métier Partagé** | PascalCase | `.tsx` | `features/_shared/AddressPicker.tsx` | | **Hook React** | camelCase (`use*`) | `.ts` | `useOrderPayment.ts` | | **Entité / Types Métier** | camelCase | `.ts` | `types.ts`, `entities.ts` | | **Service / Use Case** | camelCase | `.ts` | `calculateDiscount.ts`, `orderService.ts` | | **Schéma de Validation** | camelCase | `.ts` | `order.schema.ts`, `validation.ts` | | **Contrat d'Interface (Port)** | camelCase | `.ts` | `payment.contract.ts` | | **Adaptateur d'Infrastructure** | camelCase (`*.adapter.ts` ou `*.repository.ts`) | `.ts` | `stripePayment.adapter.ts`, `postgresOrder.repository.ts` | | **Test** | le nom de ce qu'il teste (`*.test.ts` ou `*.test.tsx`) | `.ts` / `.tsx` | `result.test.ts`, `CheckoutScreen.test.tsx` | --- ## Typage et TypeScript Strict sous Maedow Arch Pour éviter les erreurs silencieuses à l'exécution, les règles suivantes sont imposées : ```json // tsconfig.json (Extrait recommandé) { "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "exactOptionalPropertyTypes": true } } ``` ### Règles de programmation : * ⛔ **`any` est strictement interdit** : Utiliser `unknown` avec des gardes de types ou de la validation Zod/Valibot à l'entrée des frontières. * ⛔ **Double assertion interdite (`as unknown as TargetType`)** : Ne forcez pas les types, validez-les au moment du parsing. * ✅ **Inférence de types Zod (`z.infer`)** : Dérivez systématiquement les types de DTOs depuis les schémas de validation plutôt que de les dupliquer. * ✅ **Branded Types pour les identifiants critiques** : Pour éviter d'inverser accidentellement deux IDs de même type primitif (ex: `userId` et `organizationId`), utiliser des types opaques/marqués : ```typescript export type UserId = string & { readonly __brand: unique symbol }; export type OrganizationId = string & { readonly __brand: unique symbol }; ``` --- ### Vivre avec `exactOptionalPropertyTypes` C'est l'option de `strict` qui saute en premier dans un vrai projet, et sa désactivation entraîne rarement qu'elle seule : on la retire un vendredi soir, et le reste du strict suit dans les semaines qui viennent. Elle distingue deux choses que TypeScript confondait : **une propriété absente** et **une propriété présente valant `undefined`**. `{ auteur?: string }` accepte l'objet sans la clé, et refuse `{ auteur: undefined }`. C'est ce que vous voulez. Les deux ne se comportent pas pareil devant `Object.keys`, devant un `JSON.stringify`, devant un `PATCH` partiel ou devant une base de données, où l'un ne touche pas la colonne et l'autre l'efface. #### Reconnaître l'erreur Deux codes, selon l'endroit : | Code | Situation | | :--- | :--- | | `TS2375` | vous **affectez** : retour de fonction, variable typée, props JSX | | `TS2379` | vous **passez en argument** à une fonction | Le message est le même dans les deux cas, et sa dernière phrase est un piège : ``` error TS2375: Type '{ recherche: string | undefined; page: number; }' is not assignable to type 'Filtre' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties. ``` Le conseil de TypeScript, ajouter `undefined` au type cible, n'est le bon que dans un cas sur deux : celui où la cible vous appartient. #### Le type vous appartient : dites-le explicitement ```typescript // ❌ error TS2375 type Filtre = { recherche?: string; page: number }; export function construire(recherche: string | undefined): Filtre { return { recherche, page: 1 }; } ``` ```typescript // ✅ La clé peut être absente, ou présente et indéfinie. Les deux sont voulus. type Filtre = { recherche?: string | undefined; page: number }; export function construire(recherche: string | undefined): Filtre { return { recherche, page: 1 }; } ``` `?: string | undefined` n'est pas un pléonasme sous cette option, c'est la façon d'exprimer que les deux formes conviennent. Écrivez-la quand c'est vrai, et laissez `?: string` quand la clé ne doit vraiment pas exister. #### Le type ne vous appartient pas : n'écrivez pas la clé C'est le cas des SDK tiers, dont les types ont été écrits sans cette option. ```typescript // ❌ error TS2379 declare function envoyer(options: { url: string; token?: string }): Promise; export async function appeler(token: string | undefined) { await envoyer({ url: "https://exemple.test", token }); } ``` ```typescript // ✅ La clé n'apparaît que si elle a une valeur export async function appeler(token: string | undefined) { await envoyer({ url: "https://exemple.test", ...(token !== undefined && { token }) }); } ``` Le `&&` rend `false` quand la condition est fausse, et répandre `false` ne produit aucune clé. #### Effacer une propriété, c'est la retirer ```typescript // ❌ error TS2375 : `{ auteur: undefined }` n'est pas `{}` export function anonymiser(brouillon: Brouillon): Brouillon { return { ...brouillon, auteur: undefined }; } ``` ```typescript // ✅ La clé disparaît vraiment export function anonymiser(brouillon: Brouillon): Brouillon { const { auteur: _retire, ...reste } = brouillon; return reste; } ``` Cette erreur est la plus utile des quatre : elle signale un endroit où votre code croyait effacer une donnée alors qu'il écrivait `undefined` dedans. La différence est réelle dès qu'un `PATCH` ou un ORM lit cet objet. #### En React, les deux cas se présentent Si le composant est à vous, appliquez le premier remède : `couleur?: string | undefined`. S'il vient d'une bibliothèque, l'attribut absent remplace l'attribut indéfini : ```tsx // ✅ Deux appels plutôt qu'un attribut à undefined export function Etiquette({ couleurChoisie }: { couleurChoisie: string | undefined }) { return couleurChoisie === undefined ? ( ) : ( ); } ``` #### La limite : quand renoncer localement Les remèdes ci-dessus supposent un ou deux champs facultatifs. Devant un client d'API qui en compte six, le remède du spread conditionnel donne ceci : ```typescript // Correct, et illisible return requete({ url: e.url, ...(e.methode !== undefined && { methode: e.methode }), ...(e.entete !== undefined && { entete: e.entete }), ...(e.corps !== undefined && { corps: e.corps }), ...(e.delai !== undefined && { delai: e.delai }), ...(e.reprises !== undefined && { reprises: e.reprises }), }); ``` Le bon geste est alors de renoncer **localement**, une fois, et de le dire : ```typescript function sansIndefinis(objet: T): T { return Object.fromEntries( Object.entries(objet).filter(([, valeur]) => valeur !== undefined) ) as T; } export function appelLisible(e: Entree) { // Renoncement assumé : les six champs facultatifs de ce client devraient // être conditionnés un par un. L'assertion couvre cette ligne et rien // d'autre, et la fonction ci-dessus garantit ce qu'elle affirme. return requete(sansIndefinis(e) as Parameters[0]); } ``` **Une assertion confinée à trois lignes, commentée, adossée à une fonction qui fait réellement ce qu'elle prétend, vaut mieux qu'une option désactivée dans le `tsconfig.json`.** La première se relit et se retire le jour où le SDK corrige ses types ; la seconde emporte tout le fichier, puis tout le projet. C'est la seule forme de renoncement que ce standard accepte : locale, visible, et argumentée à l'endroit où elle s'applique. ## Gestion des Erreurs : Données Typées vs Exceptions (Result Pattern) Dans Maedow Arch, **les erreurs prévisibles et fonctionnelles sont modélisées comme des données**, pas comme des exceptions système. ### Le Pattern Résultat (Discriminated Union) : ```typescript // core/common/result.ts export type Result = | { ok: true; data: TData } | { ok: false; error: TError }; // Exemple d'usage métier export type CheckoutFailure = | { kind: "insufficient-funds"; amountMissing: number } | { kind: "item-out-of-stock"; itemId: string } | { kind: "payment-gateway-timeout" }; export async function processCheckout(orderId: string): Promise> { // Traitement... if (stockIssue) { return { ok: false, error: { kind: "item-out-of-stock", itemId: "123" } }; } return { ok: true, data: { transactionId: "tx_abc" } }; } ``` ### Helpers Obligatoires pour le Result Pattern (Anti-Verbosité) Sans helpers, le Result Pattern devient rapidement plus verbeux que les exceptions qu'il remplace (`if (!result.ok) { ... }` répété partout). Tout projet Maedow Arch doit définir ces helpers dès l'introduction du pattern : ```typescript // core/common/result.ts (suite) // Extrait la donnée ou retourne une valeur par défaut export function unwrapOr(result: Result, fallback: TData): TData { return result.ok ? result.data : fallback; } // Transforme la donnée si succès, propage l'erreur telle quelle export function mapResult( result: Result, fn: (data: TData) => TMapped ): Result { return result.ok ? { ok: true, data: fn(result.data) } : result; } // Pattern-matching explicite, aucune branche n'est oubliée export function match( result: Result, handlers: { ok: (data: TData) => TReturn; err: (error: TError) => TReturn } ): TReturn { return result.ok ? handlers.ok(result.data) : handlers.err(result.error); } // Enchaîne une opération faillible sur le succès de la précédente export async function andThen( result: Result | Promise>, fn: (data: TData) => Result | Promise> ): Promise> { const resolu = await result; return resolu.ok ? fn(resolu.data) : resolu; } // Agrège une liste de résultats, en s'arrêtant à la première erreur export function all(results: Result[]): Result { const donnees: TData[] = []; for (const result of results) { if (!result.ok) return result; donnees.push(result.data); } return { ok: true, data: donnees }; } ``` ### Enchaîner plusieurs opérations faillibles C'est le point où le Result Pattern tient ou s'écroule, et c'est celui qu'on documente le moins. `mapResult` transforme une donnée, mais son résultat n'est pas faillible. Un service qui enchaîne trois appels pouvant chacun échouer retombe donc sur l'imbrication que le pattern devait supprimer : ```typescript // ❌ Ce que le Result Pattern était censé faire disparaître const commande = await trouverCommande(id); if (!commande.ok) return commande; const autorisee = await autoriserPaiement(commande.data); if (!autorisee.ok) return autorisee; const encaissee = await encaisser(autorisee.data); if (!encaissee.ok) return encaissee; return { ok: true, data: encaissee.data }; ``` `andThen` enchaîne, et court-circuite à la première erreur : ```typescript // ✅ Trois étapes faillibles, une seule expression export async function payerCommande(id: string): Promise> { return andThen(andThen(trouverCommande(id), autoriserPaiement), encaisser); } ``` Chaque étape reçoit la donnée de la précédente et rend un `Result`. Dès qu'une échoue, les suivantes ne sont pas appelées et l'erreur remonte telle quelle, sans être enveloppée ni traduite. `all` sert le cas parallèle plutôt que séquentiel, typiquement une validation : ```typescript // ✅ Valider une liste, et s'arrêter à la première ligne fautive const lignes = panier.map(validerLigne); const validees = all(lignes); if (!validees.ok) return validees; // la première erreur, telle quelle ``` **Pourquoi `andThen` est asynchrone même quand l'étape ne l'est pas.** Deux variantes, l'une synchrone et l'autre non, obligeraient à choisir à chaque appel selon ce que fait l'étape suivante, c'est-à-dire à connaître son implémentation. Rendre une opération asynchrone cesserait alors d'être un détail interne et deviendrait une rupture de contrat pour tous ses appelants. **Exemple d'usage recommandé** (à documenter systématiquement dans le README d'un projet Maedow Arch) : ```typescript const checkoutResult = await processCheckout(orderId); match(checkoutResult, { ok: ({ transactionId }) => redirectToConfirmation(transactionId), err: (error) => showCheckoutError(error), }); ``` ### Par où les exceptions entrent, et où elles s'arrêtent Les cinq helpers ci-dessus supposent tous que ce qu'ils manipulent est un `Result`. Reste une question que le pattern ne résout pas de lui-même : **le monde extérieur, lui, lève.** Un client de base de données jette sur une contrainte violée, un `fetch` jette sur un délai dépassé, et aucune de ces exceptions n'est un `Result`. Sans convention, chaque dépôt improvise son `try/catch`, et il suffit d'un oubli pour qu'une exception traverse un `andThen` qui croyait n'enchaîner que des résultats. La chaîne est court-circuitée, et le service qui l'appelait n'a jamais vu passer l'erreur qu'il pensait gérer. `fromThrowable` est la porte d'entrée, et elle a **un seul endroit légitime** : ```typescript // core/orders/repository.ts import { fromThrowable, type Result } from "@/core/common/result"; type ErreurDepot = { kind: "indisponible" } | { kind: "conflit"; champ: string }; export function enregistrerCommande( commande: Commande ): Promise> { return fromThrowable( () => db.order.create({ data: commande }), (cause) => (estConflitUnicite(cause) ? { kind: "conflit", champ: "reference" } : { kind: "indisponible" }) ); } ``` **Dans `repository.ts`, jamais plus haut.** C'est la frontière entre ce qui parle au monde extérieur et le domaine, et c'est le seul endroit où une exception a encore un sens. Au-dessus, `core//service.ts` n'a plus à se demander si un appel peut lever : il reçoit un `Result` et compose. La fonction de conversion est obligatoire, et c'est délibéré. Elle force à décider ce que l'erreur signifie **pour le métier** : un `catch` qui rendrait l'exception telle quelle ferait remonter un objet de la bibliothèque jusqu'aux écrans, et le jour où l'on change de client de base de données, c'est la couche de rendu qui casse. --- ## Sécurité & Gestion des Données Sensibles 1. **Isolation Serveur / Client** : * Tout module manipulant des clés API secrètes, des tokens d'administration ou des accès directs à la base de données doit être restreint au serveur (`import "server-only"` ou convention `.server.ts`). 2. **Zéro Secret dans les Logs** : * Ne jamais logger d'objets bruts pouvant contenir des en-têtes d'autorisation, des mots de passe ou des tokens d'authentification. * Utiliser une allowlist d'en-têtes et de propriétés autorisées pour les rapports d'incidents. 3. **Validation Systématique aux Frontières** : * Toute entrée externe (requêtes HTTP, formulaires, webhooks, paramètres d'URL) doit être validée via un parseur de schéma (ex: Zod) avant d'atteindre le cœur de domaine. 4. **Sérialisation Décrite en Sortie** : * Ce qui part vers le client est décrit par un schéma, jamais laissé au hasard de la forme de l'entité. Voir [Le DTO de Sortie](./models.md) dans `models.md`. * La règle 2 protège les journaux, celle-ci protège les réponses. C'est la seconde qui fuit le plus souvent : un `return user` renvoie l'entité entière, rien n'échoue, et le champ interne ajouté ce matin part vers le client le jour même. * Une liste d'inclusions, jamais une liste d'exclusions. Un `Omit` laisse passer par défaut et n'exclut que ce dont on s'est souvenu : il est en retard d'un champ en permanence. --- ## Générateurs de Code & Productivité (Scaffolding Maedow Arch) Pour réduire le coût de création de fichiers liés au découplage de Maedow Arch, configurez un script de génération dans votre `package.json` : ```json // package.json { "scripts": { "generate:feature": "node scripts/scaffold-feature.mjs", "generate:domain": "node scripts/scaffold-domain.mjs" } } ``` Ce script initialise automatiquement : * `features//Screen.tsx` * `features//types.ts` * `features//hooks/use.ts` * `features//.test.tsx` --- ## Pyramide de Tests et Testabilité ### Où vivent les tests **À côté de ce qu'ils testent, toujours.** `core/billing/billing.test.ts` accompagne `core/billing/service.ts`, et `features/checkout/Checkout.test.tsx` accompagne son écran. Ce n'est pas un goût de rangement. Un test séparé de son sujet se met à mentir dès le premier déplacement de fichier : il continue de passer alors qu'il teste une version antérieure du comportement, ou il est oublié lors d'une suppression et devient un test orphelin qui vérifie du code mort. Colocalisé, il se déplace, se relit et se supprime avec ce qu'il couvre. C'est aussi ce que produisent les deux générateurs : `generate:domain` et `generate:feature` amorcent chacun leur test au bon endroit. Un projet Maedow Arch ne livre donc **aucun dossier `tests/`**. Une arborescence par nature de test, `unit/`, `integration/`, `e2e/`, oblige à ranger avant d'écrire, et la question « ceci est-il unitaire ou d'intégration ? » n'a aucune réponse utile pour un service de domaine qui appelle deux fonctions pures. ### Ce que chaque couche appelle un test La pyramide de Maedow Arch ne se lit pas en pourcentages, elle se lit en **coût d'exécution**. Chaque couche se teste par le moyen le moins cher qui prouve quelque chose. | Couche | Ce qu'on teste | Ce dont on n'a pas besoin | | :--- | :--- | :--- | | `core/` | les règles métier, les transitions d'état, les contrats | ni DOM, ni mock, ni rendu | | `lib/` | les fonctions d'aide, sur leurs cas limites | rien non plus | | `components/` | le rendu d'une primitive à partir de ses props | un moteur de rendu, sans données métier | | `features/` | la logique de vue dans son hook, puis le parcours de l'écran | pour le hook, aucun écran ; pour le parcours, un moteur de rendu et rien de simulé côté domaine | | `app/` | l'assemblage, de bout en bout, sur les chemins critiques seulement | un navigateur, donc le test le plus cher | La base large de la pyramide n'est pas une consigne de quantité, c'est une conséquence : **si le domaine est pur, ses tests sont si rapides et si simples à écrire qu'ils deviennent naturellement les plus nombreux.** Une pyramide qui ne se remplit pas par le bas signale que la logique a fui vers le haut, dans les écrans ou dans les routes, ce que la règle « zéro modèle dans le JSX » cherche précisément à empêcher. Reste à savoir où la remettre quand elle a déjà fui. Un écran qui porte plus de trois états, ou qui déclenche un appel réseau, ne se teste pas sans monter un arbre React : sa logique appartient à un hook de la feature, où elle redevient du TypeScript ordinaire. [`architecture.md`](./architecture.md) dit ce que `hooks/` reçoit, et donne la forme. C'est ce qui fait monter la pyramide au-dessus de `core/`, là où elle s'arrêtait. ### Ce que la pyramide ne dit pas **Elle ne fixe aucun taux de couverture.** Une exigence de couverture chiffrée sur `core/` produit des tests écrits pour la métrique : on teste les accesseurs et on saute la règle de gestion, parce que la première ligne compte autant que la seconde dans le calcul. L'exigence utile est vérifiable autrement, et elle l'est déjà : **`core/` doit se tester sans DOM, sans mock et sans rendu.** Un test du domaine qui a besoin de simuler quelque chose signale que le domaine dépend de quelque chose dont il ne devrait pas dépendre, et c'est une violation de frontière avant d'être un problème de test. ### En Mode Light La couche domaine n'existe pas, la base de la pyramide non plus. Les tests portent alors sur `lib/` et sur les écrans. C'est cohérent, et c'est même l'argument du profil : un site vitrine n'a pas de règles de gestion à protéger. Le jour où il en acquiert, `generate:domain` fait naître `core/` avec son premier test, et la pyramide se remplit par le bas. ====================================================================== # Registre des règles, MA-001 à MA-009 ====================================================================== Ce document recense les règles normatives du standard, leur donne un code stable, et dit pour chacune si elle est **vérifiée par la machine** ou **tenue par l'équipe**. Il existe pour une raison simple : un standard qui laisse croire que tout est appliqué est moins crédible qu'un standard qui distingue clairement ce qu'il outille de ce qu'il demande. Les deux catégories ont leur place. Ce qui n'en a pas, c'est le flou entre les deux. ## Comment lire ce registre **Vérifiée** signifie qu'une violation fait échouer le lint, donc la CI, avant d'atteindre la revue. La règle possède une fixture invalide qui échoue et une fixture valide qui passe : sans cette paire, une règle peut être chargée, valide, et ne rien regarder du tout, ce que ce dépôt a rencontré quatre fois et documente de [F-001](./FRICTIONS.md) à F-015. **Tenue par l'équipe** signifie que la règle relève de la revue et de la discipline. Ce n'est pas un aveu d'échec, et ce n'est pas une étape en attendant mieux : certaines exigences ne se réduisent pas à un motif syntaxique sans produire plus de faux positifs que de vraies détections. La règle reste normative, et sa violation reste une violation. Les deux règles qui restent dans cette catégorie, MA-008 et MA-009, **y sont par décision et non par manque de travail**. Chacune dit en quoi l'outiller coûterait plus qu'elle ne rapporte. Le rapport de sept règles vérifiées sur neuf n'est donc pas une dette à combler : c'est la part du standard qui se prête à la vérification automatique, et l'autre part est reconnue comme telle plutôt que passée sous silence. ## À quoi sert un code stable Les messages de lint renvoyaient jusqu'ici vers un titre de section. Un titre se réécrit, et le lien casse en silence : c'est arrivé au commit `71c0a3f`, quand la numérotation des titres a été retirée du corpus. Un code ne bouge pas. `MA-002` relie le message d'erreur, la section du corpus, la fixture qui la teste et l'entrée du journal, quelle que soit la façon dont chacun de ces textes évolue ensuite. Les codes ne sont jamais réattribués. Une règle retirée laisse son code vacant plutôt que de le céder à une autre. ## Le registre | Code | Règle | Statut | Où elle est appliquée | | :--- | :--- | :--- | :--- | | **MA-001** | Le flux `app → features → core → lib` est unidirectionnel | vérifiée | `boundaries/dependencies` | | **MA-002** | Une feature n'importe jamais une autre feature | vérifiée | `boundaries/dependencies` | | **MA-003** | `features/_shared/` ne connaît aucune feature | vérifiée | `boundaries/dependencies` | | **MA-004** | Zéro fichier `.tsx` et zéro JSX dans `core/` | vérifiée | `no-restricted-syntax`, `no-restricted-imports` | | **MA-005** | `any` interdit, `unknown` et gardes de type à la place | vérifiée | `@typescript-eslint/no-explicit-any` · entrée `strict` | | **MA-006** | Double assertion `as unknown as` interdite | vérifiée | `no-restricted-syntax` · entrée `strict` | | **MA-007** | Aucun cycle d'import entre modules | vérifiée | `import-x/no-cycle` · entrée `strict` | | **MA-008** | Les modules à secrets sont marqués `server-only` | tenue par l'équipe | revue | | **MA-009** | Les adaptateurs, dépôts et contrats suivent leur nommage | tenue par l'équipe | revue | > **Ce tableau n'est pas la liste complète de ce que le standard demande.** Six autres exigences, les [Règles de conception](#les-règles-de-conception-distinctes-des-neuf-codes), n'ont pas de code parce qu'elles s'apprécient au lieu de se constater. Elles ne font échouer aucun lint, et c'est précisément ce qui les rend faciles à ignorer : un projet réel a passé dix-neuf lots sans appliquer la Règle de Logique Extraite, avec des écrans montés à 443 lignes, pendant que ces neuf codes restaient tous au vert. --- ## MA-001 · Le flux de dépendance est unidirectionnel **Vérifiée.** `app` peut tout importer, une feature descend la pile, `core` ne connaît que `core` et `lib`, `components` reste présentationnel, et `lib` ne dépend que de lui-même. Le sens inverse est refusé. Énoncé dans [architecture.md](./architecture.md), section « Règle de dépendance et frontières ». Fixtures : `core/billing/render.ts` (core vers components), `core/billing/service.ts` (core vers feature) et `lib/bad.ts` (lib vers core) dans `packages/eslint-config-maedow-arch/test/fixtures/invalid/`. ## MA-002 · Une feature n'importe jamais une autre feature **Vérifiée.** Les imports internes à une feature restent libres. Ce qu'une feature partage avec une autre passe par `features/_shared/`, par `core/` ou par `components/`, jamais en ligne directe. Énoncé dans [architecture.md](./architecture.md), section « Règle de dépendance et frontières ». Fixture : `features/checkout/Screen.tsx`. ## MA-003 · `features/_shared/` ne connaît aucune feature **Vérifiée.** Le partagé est transverse par définition. S'il devait importer une feature, il n'aurait plus rien de transverse et deviendrait un fourre-tout, ce que la Règle de Dégradation décrit précisément. Énoncé dans [architecture.md](./architecture.md), section « Composition de features et éléments partagés ». Fixture : `features/_shared/Card.tsx`. ## MA-004 · Zéro `.tsx` et zéro JSX dans `core/` **Vérifiée.** Deux règles la portent, et il en faut bien deux. Le runtime JSX automatique ne demande aucun import de React : un composant peut donc vivre dans `core/` sans qu'aucune règle d'import ne se déclenche, ce qui a été constaté avant d'écrire la règle. `no-restricted-imports` seul aurait donné l'illusion d'une protection, en n'attrapant que le cas devenu rare où quelqu'un écrit encore `import React from "react"`. La règle vise le contenu, pas l'extension. Un fichier `.tsx` sans JSX dans `core/` ne casse rien, et un `.ts` ne peut pas en contenir : le parser le refuserait avant nous. Énoncé dans [architecture.md](./architecture.md), tableau des couches : « **ZÉRO fichier `.tsx`**, aucun import React/DOM ». Fixtures : `core/audit/Widget.tsx` pour le JSX sans import, `core/audit/useTheme.ts` pour la dépendance d'interface. ## MA-005 · `any` interdit **Vérifiée**, dans l'entrée `strict`. `unknown` avec des gardes de type, ou une validation à l'entrée des frontières. Énoncé dans [conventions.md](./conventions.md), section « TypeScript strict ». Fixtures : `strict-invalid/src/core/billing/types.ts` et sa contrepartie valide, qui montre la garde de type attendue à la place. ## MA-006 · Double assertion interdite **Vérifiée**, dans l'entrée `strict`. `as unknown as TargetType` force un type au lieu de le valider. La validation appartient au moment du parsing. Aucune règle publiée ne vise cette forme, d'où un sélecteur qui cible l'assertion vers `unknown` dont le parent est une autre assertion. Un `as unknown` isolé reste permis : c'est l'enchaînement qui pose problème, pas le passage par `unknown`. Énoncé dans [conventions.md](./conventions.md), section « TypeScript strict ». Fixtures : `strict-invalid/src/core/billing/cast.ts` et sa contrepartie valide. ## MA-007 · Aucun cycle d'import entre modules **Vérifiée**, dans l'entrée `strict`. Deux modules qui s'importent mutuellement ne peuvent plus être lus, testés ni déplacés séparément. Le cycle ne viole aucune frontière de couche, ce qui le rend invisible aux règles MA-001 à MA-003 : il se forme à l'intérieur d'une même couche. Cette règle a été introduite par le registre. Le corpus ne l'énonçait pas, alors qu'elle conditionne la testabilité que le standard revendique. Fixtures : `strict-invalid/src/core/billing/aller.ts` et `retour.ts`, qui forment un cycle sans franchir la moindre frontière. ## MA-008 · Les modules à secrets sont marqués `server-only` **Tenue par l'équipe.** Tout module manipulant des clés d'API, des jetons d'administration ou un accès direct à la base doit être restreint au serveur, par `import "server-only"` ou par la convention `.server.ts`. Énoncé dans [conventions.md](./conventions.md), section « Sécurité et données sensibles ». **Elle reste tenue par l'équipe par décision.** Reconnaître mécaniquement ce qui constitue un secret demanderait une liste de motifs de noms, qui signalerait des modules inoffensifs et manquerait ceux qui comptent. Une règle qui se trompe souvent finit désactivée, et l'exigence disparaît alors complètement. La revue, elle, sait lire ce que fait un module. ## MA-009 · Le nommage des adaptateurs, dépôts et contrats **Tenue par l'équipe.** `payment.contract.ts` pour un port, `stripePayment.adapter.ts` et `postgresOrder.repository.ts` pour ses implémentations. Énoncé dans [conventions.md](./conventions.md), table de nommage. **Elle reste tenue par l'équipe par décision.** Une règle de nom ne sait pas distinguer un adaptateur mal nommé d'un fichier qui n'en est pas un. Elle imposerait donc de nommer selon ce que la règle sait reconnaître, plutôt que selon ce que le fichier fait, ce qui est exactement l'inverse du but. --- ## Les Règles de conception, distinctes des neuf codes Le corpus porte une seconde famille d'exigences, qui n'a pas de code et n'en aura pas. Elle n'était recensée nulle part : les cinq règles ci-dessous vivaient dispersées dans trois documents, sans que rien ne dise ce qui les sépare du registre. | Règle | Ce qu'elle demande | Où elle est énoncée | | :--- | :--- | :--- | | **Règle d'Or Maedow Arch** | Une erreur attendue est une donnée typée, pas une exception | [`conventions.md`](./conventions.md) | | **Zéro Modèle dans le JSX** | Les types métier ne traversent pas la couche de rendu | [`models.md`](./models.md) | | **Règle du Pragmatisme Typé** | Ne pas dupliquer un modèle qui n'a pas divergé | [`models.md`](./models.md) | | **Règle de Lazy Abstraction** | Pas de contrat ni d'adaptateur avant la deuxième implémentation réelle | [`architecture.md`](./architecture.md) | | **Règle de Dégradation de `features/_shared/`** | Un composant partagé par une seule feature en redescend | [`architecture.md`](./architecture.md) | | **Règle de Logique Extraite** | Plus de trois états ou un appel réseau : la logique de vue passe dans un hook | [`architecture.md`](./architecture.md) | ### Ce qui les sépare d'un code `MA` **Un code se constate, une Règle de conception s'apprécie.** Un fichier `.tsx` est dans `core/` ou il n'y est pas : MA-004 se tranche sans discussion, et c'est ce qui permet à une machine ou à une revue de rendre un verdict. « La logique de vue est-elle extraite ? » n'a pas de réponse binaire, et le seuil de trois états est **indicatif** : un écran à quatre états ne viole rien, il mérite un regard. C'est aussi pourquoi elles ne figurent pas dans la clause de conformité. Se dire conforme, c'est affirmer que MA-008 et MA-009 sont tenues, deux questions auxquelles une revue répond par oui ou par non. Une clause qui reposerait sur une appréciation cesserait d'être une clause. **Elles ne sont pas pour autant facultatives.** Une Règle de conception ignorée ne fait échouer aucun contrôle, et c'est précisément son danger : un projet réel a passé dix-neuf lots sans appliquer la Règle de Logique Extraite, avec des écrans montés à 443 lignes, pendant que le lint, l'audit et les neuf règles restaient au vert. Ce que le standard vérifie mécaniquement ne dit rien de ce qu'il demande par ailleurs. ### Pourquoi ne pas leur donner un code quand même Un registre dont les entrées mêlent le constatable et l'appréciable perd ce qui fait sa valeur : le rapport de sept règles vérifiées sur neuf, et la raison donnée pour chacune des deux autres. Diluer ce compte avec des exigences qu'aucun outil ne pourra jamais trancher rendrait le registre moins crédible, pas plus complet. Le flou entre les deux catégories est ce que ce document existe pour supprimer. Les nommer et les recenser ici, sans les numéroter, est la façon de les rendre citables en revue sans effacer cette frontière. ## Se dire conforme à Maedow Arch Un projet est **conforme à Maedow Arch 1.0** lorsque les quatre conditions suivantes sont réunies. 1. **Les sept règles vérifiables passent.** `eslint-config-maedow-arch` est installé et son lint est vert, entrée `strict` comprise. C'est la seule condition qu'une machine constate. 2. **Les deux règles humaines sont tenues.** MA-008 et MA-009 relèvent de la revue, et se dire conforme, c'est affirmer qu'elle a lieu. 3. **Le typage est strict.** `strict`, `noUncheckedIndexedAccess` et `exactOptionalPropertyTypes` sont activés, et aucun n'est désactivé fichier par fichier. 4. **Les renoncements sont locaux et écrits.** Une assertion commentée à l'endroit où elle s'applique reste conforme ; une option retirée du `tsconfig.json` ne l'est pas. `npx maedow-arch check` mesure la première et la troisième. Il ne décerne pas la conformité : il dit ce qui vous en sépare. ### Ce qui constitue une rupture du standard Une rupture est un changement qui **rend non conforme un projet qui l'était**. C'est une notion distincte du versionnage des paquets : `eslint-config-maedow-arch` peut publier une version majeure sans que le standard bouge, et l'inverse est vrai aussi. Trois changements constituent une rupture : - **Une règle passe de tenue par l'équipe à vérifiée**, et fait donc échouer un lint qui passait. - **Une règle change de sens**, et interdit ce qu'elle autorisait. - **Une règle nouvelle rejoint l'entrée par défaut.** Ne constituent pas une rupture : l'ajout d'une règle à l'entrée `strict`, qui se charge explicitement ; la reformulation d'un message ; l'ajout d'une section au corpus ; le retrait d'une règle, qui ne peut que faire passer un lint qui échouait. Toute rupture est annoncée dans le `CHANGELOG.md` sous cette forme, avec le chemin de migration. Un standard qui change sans le dire ne vaut pas mieux qu'une convention d'équipe.