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

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

Prêt à vous lancer ?

La newsletter qu'on n'ignore pas

Abonnez-vous à notre newsletter pour recevoir nos derniers articles, retours d'expérience et conseils tech directement dans votre boîte mail.

Désinscription en un clic. Vos données restent privées.