Uma abordagem de Domain-Driven Design ao cálculo de notas em PHP, com testes de validação contra dados reais de 23 alunos × 2 períodos × 8 instrumentos. Sem “nós”, apenas um programador, uma professora e a disciplina de fazer a matemática bater certo.
Estou a construir um SaaS para professores portugueses. Não um sistema de gestão de aprendizagem, não um portal para pais, não mais uma plataforma EdTech — apenas a ferramenta específica e focada que um professor usa para acompanhar as notas dos alunos em várias turmas e períodos, com os cálculos exigidos pelo sistema de ensino básico português (medidas de inclusão do DL 54/2018, avaliação por domínios, ponderação por período, conversão de níveis).
O mercado é dominado pelo Excel. Os professores constroem folhas de cálculo que calculam médias por período, notas finais ponderadas e conversões de nível. Estas folhas de cálculo são frágeis, inconsistentes entre colegas e propensas a erros silenciosos nas fórmulas. Uma referência de célula errada pode propagar erros para 28 alunos antes de alguém se aperceber.
A minha beta tester é uma professora de Matemática que tem precisamente esse Excel. Real, usado em produção, aperfeiçoado ao longo de anos. Vinte e três alunos do 5.º ano, dois períodos de dados, oito instrumentos de avaliação distribuídos por dois domínios. O meu trabalho é replicar exatamente o que o Excel dela faz — mas como uma aplicação web mais rápida, mais fiável e escalável para outros professores.
Este artigo é sobre a disciplina de engenharia por trás desse “exatamente”. Mais concretamente: como construí um motor de cálculo em que confio, validado contra dados de referência reais provenientes da folha de cálculo de uma professora.
O problema de cálculo, em linguagem simples
O cálculo de notas de um professor português funciona, de forma resumida, assim:
Para cada período:
- A turma tem um modelo de critérios de avaliação, organizado em domínios (por exemplo, “Conhecimentos” 60%, “Atitudes” 40%)
- Cada domínio tem instrumentos com ponderações explícitas em pontos (Teste = 40 pts, Trabalhos = 10 pts, Participação = 10 pts, etc.)
- Os pontos dos instrumentos em todos os domínios somam 100
- A nota de período de um aluno é a soma dos pontos obtidos em cada instrumento
- Essa percentagem é depois convertida num nível (de 1 a 5) com recurso a limiares — mas com uma particularidade: os limiares situam-se em .45% (por exemplo, 49,45%, 69,45%) em vez de .5%, pelo que um aluno com 49,44% fica no nível 2, enquanto 49,45% já corresponde ao nível 3
Para a nota final ao longo de vários períodos:
- Período 1: direto (a própria nota do período)
- Período 2: 35% × P1 + 65% × P2
- Período 3: 33% × P1 + 34% × P2 + 33% × P3
Simples em teoria. Brutal nos detalhes.
Por que isto requer design orientado ao domínio
Se já passou algum tempo no mundo dos plugins WordPress, reconhecerá a tentação: enfiar tudo numa gigante class GradeCalculator com uma dúzia de métodos públicos, incorporar consultas $wpdb diretamente, misturar a lógica de apresentação com a lógica de cálculo, e publicar.
Recusei esse caminho por uma razão: tenho de ser capaz de olhar o professor nos olhos e dizer que os cálculos estão corretos. Isso exige três coisas:
- A lógica de cálculo tem de ser testável de forma isolada, sem arrancar o WordPress
- Os dados que fluem através dela têm de ser explícitos e imutáveis
- Cada regra de negócio (os limiares de 0,45%, a ponderação por período, o tratamento de pontuações em falta) tem de residir num único lugar óbvio
A forma como estruturei o código reflete isto:
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
A pasta Domain/ inteira não tem nenhuma instrução use a apontar para o WordPress. Sem $wpdb, sem chamadas wp_*(), sem get_option(). O Calculator pode ser testado com PHPUnit simples e migraria intacto para Laravel, Symfony, ou uma ferramenta de linha de comandos.
A classe Calculator em si
Aqui está o orquestrador, na íntegra. Leia-o uma vez antes de eu explicar — a própria estrutura diz-lhe o que está a acontecer.
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);
}
}
Repare em três aspetos:
final readonly class — sem herança, sem mutação. A Calculator é construída uma vez e invocada muitas vezes com entradas diferentes. Não existe estado interno passível de corrupção.
Construtor com dependências instanciadas por omissão — nos testes posso injetar agregadores simulados; em produção os valores por omissão funcionam na perfeição. As expressões padrão em construtores do PHP 8.1+ são excelentes para este propósito.
Três métodos públicos, cada um com uma única responsabilidade — periodGrade(), finalGrade(), level(). Sem um ponto de entrada que «faz tudo». O chamador compõe-os pela ordem que a lógica de negócio exige.
Objetos de valor: onde vive o 0,45%
Um erro de design frequente é deixar «números mágicos» dispersos pelo código. Os limiares de nível portugueses são 19,45%, 49,45%, 69,45%, 89,45% — e um erro de 0,05% move silenciosamente os alunos entre níveis.
Em vez de os codificar diretamente na Calculator, transformei-os num objeto de valor:
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;
}
}
A validação no construtor apanha toda a classe de erros onde alguém configura acidentalmente [19.45, 49.45, 49.45, 89.45] (duplicado) ou [19.45, 89.45, 49.45] (fora de ordem). É preferível falhar de forma clara na criação do modelo do que calcular silenciosamente de forma errada o nível de um aluno seis meses depois.
O LevelConverter torna-se então embaraçosamente simples:
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();
}
}
Note o menor-que estrito (<). Um aluno com exatamente 49,45% não está abaixo do limiar — pertence ao nível seguinte. Isto corresponde ao que a folha Excel do professor faz, e é o tipo de detalhe que um sinal-igual errado torna impossível de detetar sem valores de referência.
Fórmula entre períodos: onde o Excel e o PHP se encontram
A parte mais complexa é a fórmula de agregação de períodos. O 2.º período não é apenas «a nota do 2.º período» — é 0.35 × T1 + 0.65 × T2. O 3.º período é 0.33 × T1 + 0.34 × T2 + 0.33 × T3. E, de forma determinante, antes de existirem dados do terceiro período, a fórmula tem de produzir igualmente um valor — usando 0 para o período em falta.
Aqui está o processador da fórmula:
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;
}
}
A decisão interessante aqui é o throw em períodos em falta. O Excel do professor, quando se depara com um terceiro trimestre em falta, trata-o como 0 e calcula o parcial. Portanto, se T1 é 70%, T2 é 85% e T3 ainda não começou:
Final T3 = 0.33 × 70 + 0.34 × 85 + 0.33 × 0 = 52.0%
Esse número não tem significado por si só — um aluno não tem realmente 52%. Mas é o que o Excel produz, e é o que o professor usa para acompanhar «para onde este aluno está a caminhar». Reproduzo esse comportamento exigindo que o código chamador passe explicitamente 0 para os períodos em falta, em vez de o substituir silenciosamente. A excepção força o código chamador a reflectir sobre o que «em falta» significa em cada contexto.
Verdade absoluta: o teste que importa
É aqui que este projecto difere da maioria dos motores de cálculo que já vi: tenho o Excel real do meu beta tester, anonimizado, como uma fixture JSON. Vinte e três alunos, dois trimestres de dados, com as suas percentagens finais e níveis tal como o Excel os calcula.
A fixture reside em 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
E o teste que fecha o ciclo:
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"
);
}
}
}
O critério de validação é binário: a saída do meu Calculator corresponde à do Excel, com uma tolerância de 0,001? Se sim para todos os 23 alunos nos dois períodos, publico. Se um único aluno divergir, existe um erro.
O assertEqualsWithDelta é importante. O Excel produz valores como 0.39999999999999997 quando as suas fórmulas encadeiam operações. A aritmética de vírgula flutuante do PHP produzirá resíduos de vírgula flutuante diferentes. A tolerância de 0,001 apanha erros reais (divergência de dois pontos percentuais na fronteira de nível) sem ser ruidosa relativamente aos fantasmas do IEEE 754.
O que aprendi
A verdade de referência muda a conversa. Quando a professora pergunta «tem a certeza de que os cálculos estão corretos», não digo «sim, testei». Digo «aqui está o teste que compara todos os seus 23 alunos com o seu Excel; passa». É um tipo de confiança diferente.
O isolamento do domínio paga-se a si próprio na primeira vez que se muda o WordPress. O Calculator não tem qualquer conhecimento da existência do WordPress. Quando eventualmente substituir o $wpdb por outra coisa, ou o cálculo correr num processo batch em CLI, o código do Calculator não se move. Apenas os repositórios que obtêm os dados e os fornecem ao Calculator mudam.
Os objetos de valor apanham erros na fronteira. O construtor de LevelThresholds valida valores estritamente crescentes e dentro do intervalo. Se um futuro formulário de administração permitir que alguém configure [20, 50, 50, 90], o sistema rejeita-o no momento de guardar o modelo, e não no momento de mostrar a nota do aluno. O raio de impacto do erro é uma única submissão do formulário, e não 28 alunos multiplicados por dois períodos.
A aritmética de vírgula flutuante é um requisito funcional, não uma nota de rodapé. Os limiares .45% não existem por capricho — existem porque os valores arredondados a .5% criam fronteiras ambíguas. O Excel da professora usa .45% deliberadamente. A minha Calculadora em PHP também tem de os usar. Errar em 0.05 muda quais os alunos que ficam no nível 3 versus nível 4, o que determina se passam de ano.
O que vem a seguir
A Calculadora está na v1.0 — estável, testada contra a fonte de verdade, em uso em produção por uma professora. As próximas camadas que estou a construir por cima:
- Substituições de configuração: um professor pode desviar-se do modelo do departamento (pesos diferentes para uma turma específica), mas a lógica central da Calculadora não muda
- Medidas de inclusão do DL 54/2018: alunos com adaptações curriculares precisam dos seus próprios modelos; a Calculadora recebe um
CriteriaTemplatediferente por aluno quando necessário - Modelos multi-escola: quando uma escola adota o sistema, o seu coordenador define modelos que todos os professores do departamento herdam
Nada disto requer tocar na Calculadora. Requer novos repositórios, novos serviços, novos value objects. A Calculadora mantém-se como um pequeno, simples e bem testado pedaço de código que faz uma única coisa.
Esse é o objetivo do design orientado ao domínio, resumido: tornar as partes que importam pequenas o suficiente para raciocinar sobre elas, e pequenas o suficiente para provar que estão corretas.
Se está a construir algo em que os cálculos têm de corresponder ao modelo mental de um especialista de domínio — processamento salarial, classificações, faturação, instrumentos científicos — e não tem a certeza de como estruturar o código de forma a conseguir defender a matemática, fale comigo. Tenho opiniões, e custam menos do que descobrir um erro de cálculo seis meses após o lançamento.