Formulaire multi-étapes avec sauvegarde progressive : notre architecture NestJS

10/03/2026
Sur un projet legaltech, on a dû construire un formulaire de 17 étapes avec upload de documents, reprise de session, et rejet par étape côté admin. Voici l'architecture qui a tenu en prod.
Le besoin
L'utilisateur devait fournir des données personnelles, des choix fiscaux, et des documents justificatifs sur 17 étapes. Personne ne fait ça en une session. Il fallait pouvoir :
- Sauvegarder chaque étape indépendamment
- Reprendre là où on s'est arrêté, même 3 jours plus tard
- Permettre à l'admin de rejeter une étape spécifique sans toucher aux autres
- Renvoyer l'utilisateur directement sur l'étape à corriger
Le modèle : une entité par étape, pas par champ
La tentation naturelle, c'est de créer un gros modèle avec une colonne par champ. Avec 17 étapes et des dizaines de champs, ça devient un monstre ingérable. On a pris l'approche inverse : une entité générique qui stocke les données de chaque étape en JSON.
@Entity({ name: 'forms' })
export class FormsEntity extends BaseEntity {
@Column({ nullable: false })
data: string; // JSON stringifié
@Column({ nullable: false })
step: string; // ex: "nationality", "activity", "payment"
@Column({ nullable: true })
validated: boolean;
@ManyToOne(() => RecordEntity, (record) => record.forms)
@JoinColumn({ name: 'recordId' })
recordId: RecordEntity;
}
Chaque étape = une ligne en base. Le champ data contient le JSON de l'étape. Le champ step identifie de quelle étape il s'agit.
Sauvegarder et récupérer une étape
L'API expose deux endpoints simples :
// Sauvegarder une étape
@Post('/forms')
async saveStep(@Body() dto: SaveFormDto, @GetCurrentUser() user) {
// Crée ou met à jour la ligne pour ce step
}
// Récupérer une étape
@Get('/forms/:step/records/:recordId')
async getStep(@Param('step') step: string, @Param('recordId') recordId: string) {
return this.formsRepository.findOne({ where: { recordId, step } });
}
Le frontend appelle saveStep à chaque fois que l'utilisateur passe à l'étape suivante (ou clique "sauvegarder"). Au chargement, il appelle getStep pour pré-remplir le formulaire.
Les 17 étapes typées
Chaque étape a son propre type TypeScript dans un dossier types/steps/ :
placeOfBirth, nationality, activity, commercialName,
contactDetails, address, secondAddress, socialSecurityNumber,
socialCotisation, revenuRhythm, hasAcre, identity,
pouvoirConfie, proofOfResidence, proofOfNonConviction,
diploma, payment
Le data JSON est parsé côté service et validé contre le type de l'étape. Si l'étape est nationality, on vérifie que les champs obligatoires (pays, date de naissance, visa éventuel) sont présents.
Le rejet par étape côté admin
L'admin peut rejeter une étape spécifique :
POST /admin/records/:id/reject/:step
Le système passe le dossier en statut MISSING_INFORMATION et envoie un email à l'utilisateur avec un lien direct vers l'étape à corriger (/formulaire?step=nationality). L'utilisateur corrige uniquement cette étape — ses 16 autres étapes restent intactes.
C'est le gros avantage de l'approche "une ligne par étape" : on peut invalider une étape sans toucher aux autres.
Les trade-offs
Ce qu'on gagne :
- Flexibilité totale — ajouter une étape = ajouter un type, pas une migration
- Rejet ciblé — l'admin rejette l'étape 3 sans impacter l'étape 12
- Reprise de session — chaque étape est sauvegardée indépendamment
- Évolution facile — modifier les champs d'une étape n'impacte pas les données existantes des autres étapes
Ce qu'on perd :
- Pas de requêtes SQL sur les champs individuels (le JSON n'est pas indexable facilement)
- Validation à deux niveaux : le DTO valide la structure, le service valide le contenu métier
- Le
JSON.parse()/JSON.stringify()est un risque si on n'est pas rigoureux sur le typage
Notre tip principal
Ne stockez pas tout dans un seul JSON géant. On a vu des projets avec un seul document JSON de 200 champs. C'est impossible à rejeter partiellement, impossible à valider étape par étape, et une source de bugs quand deux onglets sauvegardent en même temps.
Une ligne par étape, c'est plus de lignes en base, mais c'est infiniment plus simple à gérer.
En contexte : on a utilisé ce pattern sur un projet legaltech d'automatisation INPI (17 étapes, 8 types de documents, 11 statuts de dossier).
Stack concernée : NestJS, TypeORM, PostgreSQL, React
