Reciprok Docs

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 :

  1. A une responsabilité claire (un seul "sujet" du domaine)
  2. A une frontière explicite (un index.ts qui expose son contrat public)
  3. N'expose que ce qui est nécessaire (les fichiers internes restent internes)
  4. N'a pas de dépendance circulaire (un module A peut dépendre de B, mais pas l'inverse)
  5. 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 uniquement

Responsabilité de chaque couche

CoucheRôleCe qu'elle ne fait PAS
routesParser le HTTP, valider les inputs, déléguer au service, formater la réponseAucune logique métier, aucun appel direct à Drizzle
serviceLogique métier, orchestration, appels à d'autres services, emit de domain eventsAucun appel SQL direct, aucune connaissance du HTTP
repositoryLire et écrire en base via Drizzle, retourner des entités typéesAucune logique métier, pas de validation, pas d'emit

Exemple concret : module requests

modules/requests/requests.routes.ts
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',
  });
modules/requests/requests.service.ts
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;
  },
};
modules/requests/requests.repository.ts
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) });
  },
};
modules/requests/index.ts
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.ts sans casser le reste du code, tant que index.ts reste 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 :

biome.json (extrait)
{
  "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

CibleNe 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 :

  1. Stub uniquement les services externes (Anthropic, Resend, Meta WhatsApp)
  2. Utilisent une vraie base Postgres (test container ou DB locale)
  3. 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

On this page