Engineering

SOLID in Practice: Refactoring a Quiz Scorer

The open/closed principle on a problem you actually have

Published 2026-07-15 By SADigisoft Insights 5 min read
SOLID in Practice: Refactoring a Quiz Scorer — SADigisoft Blog
calendar_today 2026-07-15 schedule 5 min read Engineering

Most SOLID articles explain the open/closed principle with a Shape class and a Rectangle. Nobody has ever been paid to ship a Rectangle. So here is the same principle on something we build constantly: scoring a quiz attempt.

The function everyone is afraid of

Every quiz engine starts like this, and it is genuinely fine at first:

def score(question, response):
    if question.kind == "multiple_choice":
        return 1.0 if response == question.answer else 0.0
    elif question.kind == "multi_select":
        correct = set(question.answer)
        given = set(response)
        return len(correct & given) / len(correct)
    elif question.kind == "numeric_range":
        low, high = question.answer
        return 1.0 if low <= float(response) <= high else 0.0
    # ... and so on, forever

Then the client wants drag-and-drop. Then hotspot questions. Then partial credit with a penalty for wrong picks. Eighteen months later this function is four hundred lines, every question type shares one blast radius, and the test suite for drag-and-drop somehow breaks when someone touches numeric ranges. That is not a discipline failure — it is a structural one. The function has to be modified for every extension, which is precisely what the open/closed principle warns about.

The refactor

Each question type becomes a small object that knows one scoring rule. Python's Protocol gives us the contract with no inheritance and no base class:

from dataclasses import dataclass
from typing import Protocol, Any

@dataclass
class Question:
    kind: str
    answer: Any
    weight: float = 1.0

class Scorer(Protocol):
    def score(self, question: Question, response: Any) -> float: ...

class MultipleChoice:
    def score(self, question, response) -> float:
        return 1.0 if response == question.answer else 0.0

class MultiSelect:
    # Partial credit, penalising wrong picks — never below zero.
    def score(self, question, response) -> float:
        correct, given = set(question.answer), set(response)
        if not correct:
            return 0.0
        hits = len(correct & given)
        wrong = len(given - correct)
        return max(0.0, (hits - wrong) / len(correct))

class NumericRange:
    def score(self, question, response) -> float:
        low, high = question.answer
        return 1.0 if low <= float(response) <= high else 0.0

SCORERS: dict[str, Scorer] = {
    "multiple_choice": MultipleChoice(),
    "multi_select": MultiSelect(),
    "numeric_range": NumericRange(),
}

def score_attempt(questions, responses) -> float:
    total = sum(q.weight for q in questions)
    if total == 0:
        return 0.0
    earned = sum(
        SCORERS[q.kind].score(q, r) * q.weight
        for q, r in zip(questions, responses)
    )
    return round(earned / total, 3)

Adding true/false now touches nothing that already works:

class TrueFalse:
    def score(self, question, response) -> float:
        return 1.0 if bool(response) == bool(question.answer) else 0.0

SCORERS["true_false"] = TrueFalse()   # open for extension, closed for modification

What actually improved

The honest answer is not "it is cleaner". It is that the blast radius collapsed. Four specific things changed:

  • Each rule is independently testable. You can assert that a multi-select with two hits and one wrong pick scores exactly 0.5 without constructing an entire quiz.
  • New types cannot break old ones. There is no shared branch to get wrong, so the regression the old design invited is now structurally impossible.
  • The bug that used to hide is gone. Notice the original multi_select: it divides by len(correct) with no guard, so an empty answer key raises ZeroDivisionError, and it gives no penalty for wrong picks — select every option and score 100%. Both were real, and both were invisible inside a long elif chain. Small objects make small mistakes visible.
  • Dependency inversion falls out for free. score_attempt depends on the Scorer protocol, not on any concrete type. It never learns what question kinds exist.

Where we would stop

SOLID is a diagnosis, not a religion, and this is where a lot of teams go wrong in the other direction. If your quiz has exactly two question types and always will, the if/else is the right code and the refactor above is overhead you are paying for nothing. We reach for this when a third type arrives, or when two people want to add types in the same sprint — that is the moment the branching starts costing more than the indirection.

The registry is also deliberately a plain dict. A dependency-injection framework here would add a config layer to solve a problem that a dictionary already solved. Extensibility you do not need is just cost with better marketing.

Does it run?

Yes — this code was executed before publishing, not just written. A weighted attempt of one correct multiple choice, a half-credit multi-select at double weight, and a correct numeric range scores 0.75, and the clamp holds a fully wrong multi-select at 0.0 rather than letting it go negative and quietly steal marks from other questions.

Why an eLearning studio cares about this

Because quiz engines are where LMS projects rot. Scoring rules are the requirement most likely to change after launch, and the design above is the difference between a two-hour change and a two-week regression hunt. This is the engineering under our quiz and assessment platform work and the tracking side of SCORM/xAPI integration, which decides what those scores mean once they leave the page.

Building an assessment platform that has to survive its own requirements? Book a free LMS consultation.

Sources & Further Reading:
Google Search Central Documentation  ·  Moz SEO Blog  ·  Search Engine Land

Apply It

Turn this article into a practical action plan

If this topic affects your site, campaign, or LMS growth path, we can help translate it into changes that actually fit your current setup.

Discuss This Topic arrow_forward
Continue Reading

Follow the related journey

Use the article as one step, then move into the service, portfolio, or broader blog context around it.