Card 07/ 09
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.
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}")Total: 8.50Over-engineered
Two abstract classes, two implementations, a factory and a coordinating class, for the same result.
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}")Total: 8.50Both 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.
← → move through sections and cards