Aller au contenu

Architecture

Le backend SIM est l’API REST NestJS de GLOBAL SIM GROUP. Il expose une surface HTTP versionnée unique (/api/v1) pour tous les modules métier tout en gardant la couche d’accès aux données volontairement petite, typée et générée depuis PostgreSQL.

Une frontière d’accès PostgreSQL typée, générée depuis PostgreSQL lui-même.

La chaîne source est :

Migrations Flyway → PostgreSQL → pg_catalog → dbgen → src/db/generated.ts

src/db/generated.ts est commité car une PR de migration embarque son diff généré : les reviewers voient exactement ce que TypeScript croit avoir changé.

Couche Choix Notes
Runtime Node.js 22 LTS, TypeScript 5.7 package.json
Framework NestJS 11 sur Express 5 package.json
Versioning API URI /api/v1/<module> src/app.setup.ts
Accès base Kysely 0.29 + pg query builder typé
Base PostgreSQL 18 les migrations vivent dans sim-database
Auth JWT access + rotation refresh, permissions RBAC src/auth
Redis révocation de jetons, throttling distribué, files BullMQ src/redis
Stockage objet MinIO / S3 (@aws-sdk/client-s3) src/s3, src/uploads
Logs pino (nestjs-pino) src/main.ts, src/app.module.ts
Métriques prom-client /metrics
Docs API Swagger (/docs) src/app.setup.ts
PDF / impression Puppeteer 25.8.2 src/pdf, Dockerfile
Files de tâches BullMQ 6 src/jobs
Validation class-validator / class-transformer ValidationPipe global
Contrôleur Nest
│
▼
Service de domaine
│
▼
Repository
│
▼
DbAccess (surface CRUD figée)
│
▼
Kysely → pg → PostgreSQL

Il n’y a pas d’EntityManager, de UnitOfWork, d’IdentityMap ni de graphe d’entités façon ORM.

Le contrat d’architecture évite explicitement :

  • les relations / graphes d’entités à chargement paresseux
  • l’identity map / le change tracking
  • un DSL de requêtes générique au-delà du CRUD de base
  • les jointures automatiques
  • le cache au niveau des résultats DB
  • les migrations automatiques
  • l’abstraction de moteur de base
  • la « magie de repository » / les repositories auto-générés

Ce qu’il conserve :

  • les types dérivés de la base et les vues typées
  • un CRUD sûr avec protection à la compilation
  • des transactions explicites
  • le SQL PostgreSQL et les colonnes possédées par les triggers
  • la génération de code consciente du schéma
  • la détection de dérive (pnpm db:check)

Chaque module vit dans src/<module>/ avec une forme canonique :

src/<module>/
<module>.module.ts # câblage du module Nest
<module>.controller.ts # routage + Swagger + décorateurs d'auth
<module>.service.ts # orchestration, casts DTO → marques
<module>.repository.ts # SQL de domaine
<module>.dto.ts # classes de transport + class-validator
<module>.e2e.ts # tranche sur base réelle (transaction rollback)

Il n’y a pas de classe de base repository. Chaque repository est explicite et façonné par le domaine.

Les lectures utilisent scope.root. Les écritures utilisent scope.audit(idUtilisateur, (a, tx) => …).

src/db/access-scope.ts
@Injectable()
export class AccessScope {
readonly root: TxAccess;
audit<T>(
idUtilisateur: bigint | string | null,
work: (a: TxAccess, tx: Kysely<Database>) => Promise<T>,
): Promise<T> {
return withAudit(this.db, idUtilisateur, (tx) => work(txAccess(tx), tx));
}
}

withAudit positionne le GUC PostgreSQL sim.id_utilisateur dans une transaction :

src/db/audit.ts
export async function withAudit<T>(
db: Kysely<Database>,
idUtilisateur: bigint | string | null,
work: (tx: Kysely<Database>) => Promise<T>,
): Promise<T> {
return db.transaction().execute(async (tx) => {
if (idUtilisateur != null) {
await sql`SELECT set_config('sim.id_utilisateur', ${String(idUtilisateur)}, true)`.execute(tx);
}
return work(tx);
});
}

Cela rend l’identifiant de l’acteur disponible aux triggers audit.journal_audit sans fuiter entre connexions.

DbAccess fournit une surface figée :

  • findById / findOne / findMany / exists / count
  • insert / insertMany
  • update / delete

findMany n’accepte qu’une map d’égalité + orderBy + limit + offset. Au-delà, on écrit un tag sql ponctuel ou une méthode de repository de domaine.

insertInto, updateTable et deleteFrom sont typés contre TableKeys, pas keyof Database. Les vues sont en lecture seule à la compilation.

src/db/generated.ts expose :

Type Signification Exemple
BigIntStr bigint en chaîne exacte id as BigIntStr
Money montants FCFA NUMERIC(12,2) montant as Money
DecimalQuantity quantités NUMERIC(10,2) qty as DecimalQuantity
DateStr date naïve en chaîne d('2026-10-31')
TimestampStr timestamp naïf en chaîne new Date().toISOString() as TimestampStr
TimeStr time naïf en chaîne —
Json JSON / JSONB unknown

Database mappe les noms schema.table vers des interfaces de tables et de vues. ViewKeys liste les vues en lecture seule ; TableKeys les exclut.

  • Les valeurs sont toujours liées (sql${value}`).
  • Les identifiants utilisent sql.ref.
  • sql.raw est réservé au générateur (dbgen/dbgen.mjs).

dbgen analyse statiquement les corps de triggers et marque les colonnes affectées ins: never / upd: never. Si un trigger est trop opaque, il exige une déclaration explicite dans dbgen/dbgen.metadata.json.

Le generated.ts commité doit correspondre au catalogue PostgreSQL migré :

PostgreSQL frais → Flyway migrate → pnpm db:gen → git diff --exit-code → tsc --noEmit → tests

pnpm db:verify est l’application complète de cet invariant. À exécuter systématiquement après un changement de schéma.