Chapitre 622 minIntermédiaire
Faire évoluer une fonctionnalité : la maintenance évolutive
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- transformer une demande d'évolution en critères d'acceptation ;
- mener une analyse d'impact sur la base, le code, les tests et la doc ;
- distinguer un changement additif d'un changement qui casse ;
- migrer une base sans perdre les données existantes ;
- découper une évolution en petites étapes testées.
4 exercices corrigés1 schéma
La maintenance évolutive consiste à ajouter une fonctionnalité à une application existante, ou à modifier une fonctionnalité parce que le besoin a changé, sans casser ce qui fonctionne déjà. Elle se distingue d'un développement neuf par une contrainte permanente : l'existant (ses données, ses utilisateurs, les autres applications qui l'appellent) doit continuer de marcher pendant et après le changement. Ce chapitre suit une évolution réelle de bout en bout : de la demande aux critères d'acceptation, l'analyse d'impact, la rétrocompatibilité, la migration de la base, le découpage en étapes et les tests.
De la demande aux critères d'acceptation
Ticket #250, ouvert par la responsable comptable de Sorbier :
« Aujourd'hui, quand une ligne de frais pose problème, on refuse toute la note et on l'explique par mail. On voudrait pouvoir refuser une seule ligne, avec un motif que le salarié voit dans l'application. »
Une demande d'évolution est presque toujours incomplète. Avant de coder, reformule-la sous une forme vérifiable. Le format récit utilisateur (user story) oblige à dire qui, quoi et pourquoi :
En tant que comptable, je veux refuser une ligne de frais en indiquant un motif, afin que le salarié sache ce qui ne va pas sans que je refuse toute sa note.
Puis tu écris les critères d'acceptation, les conditions qui permettront de dire « c'est fait ». La forme « Étant donné, quand, alors » les rend directement transformables en tests :
| N° | Étant donné… | Quand… | Alors… |
|---|---|---|---|
| CA1 | une note clôturée et une ligne à vérifier | le comptable refuse la ligne avec le motif « Justificatif illisible » | la ligne passe à l'état « refusée » et le motif est enregistré |
| CA2 | une ligne à vérifier | le comptable refuse avec un motif de moins de 10 caractères, ou sans motif | le refus est rejeté avec un message explicite |
| CA3 | une note avec une ligne refusée | on calcule le total remboursé | la ligne refusée n'est pas comptée |
| CA4 | une ligne refusée | le salarié ouvre sa note | il voit l'état « refusée » et le motif |
| CA5 | un salarié connecté | il tente de refuser une ligne | l'action est interdite (403) |
| CA6 | les notes existantes, créées avant l'évolution | on les affiche ou on calcule leur total | rien ne change pour elles |
Écrire ces critères fait surgir les questions à poser au demandeur. Peut-on refuser une ligne d'une note déjà validée ? Le salarié peut-il corriger une ligne refusée et la soumettre à nouveau ? Le motif a-t-il une longueur minimale ? Faut-il prévenir le salarié par mail ? Chaque réponse précise le périmètre. Ici, la responsable répond : seulement sur une note clôturée et pas encore validée ; pas de nouvelle soumission pour l'instant ; 10 caractères au minimum ; pas de mail.
Rester dans le périmètre
Pendant l'analyse, des idées surgissent : « tant qu'on y est, on pourrait aussi ajouter le mail de notification », « et refondre l'écran du comptable ». Résiste. Chaque ajout non demandé allonge le travail, multiplie les risques de régression et retarde la livraison de ce qui a été demandé. Note les bonnes idées dans de nouveaux tickets, qui seront arbitrés comme les autres. À l'E6 SLAM, le conseil vaut double : en une heure, une évolution modeste, terminée et testée vaut mieux qu'une grande à moitié faite.
L'analyse d'impact
L'analyse d'impact répond à la question : qu'est-ce que cette évolution va toucher ? On la fait avant de coder, couche par couche, en s'aidant de la navigation de l'éditeur (Maj+F12) et de la recherche textuelle.
| Élément | Impact de l'évolution #250 |
|---|---|
| Base de données | Deux colonnes à ajouter à ligne_frais : statut et motif_refus |
| Données existantes | Les lignes déjà en base n'ont pas de statut : elles doivent devenir « à vérifier » |
| Dépôt (SQL) | Une requête de mise à jour ; les requêtes de lecture doivent renvoyer les nouvelles colonnes |
| Service | La validation du motif ; le calcul du total remboursé doit exclure les lignes refusées |
| API | Une nouvelle route PATCH /api/lignes/:id/refus, réservée aux comptables |
| Front | Un bouton et un champ pour le comptable ; l'affichage de l'état et du motif pour le salarié |
| Autres usages du total | totalNote sert au résumé, à l'export CSV et au tableau de bord : faut-il exclure les lignes refusées partout ? |
| Tests | Des tests pour chaque critère d'acceptation ; toute la suite pour la non-régression |
| Documentation | Le dictionnaire de données, la documentation de l'API, le guide du comptable, le changelog |
La ligne « autres usages du total » est typique de ce que révèle une analyse d'impact : une question métier que la demande ne tranchait pas. Faut-il exclure une ligne refusée de l'export comptable ? Oui, répond la responsable, puisqu'elle ne sera pas remboursée. Mieux vaut le découvrir maintenant qu'après la livraison.
Additif ou cassant : la rétrocompatibilité
Une évolution est rétrocompatible quand tout ce qui utilisait l'application avant continue de fonctionner sans changement : les anciennes données, les anciens écrans, les autres applications qui appellent l'API, les scripts. Pour la garantir, distingue les changements additifs, qui ajoutent sans retirer, des changements cassants, qui modifient ou suppriment ce qui existe.
| Changement additif (sûr) | Changement cassant (risqué) |
|---|---|
| Ajouter une colonne facultative, ou avec une valeur par défaut | Rendre obligatoire une colonne qui existe déjà |
| Ajouter une route d'API | Supprimer ou renommer une route |
| Ajouter un champ dans une réponse JSON | Retirer ou renommer un champ, en changer le type (texte en nombre) |
Ajouter une valeur possible à une liste (ENUM) | Retirer une valeur possible |
| Accepter plus de formats en entrée | Accepter moins de formats en entrée |
Version mineure : 1.4.3 → 1.5.0 | Version majeure : 1.4.3 → 2.0.0 |
L'évolution #250 est entièrement additive : deux colonnes avec une valeur par défaut, une route nouvelle, des champs ajoutés aux réponses. Elle sortira dans la version 1.5.0.
Quand un changement cassant est inévitable (renommer une colonne mal nommée, changer le format d'un champ d'API), on ne le fait pas d'un coup. On procède en trois temps, selon le modèle étendre, migrer, contracter (expand and contract) :
- Étendre : ajouter le nouveau à côté de l'ancien (une nouvelle colonne, une nouvelle route
/api/v2/…). Rien ne casse. - Migrer : recopier les données, puis basculer progressivement le code et les clients vers le nouveau. Pour une API publique, on annonce une date de fin de l'ancienne version.
- Contracter : quand plus personne n'utilise l'ancien, le supprimer. C'est le seul moment cassant, et il ne casse plus rien.
Faire évoluer la base sans perdre les données
La base de production contient des milliers de lignes de frais. La modifier se fait par un script de migration : un fichier SQL numéroté, versionné dans le dépôt avec le code, et exécuté une seule fois sur chaque base (développement, test, production).
-- migrations/007_refus_ligne.sql — évolution #250
ALTER TABLE ligne_frais
ADD COLUMN statut ENUM('a_verifier', 'acceptee', 'refusee') NOT NULL DEFAULT 'a_verifier',
ADD COLUMN motif_refus VARCHAR(255) NULL,
ADD CONSTRAINT ck_ligne_motif CHECK (statut <> 'refusee' OR motif_refus IS NOT NULL);
La valeur par défaut fait le travail pour les données existantes : toutes les lignes déjà en base reçoivent a_verifier, et la contrainte NOT NULL est respectée dès la création de la colonne. La contrainte CHECK traduit une règle métier dans la base : une ligne refusée a forcément un motif, quel que soit le programme qui l'écrit. MySQL applique les contraintes CHECK depuis la version 8.0.16 ; le chapitre CREATE TABLE et contraintes du cours SQL détaille ALTER TABLE.
On prépare aussi le script de retour arrière, au cas où la livraison devrait être annulée :
-- migrations/007_refus_ligne.down.sql
ALTER TABLE ligne_frais
DROP CHECK ck_ligne_motif,
DROP COLUMN motif_refus,
DROP COLUMN statut;
Avant de lancer une migration en production : une sauvegarde de la base, un essai de la migration (et du retour arrière) sur une copie récente, et une idée du temps qu'elle prendra sur une grosse table. Dans les projets plus outillés, un gestionnaire de migrations (Doctrine Migrations ou Phinx en PHP, Knex ou Prisma en Node.js) numérote les scripts, retient lesquels ont été exécutés sur chaque base et les applique dans l'ordre.
Découper en petites étapes
Une évolution se livre par étapes, chacune testée et commitée, l'application restant fonctionnelle entre deux étapes. Le bon découpage suit des tranches verticales : chaque étape traverse toutes les couches pour un petit morceau de fonctionnalité, plutôt que de faire toute la base, puis tout le back, puis tout le front.
| Étape | Contenu | Critères couverts |
|---|---|---|
| 1 | Migration, validation du motif, total remboursé, avec leurs tests | CA2, CA3, CA6 |
| 2 | Route PATCH /api/lignes/:id/refus, réservée aux comptables | CA1, CA5 |
| 3 | Écran du comptable : bouton « Refuser » et champ motif | CA1, CA2 |
| 4 | Écran du salarié : état et motif de chaque ligne | CA4 |
| 5 | Documentation et changelog | — |
Après l'étape 1, rien n'est encore visible, mais tout fonctionne. Après l'étape 2, un comptable peut refuser une ligne avec un outil comme Bruno ou curl. Si la livraison doit partir avant la fin, les étapes faites sont utilisables et sûres.
Le code de l'évolution
Étape 1 : le service. La validation du motif et le calcul du total, en réutilisant la conversion en centimes du chapitre 5 :
// services/lignes.js
const { enCentimes } = require('./notes');
const { RegleMetierError } = require('../erreurs');
const MOTIF_LONGUEUR_MIN = 10;
const MOTIF_LONGUEUR_MAX = 255;
function verifierMotif(motif) {
const texte = (motif ?? '').trim();
if (texte.length < MOTIF_LONGUEUR_MIN) {
throw new RegleMetierError(`Le motif doit faire au moins ${MOTIF_LONGUEUR_MIN} caractères`);
}
if (texte.length > MOTIF_LONGUEUR_MAX) {
throw new RegleMetierError(`Le motif ne doit pas dépasser ${MOTIF_LONGUEUR_MAX} caractères`);
}
return texte;
}
// Une ligne sans statut (objet construit avant l'évolution) compte comme « à vérifier ».
function totalRembourse(lignes = []) {
const centimes = lignes
.filter((ligne) => ligne.statut !== 'refusee')
.reduce((total, ligne) => total + enCentimes(ligne.montant), 0);
return centimes / 100;
}
module.exports = { verifierMotif, totalRembourse };
Le filtre statut !== 'refusee' est écrit à dessein : il exclut ce qui est refusé, au lieu de ne garder que ce qui est accepté. Ainsi, une ligne sans statut, ou à vérifier, continue de compter comme avant : c'est le critère CA6.
Les tests suivent les critères d'acceptation, et le dernier bloc protège l'existant :
// tests/lignes.test.js
const { verifierMotif, totalRembourse } = require('../services/lignes');
describe('refus d\'une ligne (évolution #250)', () => {
test('un motif trop court est refusé', () => {
expect(() => verifierMotif('Non')).toThrow('au moins 10 caractères');
});
test('un motif absent est refusé', () => {
expect(() => verifierMotif(undefined)).toThrow('au moins 10 caractères');
});
test('le motif est enregistré sans les espaces autour', () => {
expect(verifierMotif(' Justificatif illisible ')).toBe('Justificatif illisible');
});
test('une ligne refusée ne compte pas dans le total remboursé', () => {
const lignes = [
{ montant: '12.50', statut: 'acceptee' },
{ montant: '19.90', statut: 'refusee' },
{ montant: '7.30', statut: 'a_verifier' },
];
expect(totalRembourse(lignes)).toBe(19.8);
});
});
describe('non-régression', () => {
test('les lignes sans statut, comme avant l\'évolution, comptent toujours', () => {
expect(totalRembourse([{ montant: '12.50' }, { montant: '19.90' }])).toBe(32.4);
});
});
Étape 2 : la route. Elle réutilise les middlewares d'authentification et de rôle qui existent déjà :
// routes/lignes.routes.js
router.patch('/:id/refus', requireAuth, requireRole('comptable'), lignesController.refuser);
// controllers/lignes.controller.js
async function refuser(req, res) {
const motif = verifierMotif(req.body.motif); // RegleMetierError → 422
const modifiee = await lignesRepo.refuser(req.params.id, motif);
if (!modifiee) return res.status(404).json({ erreur: 'Ligne introuvable' });
res.status(204).end();
}
// repositories/lignes.repository.js
async function refuser(idLigne, motif) {
const [resultat] = await pool.execute(
"UPDATE ligne_frais SET statut = 'refusee', motif_refus = ? WHERE id_ligne = ?",
[motif, idLigne],
);
return resultat.affectedRows === 1;
}
Le contrôleur ne contient aucune règle : il appelle le service, le dépôt, et traduit le résultat en code HTTP. Une RegleMetierError levée par verifierMotif arrive au gestionnaire d'erreurs du chapitre 5, qui répond 422 avec le message. Le contrôle de l'état de la note (refus possible seulement sur une note clôturée) s'ajoute dans le service, avec son test, avant l'UPDATE. Côté API, un test d'intégration avec Supertest vérifie le critère CA5 : un jeton de salarié reçoit 403.
Tester une évolution
Une évolution se teste sur trois plans.
- Le nouveau comportement, critère par critère : un test au moins par critère d'acceptation, y compris les cas d'erreur (motif trop court, salarié qui tente de refuser).
- La non-régression : toute la suite existante, plus le test du critère CA6 sur les données d'avant l'évolution. Le calcul du total, l'export CSV et le tableau de bord doivent donner les mêmes résultats sur une note sans ligne refusée.
- La recette : les critères d'acceptation déroulés à la main, dans l'interface, idéalement par le demandeur lui-même. C'est elle qui dit si l'évolution répond au besoin, et pas seulement à ta compréhension du besoin.
Livrer l'évolution
La livraison suit le même chemin qu'un correctif, avec trois différences : le type de commit est feat, la version est mineure (1.4.3 → 1.5.0) puisque la fonctionnalité est nouvelle et rétrocompatible, et l'entrée du changelog va dans Ajouté. La documentation demande plus de travail qu'après un correctif : dictionnaire de données, documentation de l'API, guide du comptable et du salarié (chapitre 10).
feat(lignes): refus d'une ligne de frais avec motif (#250)
Exercices
Exercice 1 sur 4 Écrire des critères d'acceptation Réussi À revoir
Le responsable du parc de Sorbier demande : « Je voudrais que chaque réservation de véhicule indique sa destination, pour savoir où sont nos voitures. »
- Écris le récit utilisateur.
- Écris au moins quatre critères d'acceptation au format « Étant donné, quand, alors », dont un sur l'existant.
- Quelles questions poses-tu au responsable avant de commencer ?
Voir la réponseMasquer la réponse de l'exercice 1
- En tant que responsable du parc, je veux connaître la destination de chaque réservation, afin de savoir où se trouvent les véhicules.
- Par exemple :
- Étant donné le formulaire de réservation, quand un salarié réserve avec la destination « Lyon, client Darras », alors la réservation est enregistrée avec cette destination.
- Étant donné le formulaire, quand un salarié réserve sans destination, alors la réservation est refusée avec le message « La destination est obligatoire ».
- Étant donné une réservation avec destination, quand le responsable consulte le planning, alors il voit la destination de chaque réservation.
- Étant donné une réservation créée avant l'évolution, sans destination, quand on affiche le planning, alors elle s'affiche normalement, avec la mention « non renseignée ».
- La destination est-elle obligatoire pour toutes les réservations, ou seulement les nouvelles ? Texte libre ou liste de villes ? Longueur maximale ? Qui peut la voir : tout le monde, ou seulement le responsable (c'est une information sur les déplacements des salariés, donc une donnée personnelle à limiter au besoin) ? Faut-il pouvoir filtrer le planning par destination ?
Tu avais trouvé ?
Exercice 2 sur 4 Additif ou cassant ? Réussi À revoir
Pour chaque changement de l'API de Sorbier Frais, dis s'il est additif ou cassant, et donne la nouvelle version à partir de 1.5.0.
- Ajouter un champ
motifRefusdans la réponse deGET /api/notes/:id. - Renommer le champ
totalenmontantTotaldans la même réponse. - Corriger un bug dans le calcul du total, sans changer le format de la réponse.
- Ajouter une route
GET /api/notes/:id/pdf. - Rendre obligatoire le champ
commentairedansPOST /api/notes, jusqu'ici facultatif. - Accepter les montants avec une virgule (
"12,50") en plus du point.
Voir la réponseMasquer la réponse de l'exercice 2
- Additif :
1.6.0. Les clients qui ne connaissent pas le champ l'ignorent. - Cassant :
2.0.0. Tout client qui littotalreçoitundefined. À faire en « étendre, migrer, contracter » : renvoyer les deux champs pendant un temps, puis retirertotaldans une version majeure. - Ni l'un ni l'autre : un correctif,
1.5.1. - Additif :
1.6.0. - Cassant :
2.0.0. Les clients qui envoyaient des notes sans commentaire reçoivent maintenant une erreur 400. - Additif :
1.6.0. L'API accepte plus de formats qu'avant ; tout ce qui marchait marche encore.
Chaque numéro suppose que le changement est livré seul après la 1.5.0. Si plusieurs sortent ensemble, le plus fort l'emporte : un changement cassant, même accompagné d'ajouts, donne une version majeure.
Tu avais trouvé ?
Exercice 3 sur 4 Une migration de base Réussi À revoir
La table reservation de Sorbier Véhicules contient 8 400 lignes. Pour l'évolution « destination », un développeur propose :
ALTER TABLE reservation ADD COLUMN destination VARCHAR(100) NOT NULL;
- Quel est le problème ?
- Écris la migration correcte en trois instructions, sachant que les anciennes réservations doivent afficher « Non renseignée ».
- Écris le script de retour arrière.
Voir la réponseMasquer la réponse de l'exercice 3
- La colonne est obligatoire, mais les 8 400 lignes existantes n'ont pas de destination. Sous MySQL, elles reçoivent la valeur implicite d'un
VARCHAR, une chaîne vide : on ne distingue plus une ancienne réservation d'une saisie vide. D'autres SGBD refusent purement et simplement la commande. - Ajouter la colonne facultative, remplir l'existant, puis la rendre obligatoire :
ALTER TABLE reservation ADD COLUMN destination VARCHAR(100) NULL;
UPDATE reservation SET destination = 'Non renseignée' WHERE destination IS NULL;
ALTER TABLE reservation MODIFY destination VARCHAR(100) NOT NULL;
ALTER TABLE reservation DROP COLUMN destination;
On teste la migration et le retour arrière sur une copie de la base, après une sauvegarde de la production. La valeur « Non renseignée » est une décision métier : à valider avec le responsable du parc, qui préférera peut-être laisser NULL et afficher la mention dans l'interface seulement.
Tu avais trouvé ?
Exercice 4 sur 4 L'analyse d'impact Réussi À revoir
Sorbier Frais doit maintenant gérer un plafond mensuel par service : quand le total des notes validées d'un service dépasse son plafond, le comptable voit une alerte sur le tableau de bord. Dresse l'analyse d'impact : pour chaque élément (base, données existantes, dépôt, service, API, front, tests, documentation), indique ce qui change.
Voir la réponseMasquer la réponse de l'exercice 4
| Élément | Impact |
|---|---|
| Base | Une colonne plafond_mensuel DECIMAL(10,2) NULL dans service (ou une table plafond si le plafond peut changer d'une année sur l'autre et qu'il faut garder l'historique) |
| Données existantes | Les services existants n'ont pas de plafond : NULL signifie « pas d'alerte », ce qui conserve le comportement actuel |
| Dépôt | Une requête qui totalise les notes validées par service et par mois (SUM, GROUP BY, jointure note → utilisateur → service) |
| Service | Le calcul du dépassement, en centimes, avec ses cas limites (total égal au plafond : alerte ou pas ? à faire trancher) |
| API | Le tableau de bord renvoie les alertes ; une route pour que le comptable fixe le plafond de chaque service |
| Front | Le bandeau d'alerte du tableau de bord ; le formulaire de saisie des plafonds |
| Droits | Qui peut modifier un plafond ? Le comptable, ou seulement la direction ? |
| Tests | Dépassement, égalité, service sans plafond (non-régression), mois sans note validée |
| Documentation | Dictionnaire de données, documentation de l'API, guide du comptable, changelog (version mineure) |
Deux questions métier sortent de l'analyse : le cas d'égalité, et qui a le droit de fixer les plafonds. Elles doivent être posées avant le code.
Tu avais trouvé ?