A Domain-Driven Design approach to grade calculation in PHP, with ground truth testing against 23 students × 2 terms × 8 instruments. No “we”, just one developer, one teacher, and the discipline of making the maths match.
I’m building a SaaS for Portuguese teachers. Not a learning management system, not a parent portal, not yet another EdTech platform — just the specific, narrow tool a teacher uses to track student grades across multiple classes and terms, with the calculations that the Portuguese basic education system requires (DL 54/2018 inclusion measures, multi-domain grading, period weighting, level conversion).
The market is dominated by Excel. Teachers build spreadsheets that calculate term averages, weighted final grades, and level conversions. These spreadsheets are fragile, inconsistent between colleagues, and prone to silent formula breakage. A bad cell reference can propagate errors to 28 students before anyone notices.
My beta tester is a Maths teacher with such an Excel. Real, used in production, refined over years. Twenty-three students in 5th grade, two terms of data, eight grading instruments across two domains. My job is to replicate exactly what her Excel does — but as a web application that’s faster, more reliable, and can scale to other teachers.
This article is about the engineering discipline behind that “exactly”. Specifically: how I built a calculation engine I trust, validated against ground truth from a real teacher’s spreadsheet.
The calculation problem, in plain terms
A Portuguese teacher’s grade calculation looks roughly like this:
For each term:
- The class has a template of grading criteria, organised in domains (e.g. “Knowledge” 60%, “Attitudes” 40%)
- Each domain has instruments with explicit point allocations (Test = 40 pts, Assignments = 10 pts, Participation = 10 pts, etc.)
- The instrument points across all domains sum to 100
- A student’s term grade is the sum of points obtained on each instrument
- That percentage is then converted to a level (1 to 5) using thresholds — but with a twist: the thresholds are at .45% (e.g. 49.45%, 69.45%) rather than .5%, so a student with 49.44% lands in level 2 while 49.45% lands in level 3
For the final grade across multiple terms:
- Term 1: direct (the term grade itself)
- Term 2: 35% × T1 + 65% × T2
- Term 3: 33% × T1 + 34% × T2 + 33% × T3
Simple in principle. Brutal in detail.
Why this needs domain-driven design
If you’ve spent time in WordPress plugin land, you’ll recognise the temptation: throw it into a giant class GradeCalculator with a dozen public methods, embed $wpdb queries directly, mix presentation concerns with calculation logic, ship it.
I refused that path for one reason: I have to be able to look the teacher in the eye and say the calculations are correct. That requires three things:
- The calculation logic must be testable in isolation, without booting WordPress
- The data flowing through it must be explicit and immutable
- Every business rule (the .45% thresholds, the period weighting, the handling of missing scores) must live in one obvious place
The way I structured the code reflects this:
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
The entire Domain/ folder has zero use statements pointing at WordPress. No $wpdb, no wp_*() calls, no get_option(). The Calculator can be unit-tested with vanilla PHPUnit and would migrate intact to Laravel, Symfony, or a CLI tool.
The Calculator class itself
Here’s the orchestrator, in full. Read it once before I explain — the shape itself tells you what’s happening.
declare(strict_types=1);
namespace Avaliar\Domain\Calculator;
use Avaliar\Domain\Models\CriteriaTemplate;
use Avaliar\Domain\ValueObjects\LevelThresholds;
use Avaliar\Domain\ValueObjects\PeriodAggregationFormula;
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);
}
}
Notice three things:
final readonly class — no inheritance, no mutation. The Calculator is constructed once and called many times with different inputs. There’s no internal state to corrupt.
Constructor with default-instantiated dependencies — for tests I can pass mocked aggregators; for production the defaults work. PHP 8.1+ default expression in constructors is wonderful for this.
Three public methods, one purpose each — periodGrade(), finalGrade(), level(). No “do everything” entry point. The caller composes them in the order the business needs them.
Value objects: where the .45% lives
A common design failure is leaving “magic numbers” scattered across the code. The Portuguese level thresholds are 19.45%, 49.45%, 69.45%, 89.45% — and getting them wrong by 0.05% silently moves students between levels.
Instead of hard-coding these in the Calculator, I made them a value object:
declare(strict_types=1);
namespace Avaliar\Domain\ValueObjects;
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;
}
}
The validation in the constructor catches the entire class of bugs where someone accidentally configures [19.45, 49.45, 49.45, 89.45] (duplicate) or [19.45, 89.45, 49.45] (out of order). Better to fail loudly at template creation than silently miscalculate a student’s level six months later.
The LevelConverter then becomes embarrassingly small:
declare(strict_types=1);
namespace Avaliar\Domain\Calculator;
use Avaliar\Domain\ValueObjects\LevelThresholds;
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();
}
}
Notice the strict less-than (<). A student with exactly 49.45% is not below the threshold — they belong to the next level up. This matches what the teacher’s Excel does, and it’s the kind of detail that wrong-by-one-equals-sign is undetectable without ground truth.
Inter-term formula: where Excel and PHP meet
The trickiest piece is the period aggregation formula. Term 2 isn’t just “term 2’s grade” — it’s 0.35 × T1 + 0.65 × T2. Term 3 is 0.33 × T1 + 0.34 × T2 + 0.33 × T3. And critically, before the third term has any data, the formula still has to produce a number — using 0 for the missing term.
Here’s the formula handler:
declare(strict_types=1);
namespace Avaliar\Domain\Calculator;
use Avaliar\Domain\ValueObjects\PeriodAggregationFormula;
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;
}
}
The interesting decision here is the throw on missing periods. The teacher’s Excel, when faced with a missing term 3, treats it as 0 and calculates the partial. So if T1 is 70%, T2 is 85%, and T3 hasn’t started:
Final T3 = 0.33 × 70 + 0.34 × 85 + 0.33 × 0 = 52.0%
That number is meaningless on its own — a student doesn’t actually have 52%. But it’s what the Excel produces, and it’s what the teacher uses to track “where this student is heading”. I match that behaviour by requiring the caller to explicitly pass 0 for missing periods, rather than silently substituting it. The exception forces the calling code to think about what “missing” means in each context.
Ground truth: the test that matters
Here’s where this project differs from most calculation engines I’ve seen: I have my beta tester’s actual Excel, anonymised, as a JSON fixture. Twenty-three students, two terms of data, with their final percentages and levels as the Excel computes them.
The fixture lives in 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
And the test that closes the loop:
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"
);
}
}
}
The validation criterion is binary: does my Calculator output match the Excel’s, to within 0.001? If yes for all 23 students across two terms, I ship. If any single student diverges, I have a bug.
The assertEqualsWithDelta matters. Excel produces values like 0.39999999999999997 when its formulas have any chained operations. PHP’s float arithmetic will produce different floating-point dust. The 0.001 tolerance catches real bugs (two-percentage-point divergence on level boundary) without being noisy about IEEE 754 ghosts.
What I learned
Ground truth changes the conversation. When the teacher asks “are you sure the calculations are right”, I don’t say “yes, I tested it”. I say “here’s the test that compares all 23 of your students against your Excel; it passes”. That’s a different kind of confidence.
Domain isolation pays for itself the first time you change WordPress. The Calculator has no idea WordPress exists. When I eventually swap $wpdb for something else, or the calculation runs in a CLI batch job, the Calculator code doesn’t move. Only the repositories that fetch data and feed it to the Calculator change.
Value objects catch bugs at the boundary. The LevelThresholds constructor validates strictly-increasing, in-range values. If a future admin form lets someone configure [20, 50, 50, 90], the system rejects it at template save time, not at student-grade-display time. The blast radius of the bug is one form submission, not 28 students times two terms.
Floating-point arithmetic is a feature requirement, not a footnote. The .45% thresholds aren’t there to be cute — they’re there because .5% rounded values create ambiguous edges. The teacher’s Excel uses .45% deliberately. My PHP Calculator has to use them too. Getting this wrong by 0.05 changes which students get level 3 versus level 4, which changes whether they pass the year.
What’s next
The Calculator is at v1.0 — locked, tested against ground truth, in production usage by one teacher. The next layers I’m building on top of it:
- Configuration overrides: a teacher can deviate from the department template (different weights for one specific class), but the core Calculator logic doesn’t change
- DL 54/2018 inclusion measures: students with curriculum adaptations need their own templates; the Calculator gets a different
CriteriaTemplateper student when needed - Multi-school templates: when a school adopts the system, their coordinator defines templates that all teachers in the department inherit
None of these require touching the Calculator. They require new repositories, new services, new value objects. The Calculator stays a tiny, boring, well-tested piece of code that does one thing.
That’s the goal of domain-driven design, summarised: make the parts that matter small enough that you can reason about them, and small enough that you can prove they’re correct.
If you’re building anything where calculations have to match a domain expert’s mental model — payroll, grading, billing, scientific instruments — and you’re not sure how to structure the code so you can defend the maths, let’s talk. I have opinions, and they cost less than discovering a calculation bug six months after launch.