Aller au contenu

Modules & couches

La décision

Le code de src/__new/ est découpé en modules par feature (src/__new/modules/<feature>/), flat — pas de sous-modules, pas d'arborescence profonde. Chaque module contient des dossiers par type de fichier (controllers/, services/, repositories/, business/, mappers/, guards/…), et chaque type a une responsabilité stricte.

Pas de modules NestJS, pas d'injection de dépendances : les services sont des objets exportés appelés par import statique, et les controllers sont enregistrés à la main dans app.module.ts.

Pourquoi des modules par feature, flat

  • Le legacy est organisé par couches techniques globales : la logique d'une feature est éclatée dans toute la codebase. Un module par feature regroupe ce qui change ensemble.
  • Flat, parce qu'une hiérarchie profonde est une décision permanente prise trop tôt : une soixantaine de modules au même niveau se scannent d'un ls et se nomment sans ambiguïté (nommage des modules).
  • Un dossier de couche n'existe que s'il a du contenu : seuls 7 modules ont un business/ aujourd'hui. On ne crée pas de structure vide.

Pourquoi zéro DI

La DI ajoute de la complexité et une orchestration qui ne paient pas :

  • Chaque service s'auto-instancie dans son fichier et s'exporte directement (export const XService = { ... }). L'appelant l'importe et l'appelle — pas de container, pas de wiring, pas de cycle de vie.
  • L'argument classique pour la DI est la substitution en test. Or on ne mocke pas les repositories : tests unitaires sur les fonctions pures (business/) et d'intégration sur de vraies Postgres/Redis pour le reste (tests d'intégration). Sans mock, la DI perd sa raison d'être.
  • C'est aussi un pas vers la sortie de NestJS : services, repositories, business et mappers sont du TypeScript pur, sans décorateur. NestJS ne survit que là où HTTP l'exige — controllers, guards, exception filter.

Quel code va dans quel fichier

Le test décisif, dans l'ordre — la première question qui matche gagne :

  1. Ça parle HTTP (status, headers, parsing de requête, session) ? → controller (ou guard si authn/authz)
  2. Ça touche la DB ? → repository, nulle part ailleurs
  3. Ça appelle un système externe (S3, Lemonway, Pappers…) ? → service du module, ou provider partagé dans __new/lib/providers/
  4. C'est une contrainte logique ou métier pure (mêmes inputs → mêmes outputs, zéro I/O, zéro horloge) ? → business
  5. Ça construit la shape de réponse API ? → mapper
  6. Ça coordonne tout ça (ordre des appels, transaction, assemblage d'entités, propagation d'erreurs) ? → service
  7. Helper technique sans signification métier (remapping, parsing) ? → lib/ du module, ou __new/lib/ si transverse

controllers/ — la couche HTTP, et rien d'autre

L'auth (guards) et la validation des payloads : c'est tout ce que la couche HTTP a le droit de faire. Un controller parse le body (bodySafeParse_zod), appelle un service, traduit le Result (throwApiError sur Err), retourne la réponse.

Si le service ne serait qu'un proxy, le controller appelle le repository directement — un CRUD trivial n'a pas besoin de trois couches (ex. internal-investor.controller.ts). Le service devient obligatoire dès qu'il y a transaction, plusieurs repositories, un appel externe ou un check de flow.

Jamais dans un controller : transaction, orchestration multi-repo, branchement métier.

services/ — la plomberie, et les side effects

Le service orchestre : ordre des appels, transactions (ky_safePgTransaction), assemblage d'entités avec leurs defaults, checks de flow (existence, bon statut avant transition), traduction de l'absence en erreur métier quand le flow l'exige.

Les side effects vivent ici (ou dans un repository). Deux corollaires :

  • Une intégration externe propre au module est un service du module (cible : ProjectFinancingRequestS3Service), construit sur les helpers génériques de __new/lib/. Seules les intégrations partagées entre modules vivent dans __new/lib/providers/.
  • L'assemblage et le merge d'entités sont du ressort du service — voir getOrCreate et le merge des lots dans project-presentation.service.ts. Ça ne bascule en business/ que si la règle de merge devient une vraie règle de domaine à tester unitairement.

Jamais dans un service : requête SQL/Kysely directe, calcul métier complexe (→ business/).

Entorse connue

Le S3 de PFR vit dans __new/lib/providers/s3/project-financing-request-s3.ts alors qu'il est mono-module — passé à la trappe en review. La cible était un service du module. À ne pas reproduire ; à migrer à l'occasion.

repositories/ — le seul accès à la base

Un repository ≈ une table. Seul endroit qui importe Kysely. Il parle en types du domaine (schemas zod), valide chaque row lue (ky_parseRows / ky_parseOneRow), exige trx sur toute mutation.

Le contrat de présence est porté par le verbe (naming) : get* = présence attendue, find* = T | undefined nullable-by-design. C'est l'appelant qui choisit le verbe selon que l'absence fait partie du chemin nominal ou non — le repository n'interprète pas l'absence en erreur métier. Ses seules erreurs propres sont les erreurs d'intégrité (validation-pg : la row ne valide pas le schema).

Jamais dans un repository : décision métier, fuite du query builder vers l'extérieur.

business/ — les contraintes logiques et métier, pures

business/ ne contient que des contraintes logiques ou métier : calculs (compute*), sélection de règles (resolve*), évaluation de décisions (evaluate*). Zéro side effect — pas de DB, pas de provider, pas de logger, pas de new Date() (l'horloge se passe en paramètre). Couverture de test à 100 % — ex. compute-project-financing-request-steps.ts.

Deux garde-fous pour rester pragmatique :

  • Une ligne ne justifie pas l'extraction. Un garde-fou de flow (status !== 'draft'conflict) reste inline dans le service. On extrait quand la logique a des branchements, de l'arithmétique, une table de cas — quand le test unitaire a du sens.
  • L'utilitaire n'est pas du métier. Du remapping ou du parsing sans signification domaine va dans lib/, pas dans business/.

Quand une décision a besoin de la DB au milieu : on coupe — le service fetch, le business décide.

mappers/ — la projection vers la Response

Le mapper construit la shape de réponse API depuis les shapes internes. Il reçoit toutes ses sources en paramètres et les merge — fonction pure ; les side effects restent dans service et repository.

Entorse connue

project-presentation.mapper.ts signe lui-même les URLs S3 (effet de lecture asynchrone, fail-fast si une signature échoue). La cible : le service calcule les URLs et les passe au mapper.

guards/ — authn/authz

Les guards NestJS vérifient session et appartenance, et enrichissent request avec du contexte typé (ex. request.projectFinancingRequestOwnerId dans project-financing-request-owner.guard.ts). Avec les controllers, c'est la seule couche autorisée à throw — frontière HTTP, voir Erreurs & Result. Rien d'autre que du contrôle d'accès.

lib/ (module) et __new/lib/ (kernel)

lib/ de module : helpers techniques purs, spécifiques au module — ni métier ni plomberie. Ex. parse-presentation-id.ts (path param → id brandé), presentation-images-prefix.ts (préfixe clé S3).

__new/lib/ : kernel partagé cross-modules. Ex. bodySafeParse_zod, ky_safePgTransaction, providers/ (Lemonway, S3, Slack…).

Entry points additionnels

Au même niveau que controllers/ : tasks/ (jobs graphile-worker), state-watcher/ (suivi de machines à états, cf. primary-purchase). Pas de wrapper entrypoints/ : trop générique, abandonné.

Frontières entre modules — guideline

La cible : chaque module a ses services privés et publics, et les voisins ne consomment que la surface publique — jamais les repositories d'un autre module, surtout pas en écriture. Aujourd'hui c'est une guideline tenue par la review, pas un sujet outillé ni prioritaire (dependency-cruiser est une piste si le besoin se confirme).

Références