Une approche Domain-Driven Design pour le calcul des notes en PHP, avec des tests de référence sur 23 élèves × 2 trimestres × 8 instruments. Pas de « nous », juste un développeur, un professeur, et la rigueur de faire correspondre les mathématiques à la réalité.
Je construis un SaaS pour les professeurs portugais. Pas un système de gestion de l’apprentissage, pas un portail parents, pas encore une autre plateforme EdTech — juste l’outil spécifique et ciblé qu’un enseignant utilise pour suivre les notes des élèves sur plusieurs classes et trimestres, avec les calculs qu’exige le système d’éducation de base portugais (mesures d’inclusion DL 54/2018, notation multi-domaines, pondération des périodes, conversion en niveaux).
Le marché est dominé par Excel. Les enseignants construisent des feuilles de calcul qui calculent les moyennes trimestrielles, les notes finales pondérées et les conversions en niveaux. Ces feuilles de calcul sont fragiles, incohérentes d’un collègue à l’autre, et sujettes à des erreurs de formule silencieuses. Une mauvaise référence de cellule peut propager des erreurs à 28 élèves avant que quiconque ne s’en aperçoive.
Ma bêta-testeuse est une professeure de Mathématiques qui possède justement un tel fichier Excel. Réel, utilisé en production, affiné au fil des années. Vingt-trois élèves en 5e année, deux trimestres de données, huit instruments de notation répartis sur deux domaines. Mon travail consiste à reproduire exactement ce que fait son Excel — mais sous la forme d’une application web plus rapide, plus fiable, et capable de s’adapter à d’autres enseignants.
Cet article porte sur la rigueur d’ingénierie derrière ce mot « exactement ». Plus précisément : comment j’ai construit un moteur de calcul en lequel j’ai confiance, validé par rapport à la vérité terrain issue du tableur d’une vraie enseignante.
Le problème de calcul, en termes simples
Le calcul des notes d’un professeur portugais ressemble grossièrement à ceci :
Pour chaque trimestre :
- La classe dispose d’un modèle de critères de notation, organisé en domaines (par ex. « Connaissances » 60 %, « Attitudes » 40 %)
- Chaque domaine comporte des instruments avec des allocations de points explicites (Devoir = 40 pts, Travaux = 10 pts, Participation = 10 pts, etc.)
- Les points des instruments sur l’ensemble des domaines totalisent 100
- La note trimestrielle d’un élève est la somme des points obtenus sur chaque instrument
- Ce pourcentage est ensuite converti en un niveau (1 à 5) à l’aide de seuils — avec une particularité : les seuils sont fixés à ,45 % (par ex. 49,45 %, 69,45 %) plutôt qu’à ,5 %, de sorte qu’un élève avec 49,44 % se retrouve au niveau 2 tandis que 49,45 % correspond au niveau 3
Pour la note finale sur plusieurs trimestres :
- Trimestre 1 : direct (la note du trimestre elle-même)
- Trimestre 2 : 35% × T1 + 65% × T2
- Trimestre 3 : 33% × T1 + 34% × T2 + 33% × T3
Simple en théorie. Redoutable dans les détails.
Pourquoi cela nécessite une conception pilotée par le domaine
Si vous avez déjà travaillé dans l’univers des extensions WordPress, vous reconnaîtrez la tentation : tout entasser dans une gigantesque class GradeCalculator avec une douzaine de méthodes publiques, y intégrer directement des requêtes $wpdb, mélanger les préoccupations de présentation avec la logique de calcul, et expédier le tout.
J’ai refusé cette approche pour une raison : je dois pouvoir regarder l’enseignant dans les yeux et affirmer que les calculs sont corrects. Cela exige trois choses :
- La logique de calcul doit pouvoir être testée de manière isolée, sans démarrer WordPress
- Les données qui la traversent doivent être explicites et immuables
- Chaque règle métier (les seuils à 0,45 %, la pondération par période, la gestion des notes manquantes) doit résider en un endroit clairement identifié
La structure du code reflète ce parti pris :
includes/Domain/
├── Calculator/
│ ├── Calculator.php # Orchestrator
│ ├── InstrumentAggregator.php # Aggregates scores within an instrument
│ ├── PeriodAggregator.php # Applies the inter-term formula
│ ├── LevelConverter.php # Percentage → level mapping
│ ├── StudentScoreset.php # Immutable input data
│ └── GradeBreakdown.php # Immutable output data
├── Models/
│ ├── CriteriaTemplate.php # The grading template (domains, instruments)
│ └── Domain/Instrument/... # Sub-models
└── ValueObjects/
├── LevelThresholds.php # The .45% values, encapsulated
└── PeriodAggregationFormula.php # The 35/65 weights, encapsulated
L’intégralité du dossier Domain/ ne contient aucune instruction use pointant vers WordPress. Ni $wpdb, ni appels wp_*(), ni get_option(). Le Calculator peut être testé unitairement avec PHPUnit standard et serait transposable tel quel vers Laravel, Symfony ou un outil en ligne de commande.
La classe Calculator elle-même
Voici l’orchestrateur, dans son intégralité. Lisez-le une fois avant que je l’explique — sa structure elle-même vous indique ce qui se passe.
declare(strict_types=1);
namespace AvaliarDomainCalculator;
use AvaliarDomainModelsCriteriaTemplate;
use AvaliarDomainValueObjectsLevelThresholds;
use AvaliarDomainValueObjectsPeriodAggregationFormula;
final readonly class Calculator
{
public function __construct(
private InstrumentAggregator $instrumentAggregator = new InstrumentAggregator(),
private PeriodAggregator $periodAggregator = new PeriodAggregator(),
private LevelConverter $levelConverter = new LevelConverter(),
) {}
public function periodGrade(
CriteriaTemplate $template,
StudentScoreset $scores
): GradeBreakdown {
$pointsByInstrument = [];
$subtotalsByDomain = [];
$total = 0.0;
$maxTotal = 0.0;
foreach ($template->domains as $domain) {
$domainSubtotal = 0.0;
foreach ($domain->instruments as $instrument) {
$points = $this->instrumentAggregator->pointsFor($instrument, $scores);
$pointsByInstrument[$instrument->id] = $points;
$domainSubtotal += $points;
$maxTotal += $instrument->maxScore;
}
$subtotalsByDomain[$domain->id] = $domainSubtotal;
$total += $domainSubtotal;
}
return new GradeBreakdown($total, $maxTotal, $pointsByInstrument, $subtotalsByDomain);
}
public function finalGrade(
PeriodAggregationFormula $formula,
array $periodGradesByOrder
): float {
return $this->periodAggregator->finalGrade($formula, $periodGradesByOrder);
}
public function level(float $gradePercentage, LevelThresholds $thresholds): int
{
return $this->levelConverter->toLevel($gradePercentage, $thresholds);
}
}
Remarquez trois choses :
final readonly class — pas d’héritage, pas de mutation. Le Calculator est instancié une seule fois et appelé de nombreuses fois avec des entrées différentes. Il n’y a aucun état interne susceptible d’être corrompu.
Constructeur avec des dépendances instanciées par défaut — pour les tests, je peux injecter des agrégateurs bouchonnés ; en production, les valeurs par défaut suffisent. L’expression par défaut dans les constructeurs, disponible depuis PHP 8.1+, est particulièrement pratique pour cela.
Trois méthodes publiques, chacune avec un rôle précis — periodGrade(), finalGrade(), level(). Aucun point d’entrée « fait tout ». L’appelant les compose dans l’ordre que la logique métier requiert.
Les objets valeur : là où vivent les 0,45 %
Un défaut de conception courant consiste à laisser des « nombres magiques » dispersés dans le code. Les seuils de niveau portugais sont 19,45 %, 49,45 %, 69,45 %, 89,45 % — et une erreur de 0,05 % fait passer silencieusement des élèves d’un niveau à l’autre.
Plutôt que de les coder en dur dans le Calculator, j’en ai fait un objet valeur :
declare(strict_types=1);
namespace AvaliarDomainValueObjects;
final readonly class LevelThresholds
{
/**
* @param float[] $values Strictly increasing thresholds.
* For 5 levels (Portuguese system): [19.45, 49.45, 69.45, 89.45]
*/
public function __construct(public array $values)
{
// Validate: strictly increasing, all in [0, 100]
$previous = -INF;
foreach ($values as $v) {
if ($v < 0 || $v > 100) {
throw new InvalidArgumentException("Threshold {$v} out of range");
}
if ($v <= $previous) {
throw new InvalidArgumentException("Thresholds must be strictly increasing");
}
$previous = $v;
}
}
public function maxLevel(): int
{
return count($this->values) + 1;
}
}
La validation dans le constructeur attrape toute la catégorie de bugs où quelqu’un configure accidentellement [19.45, 49.45, 49.45, 89.45] (doublon) ou [19.45, 89.45, 49.45] (ordre incorrect). Mieux vaut échouer bruyamment à la création du modèle que de mal calculer silencieusement le niveau d’un élève six mois plus tard.
Le LevelConverter devient alors d’une simplicité désarmante :
declare(strict_types=1);
namespace AvaliarDomainCalculator;
use AvaliarDomainValueObjectsLevelThresholds;
final readonly class LevelConverter
{
public function toLevel(float $gradePercentage, LevelThresholds $thresholds): int
{
foreach ($thresholds->values as $i => $threshold) {
if ($gradePercentage < $threshold) {
return $i + 1;
}
}
return $thresholds->maxLevel();
}
}
Notez le strictement inférieur à (<). Un élève ayant exactement 49,45 % n’est pas en dessous du seuil — il appartient au niveau supérieur suivant. Cela correspond à ce que fait le fichier Excel de l’enseignant, et c’est le genre de détail qu’une erreur d’un seul signe égal rend indétectable sans valeur de référence.
Formule inter-périodes : là où Excel et PHP se rejoignent
La partie la plus délicate est la formule d’agrégation des périodes. Le trimestre 2 n’est pas simplement « la note du trimestre 2 » — c’est 0.35 × T1 + 0.65 × T2. Le trimestre 3 est 0.33 × T1 + 0.34 × T2 + 0.33 × T3. Et surtout, avant que le troisième trimestre ne dispose de données, la formule doit quand même produire un résultat — en utilisant 0 pour le trimestre manquant.
Voici le gestionnaire de formule :
declare(strict_types=1);
namespace AvaliarDomainCalculator;
use AvaliarDomainValueObjectsPeriodAggregationFormula;
final readonly class PeriodAggregator
{
public function finalGrade(
PeriodAggregationFormula $formula,
array $periodGradesByOrder
): float {
if ($formula->isDirect()) {
if ($periodGradesByOrder === []) {
return 0.0;
}
// Direct mode: use the highest-order period (the "current" one)
$maxOrder = max(array_keys($periodGradesByOrder));
return (float) $periodGradesByOrder[$maxOrder];
}
$sum = 0.0;
foreach ($formula->weights as $order => $weight) {
if (!isset($periodGradesByOrder[$order])) {
throw new InvalidArgumentException(
sprintf('Missing grade for period order %d', $order)
);
}
$sum += $periodGradesByOrder[$order] * ($weight / 100.0);
}
return $sum;
}
}
La décision intéressante ici est le throw en cas de période manquante. Le fichier Excel de l’enseignant, lorsqu’il rencontre un trimestre 3 absent, le traite comme 0 et calcule le résultat partiel. Ainsi, si T1 est à 70 %, T2 à 85 % et que T3 n’a pas encore commencé :
Final T3 = 0.33 × 70 + 0.34 × 85 + 0.33 × 0 = 52.0%
Ce chiffre n’a pas de sens en lui-même — un élève n’a pas réellement 52 %. Mais c’est ce que produit le fichier Excel, et c’est ce que l’enseignant utilise pour suivre « la trajectoire de cet élève ». Je reproduis ce comportement en exigeant que l’appelant passe explicitement 0 pour les périodes manquantes, plutôt que de le substituer silencieusement. L’exception oblige le code appelant à réfléchir à ce que signifie « manquant » dans chaque contexte.
La vérité terrain : le test qui compte
Voici ce qui distingue ce projet de la plupart des moteurs de calcul que j’ai rencontrés : je dispose du vrai fichier Excel de mon bêta-testeur, anonymisé, sous forme de fixture JSON. Vingt-trois élèves, deux trimestres de données, avec leurs pourcentages finaux et leurs niveaux tels que le fichier Excel les calcule.
La fixture se trouve dans tests/fixtures/ground-truth/ :
ground-truth-complete.json # everything: template + students + assessments
template.json # just the criteria configuration
students-summary.json # students with final percent + level per term
assessments.json # tests and assignments with per-question scores
Et le test qui boucle la boucle :
class GroundTruthTest extends TestCase
{
private const FIXTURES_DIR = __DIR__ . '/../../fixtures/ground-truth/';
private const DELTA = 0.001; // floating-point tolerance
private array $groundTruth;
protected function setUp(): void
{
$this->groundTruth = json_decode(
file_get_contents(self::FIXTURES_DIR . 'ground-truth-complete.json'),
true
);
}
public function testAllStudentsMatchTerm1GroundTruth(): void
{
$template = $this->buildTemplateFromFixture();
$calculator = new Calculator();
foreach ($this->groundTruth['students'] as $student) {
$term1 = $student['periods']['1P'];
$scoreset = $this->buildScoresetFor1P($student);
$breakdown = $calculator->periodGrade($template, $scoreset);
$level = $calculator->level(
$breakdown->percentage(),
$template->levelThresholds
);
$this->assertEqualsWithDelta(
$term1['final_percent'],
$breakdown->percentage(),
self::DELTA,
"Student {$student['number']}: Term 1 final percent divergence"
);
$this->assertEquals(
$term1['level'],
$level,
"Student {$student['number']}: Term 1 level divergence"
);
}
}
}
Le critère de validation est binaire : la sortie de mon Calculator correspond-elle à celle d’Excel, à 0,001 près ? Si c’est le cas pour les 23 élèves sur deux trimestres, je livre. Si un seul élève diverge, j’ai un bug.
assertEqualsWithDelta a toute son importance. Excel produit des valeurs comme 0.39999999999999997 lorsque ses formules comportent des opérations enchaînées. L’arithmétique flottante de PHP produira des résidus en virgule flottante différents. La tolérance de 0,001 détecte les vrais bugs (écart de deux points de pourcentage sur une limite de niveau) sans générer de faux positifs liés aux fantômes de l’IEEE 754.
Ce que j’ai appris
La vérité terrain change la nature de la conversation. Quand l’enseignant demande « êtes-vous sûr que les calculs sont corrects ? », je ne réponds pas « oui, je l’ai testé ». Je dis « voici le test qui compare vos 23 élèves avec votre Excel ; il passe ». C’est une tout autre forme de confiance.
L’isolation du domaine se rentabilise dès le premier changement WordPress. Le Calculator ignore totalement l’existence de WordPress. Lorsque je remplacerai un jour $wpdb par autre chose, ou que le calcul s’exécutera dans un traitement en lot en CLI, le code du Calculator ne bougera pas. Seuls les dépôts qui récupèrent les données et les transmettent au Calculator seront modifiés.
Les objets valeur détectent les bugs à la frontière. Le constructeur de LevelThresholds valide des valeurs strictement croissantes et comprises dans les plages autorisées. Si un futur formulaire d’administration permet à quelqu’un de configurer [20, 50, 50, 90], le système le rejette au moment de la sauvegarde du gabarit, et non à l’affichage des notes des élèves. Le rayon de destruction du bug se limite à une soumission de formulaire, et non à 28 élèves multiplié par deux trimestres.
L’arithmétique en virgule flottante est une exigence fonctionnelle, pas une note de bas de page. Les seuils à .45% ne sont pas là pour faire joli — ils existent parce que les valeurs arrondies à .5% créent des limites ambiguës. Le tableur Excel de l’enseignant utilise .45% délibérément. Mon calculateur PHP doit les utiliser également. Se tromper de 0.05 change quels élèves obtiennent le niveau 3 plutôt que le niveau 4, ce qui détermine s’ils valident leur année.
La suite
Le calculateur est en v1.0 — verrouillé, testé par rapport à la vérité terrain, utilisé en production par un enseignant. Les prochaines couches que je construis par-dessus :
- Substitutions de configuration : un enseignant peut déroger au modèle du département (pondérations différentes pour une classe spécifique), mais la logique centrale du calculateur ne change pas
- Mesures d’inclusion DL 54/2018 : les élèves bénéficiant d’adaptations curriculaires ont besoin de leurs propres modèles ; le calculateur reçoit un
CriteriaTemplatedifférent par élève lorsque nécessaire - Modèles multi-établissements : lorsqu’un établissement adopte le système, son coordinateur définit des modèles que tous les enseignants du département héritent
Rien de tout cela ne nécessite de toucher au calculateur. Cela requiert de nouveaux dépôts, de nouveaux services, de nouveaux objets valeur. Le calculateur reste un petit bout de code ennuyeux et bien testé qui fait une seule chose.
C’est l’objectif de la conception pilotée par le domaine, résumé : rendre les parties qui comptent suffisamment petites pour pouvoir les raisonner, et suffisamment petites pour pouvoir prouver qu’elles sont correctes.
Si vous construisez quoi que ce soit où les calculs doivent correspondre au modèle mental d’un expert métier — paie, notation, facturation, instruments scientifiques — et que vous ne savez pas comment structurer le code pour défendre les mathématiques, parlons-en. J’ai des convictions, et elles coûtent moins cher que de découvrir un bug de calcul six mois après le lancement.