Card 07/ 09

GotchaDifficulty: Intermediate2 min

SOLID Applied Too Early: Interfaces Nobody Needed

Three interfaces and a factory for a script that will never change.

SOLID principles pay off where code is expected to change, and nowhere else. Applied to code that will not change, each one adds a class, an interface or a level of indirection that has nothing to protect. This card shows what that looks like, so that you can recognise it in a review and leave the principles out when they do not earn their place.

The expectation is natural. Once you have seen a checkout rot, every class looks like the next checkout, and splitting it early feels like care. Here is a script that totals a day's sales from a CSV export, written plainly, and then written by someone applying every principle at once.

The same sales total, plain and over-engineered

Plain

Twelve lines. One function reads the text and sums a column.

py
import csv
import io

SALES = "item,amount\ncoffee,3.20\nbagel,2.10\ncoffee,3.20\n"


def total_sales(text):
    rows = csv.DictReader(io.StringIO(text))
    return sum(float(row["amount"]) for row in rows)


print(f"Total: {total_sales(SALES):.2f}")
OutputRan with python:3.12-slim (Python 3.12.13)
Total: 8.50

Over-engineered

Two abstract classes, two implementations, a factory and a coordinating class, for the same result.

py
import csv
import io
from abc import ABC, abstractmethod

SALES = "item,amount\ncoffee,3.20\nbagel,2.10\ncoffee,3.20\n"


class Reader(ABC):
    @abstractmethod
    def read(self): ...


class Parser(ABC):
    @abstractmethod
    def parse(self, text): ...


class StringReader(Reader):
    def __init__(self, text):
        self.text = text

    def read(self):
        return self.text


class CsvParser(Parser):
    def parse(self, text):
        return list(csv.DictReader(io.StringIO(text)))


class ParserFactory:
    def create(self, kind):
        if kind == "csv":
            return CsvParser()
        raise ValueError(kind)


class SalesTotaller:
    def __init__(self, reader, parser):
        self.reader = reader
        self.parser = parser

    def total(self):
        rows = self.parser.parse(self.reader.read())
        return sum(float(row["amount"]) for row in rows)


totaller = SalesTotaller(StringReader(SALES), ParserFactory().create("csv"))
print(f"Total: {totaller.total():.2f}")
OutputRan with python:3.12-slim (Python 3.12.13)
Total: 8.50

Both print Total: 8.50. The second version has five classes and a factory that can only return one thing. To find out what the script does, a reader now follows the code through four classes. Nothing in it can vary without editing anyway, because there is one reader, one format and one caller.

This failure is common enough to have names: speculative generality, or building for a future that never comes. Each interface was justified by a change that someone imagined. None of those changes had arrived.

The working rule is to wait for the second case. Write the plain version. When a real second reader or a second format turns up, the plain function is short enough to split in minutes, and you will split it along the line the real change needs rather than the line you guessed. The principles describe where to cut when the pressure arrives. They do not ask you to cut in advance.

Two exceptions are worth knowing. Code at a boundary you do not control, such as a payment provider or a database, usually does deserve an abstraction early, because tests need to replace it. And a library used by other teams pays for its interfaces sooner, because its callers cannot edit it.