Architecture modulaire
Frontières de modules, couplage, dépendances, responsabilités
Un module = une responsabilité, une frontière, un contrat. Si tu ne sais pas où mettre un fichier, c'est probablement qu'un nouveau module manque.
Le principe
Reciprok est organisé en modules métier indépendants. Chaque module :
- A une responsabilité claire (un seul "sujet" du domaine)
- A une frontière explicite (un
index.tsqui expose son contrat public) - N'expose que ce qui est nécessaire (les fichiers internes restent internes)
- N'a pas de dépendance circulaire (un module A peut dépendre de B, mais pas l'inverse)
- Est testable en isolation (ses dépendances sont injectées, pas importées en dur)
Structure d'un module
modules/[feature]/
├── [feature].routes.ts # HTTP layer (Elysia), pas de logique métier
├── [feature].service.ts # Logique métier, orchestration
├── [feature].repository.ts # Accès Drizzle, pas de logique
├── [feature].schema.ts # TypeBox / Zod locaux
├── [feature].types.ts # Types TS partagés du module
├── [feature].errors.ts # Erreurs typées du module
├── [feature].test.ts # Tests d'intégration
└── index.ts # Re-export PUBLIC uniquementResponsabilité de chaque couche
| Couche | Rôle | Ce qu'elle ne fait PAS |
|---|---|---|
| routes | Parser le HTTP, valider les inputs, déléguer au service, formater la réponse | Aucune logique métier, aucun appel direct à Drizzle |
| service | Logique métier, orchestration, appels à d'autres services, emit de domain events | Aucun appel SQL direct, aucune connaissance du HTTP |
| repository | Lire et écrire en base via Drizzle, retourner des entités typées | Aucune logique métier, pas de validation, pas d'emit |
Exemple concret : module requests
import { Elysia } from 'elysia';
import { requestsService } from './requests.service';
import { CreateRequestBody } from './requests.schema';
export const requestsRoutes = new Elysia({ prefix: '/requests', name: 'requests' })
.use(authMacro)
.post('/', async ({ body, user }) => {
return requestsService.create(body, { actorId: user.id });
}, {
body: CreateRequestBody,
auth: 'team',
});import { requestsRepository } from './requests.repository';
import { emit } from '@reciprok/domain/events';
import { RequestNotFoundError } from './requests.errors';
export const requestsService = {
async create(input: CreateRequestInput, ctx: { actorId: string }) {
const created = await requestsRepository.insert(input);
await emit('request.created', { requestId: created.id, ... }, { actor: 'user', actorId: ctx.actorId });
return created;
},
async byId(id: string) {
const found = await requestsRepository.findById(id);
if (!found) throw new RequestNotFoundError(id);
return found;
},
};import { db } from '@reciprok/db';
import { request } from '@reciprok/db/schema';
import { eq } from 'drizzle-orm';
export const requestsRepository = {
async insert(values: NewRequest) {
const [created] = await db.insert(request).values(values).returning();
return created;
},
async findById(id: string) {
return db.query.request.findFirst({ where: eq(request.id, id) });
},
};export { requestsRoutes } from './requests.routes';
export { requestsService } from './requests.service';
export { RequestNotFoundError } from './requests.errors';
export type { Request, CreateRequestInput } from './requests.types';Frontières strictes
Règle d'import
Un module n'importe jamais un fichier interne d'un autre module. Toujours via l'index.ts.
// ✅ Bon
import { requestsService } from '@/modules/requests';
// ❌ Mauvais, bypass de la frontière
import { requestsService } from '@/modules/requests/requests.service';Pourquoi
- Refactor sécurisé : on peut renommer ou splitter
requests.service.tssans casser le reste du code, tant queindex.tsreste stable - Contrat explicite : on sait exactement ce qu'expose un module en lisant son
index.ts - Pas de couplage caché : si un module a besoin d'un fichier interne d'un autre, c'est probablement qu'il y a un problème d'organisation
Lint
À configurer dès que possible :
{
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"paths": {
"@/modules/*/!(index)": "Use the module's index.ts barrel export"
}
}
}
}
}
}
}Note : Biome est en cours d'évolution sur ce point. Si la règle n'est pas dispo, fallback sur ESLint avec
eslint-plugin-boundaries.
Dépendances entre modules
Direction unique
Les modules forment un DAG (graphe acyclique). A peut dépendre de B, mais B ne peut pas dépendre de A.
Si on a besoin d'une dépendance bidirectionnelle, c'est un signal qu'il manque un module commun (ou que la responsabilité est mal coupée).
Couches transverses
Tous les modules ont accès à :
@/lib/*, utilitaires purs sans état (formatage, parsers, helpers)@/config/*, variables d'env validées@reciprok/db, schéma + client Drizzle@reciprok/domain, types métier partagés, codes d'événements
Ces "couches" ne sont pas des modules au même titre que les modules métier. Ce sont des fondations sur lesquelles tout le monde s'appuie.
Dépendances interdites
| Cible | Ne peut pas dépendre de |
|---|---|
@/lib/* | @/modules/* (lib est sans état métier) |
@/config/* | @/modules/* |
@reciprok/db | @/modules/* (la DB est sous le métier) |
@reciprok/domain | @/modules/* (les types métier viennent en amont) |
modules/A (repository) | modules/B (les repos ne se connaissent pas) |
modules/A (service) | modules/B/B.repository (toujours via le service de B) |
Tests par module
Chaque module a ses propres tests d'intégration, qui :
- Stub uniquement les services externes (Anthropic, Resend, Meta WhatsApp)
- Utilisent une vraie base Postgres (test container ou DB locale)
- Couvrent les chemins critiques métier, pas les détails d'implémentation
Pas de test unitaire isolé sur un repository Drizzle (peu de valeur). Les tests unitaires se concentrent sur la logique métier dans les services.
Quand créer un nouveau module ?
Crée un nouveau module si :
- Une nouvelle entité métier apparaît avec sa propre table et son propre cycle de vie
- Un sujet transverse (ex: notifications, audit log) commence à apparaître dans plusieurs modules
- Un fichier dépasse les seuils (cf. Seuils de fichiers) et son contenu est cohérent avec une responsabilité distincte
N'crée pas un module pour :
- Un seul fichier helper (mets-le dans
lib/) - Une feature qui appartient à un module existant
- Une "couche" technique sans responsabilité métier
Anti-patterns
Le "god module"
Un module core/ ou common/ qui contient tout. Interdit. Si quelque chose ne sait pas où aller, c'est qu'il faut clarifier sa responsabilité, pas le mettre dans une poubelle commune.
Le module "manager" trop générique
modules/manager/, modules/handler/, modules/utils/. Interdit. Un nom de module doit décrire un sujet métier, pas une catégorie technique.
Le bypass d'index
import { internalThing } from '@/modules/members/members.repository';Interdit. Si internalThing est utile à l'extérieur, il doit passer par l'index.ts. Sinon il reste interne.
Le couplage par "context global"
Stocker du métier dans un singleton global accessible de partout. Interdit. Utilise l'injection via appContext Elysia (derive / decorate), c'est typé et explicite.
Voir aussi
- Nommage, kebab-case et conventions par contexte
- Seuils de fichiers, quand splitter un module
- Design d'API, comment un module Elysia se compose