Design Principles
The principles behind maintainable code: cohesion and coupling, separation of concerns, the SOLID principles, DRY, KISS and YAGNI, and composition over inheritance.
Architecture decides the big parts of a system; design principles keep each part easy to change. They are rules of thumb collected over decades of maintaining software, and they all aim at the same thing: when a requirement changes, the change should be small, local and safe. The object-oriented basics they build on (classes, interfaces, polymorphism) are explained in Programming Concepts. The examples use KBS, Kestrel Energy's billing system.
Coupling and cohesion
Coupling is the degree to which one module depends on another, so that a change in one forces a change in the other. Calling another module's function is coupling; reading its database tables, relying on the order of its log lines or knowing its internal class names is much stronger coupling.
Cohesion is how strongly the
contents of one module belong together. A Tariffs module that holds
prices, tariff periods and the rules to calculate a price is
cohesive. A utils module with date parsing, PDF fonts
and a retry helper is not: its parts share nothing but a file.
The goal, since Larry Constantine's work on structured design in the 1970s, is low coupling and high cohesion. They reinforce each other: when everything about tariffs is in one module, other modules need to know very little about it. A quick way to see coupling is to count dependencies in both directions:
# Which KBS modules call or import which (from a quick scan of the code)
depends_on = {
"invoicing": ["tariffs", "customer_accounts", "pdf", "mail", "db"],
"payments": ["invoicing", "customer_accounts", "db"],
"dunning": ["payments", "invoicing", "customer_accounts", "mail", "db"],
"tariffs": ["db"],
"customer_accounts": ["db"],
"pdf": [],
"mail": [],
"db": [],
}
fan_in = {module: 0 for module in depends_on}
for deps in depends_on.values():
for dep in deps:
fan_in[dep] += 1
print(f"{'module':18} {'uses':>4} {'used by':>7}")
for module, deps in sorted(depends_on.items(), key=lambda m: -len(m[1])):
print(f"{module:18} {len(deps):>4} {fan_in[module]:>7}")
module uses used by invoicing 5 2 dunning 5 0 payments 3 1 tariffs 1 1 customer_accounts 1 3 pdf 0 1 mail 0 2 db 0 5
A module that uses many others (invoicing, dunning) breaks when any of them changes. A module that is used by many (db, customer_accounts) breaks many others when it changes, so it must be stable and change carefully. Trouble starts where both are high, and where five modules all depend directly on the database layout: a column rename in db reaches five places.
Separation of concerns
Separation of concerns means dividing a program so that each part deals with one aspect of the problem, such as business rules, presentation, storage or communication, and can be understood and changed without the others. The phrase is usually traced to Edsger Dijkstra (1974).
The most valuable separation in business software is between deciding and doing: code that calculates an invoice amount should not also open database connections or send email. The calculation then becomes a pure function: easy to test with a few numbers, easy to reuse in the portal's "estimate my bill" page. Layers and hexagons, on Layers and Boundaries, apply the same idea to a whole application.
The SOLID principles
SOLID is an acronym for five object-oriented design principles collected by Robert C. Martin in the late 1990s and early 2000s: single responsibility, open-closed, Liskov substitution, interface segregation and dependency inversion. The acronym itself is usually credited to Michael Feathers. The principles were written with classes in mind, but each applies just as well to modules and to whole services.
Single responsibility
The single responsibility principle (SRP) says a module should have only one reason to change. Martin later sharpened it: "Gather together the things that change for the same reasons. Separate those things that change for different reasons." A reason to change is a person or group that asks for changes, so in practice: one module, one group of stakeholders.
Here is an invoice service as it exists in KBS today:
class InvoiceService:
def create_invoice(self, customer, kwh):
amount = kwh * self.price_for(customer) + STANDING_CHARGE # billing rules
pdf = self.render_pdf(customer, amount) # layout
self.db.execute("INSERT INTO invoice VALUES (?, ?)", (customer, amount)) # schema
self.mailer.send(customer.email, pdf) # delivery
Four lines, one method, and it looks like it "does one thing": creating an invoice. But Femke Jansen's billing department changes the rules, the communications team changes the layout, and Hamid Rahimi changes the database schema. Three groups edit the same class, their changes collide in code review, and a layout fix can break a calculation. Splitting along those lines:
from decimal import Decimal
class TariffCalculator: # changes when billing rules change (Femke)
def amount(self, kwh, price, standing_charge):
return kwh * price + standing_charge
class InvoiceRenderer: # changes when the layout changes (communications)
def render(self, customer, amount):
return f"Invoice for {customer}: EUR {amount:.2f}"
class InvoiceArchive: # changes when the schema changes (Hamid)
def __init__(self):
self.rows = []
def save(self, customer, amount):
self.rows.append((customer, amount))
calculator, renderer, archive = TariffCalculator(), InvoiceRenderer(), InvoiceArchive()
amount = calculator.amount(Decimal("287.4"), Decimal("0.27"), Decimal("6.50"))
archive.save("C-10044", amount)
print(renderer.render("C-10044", amount))
print(len(archive.rows), "invoice archived")
Invoice for C-10044: EUR 84.10 1 invoice archived
Open-closed
The open-closed principle (OCP) says a module should be open for extension but closed for modification: you add new behavior by adding new code, not by editing code that already works. Bertrand Meyer formulated it in 1988; the interface-based reading used today is Martin's. The usual tool is polymorphism: the stable code depends on an interface, and each new variant is a new class that implements it.
KBS has a fixed tariff and a day/night tariff. Femke wants a dynamic
tariff with a different price each hour. If the cost calculation has
an if tariff == "fixed" … elif …
chain, every new tariff means editing and retesting it. With a small
protocol, it does not:
from decimal import Decimal
from typing import Protocol
class TariffRule(Protocol):
def price(self, hour: int) -> Decimal: ...
class FixedTariff:
def price(self, hour):
return Decimal("0.27")
class DayNightTariff:
def price(self, hour):
return Decimal("0.31") if 7 <= hour < 23 else Decimal("0.22")
def cost(usage_by_hour, rule: TariffRule): # closed: never edited for a new tariff
return sum(kwh * rule.price(hour) for hour, kwh in usage_by_hour.items())
usage = {3: Decimal("1.3"), 8: Decimal("0.8"), 19: Decimal("2.5")}
for rule in (FixedTariff(), DayNightTariff()):
print(f"{type(rule).__name__:15} EUR {cost(usage, rule):.2f}")
class DynamicTariff: # open: a new tariff is a new class
def __init__(self, prices):
self.prices = prices
def price(self, hour):
return self.prices[hour]
day_ahead = {3: Decimal("0.09"), 8: Decimal("0.34"), 19: Decimal("0.42")}
print(f"{'DynamicTariff':15} EUR {cost(usage, DynamicTariff(day_ahead)):.2f}")
FixedTariff EUR 1.24 DayNightTariff EUR 1.31 DynamicTariff EUR 1.44
Python checks none of this before the program runs: any object with
a price method will do, and the
Protocol only helps a type checker. In Java the
interface is a contract the compiler enforces. A tariff that forgets
price does not compile, and cost cannot
even see which tariffs exist:
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
interface TariffRule {
BigDecimal price(int hour);
}
record FixedTariff(BigDecimal perKwh) implements TariffRule {
public BigDecimal price(int hour) {
return perKwh;
}
}
record DayNightTariff(BigDecimal day, BigDecimal night) implements TariffRule {
public BigDecimal price(int hour) {
return hour >= 7 && hour < 23 ? day : night;
}
}
public class Main {
// Closed for modification: knows only the interface, never a concrete tariff
static BigDecimal cost(Map<Integer, BigDecimal> usageByHour, TariffRule rule) {
BigDecimal total = BigDecimal.ZERO;
for (var entry : usageByHour.entrySet()) {
total = total.add(entry.getValue().multiply(rule.price(entry.getKey())));
}
return total.setScale(2, RoundingMode.HALF_UP);
}
public static void main(String[] args) {
var usage = new TreeMap<Integer, BigDecimal>(Map.of(
3, new BigDecimal("1.3"), 8, new BigDecimal("0.8"), 19, new BigDecimal("2.5")));
List<TariffRule> rules = List.of(
new FixedTariff(new BigDecimal("0.27")),
new DayNightTariff(new BigDecimal("0.31"), new BigDecimal("0.22")));
for (TariffRule rule : rules) {
System.out.println(rule.getClass().getSimpleName() + " EUR " + cost(usage, rule));
}
}
}
FixedTariff EUR 1.24 DayNightTariff EUR 1.31
Each tariff is a
record
that implements the interface; a dynamic tariff would be a third
record, and cost would not change. Java also offers the
deliberate opposite:
sealed types
close the list of implementations so the compiler can check that
every switch handles all of them. That is a trade-off,
not a mistake: open when new variants are expected, closed when the
set is fixed and completeness matters more.
Liskov substitution
The Liskov substitution principle (LSP), from Barbara Liskov's 1987 keynote and her later paper with Jeannette Wing, says that objects of a subtype must be usable wherever the base type is expected without breaking the program. A subclass may do more, but it must keep every promise the base class makes. "A prepaid contract is a contract" sounds right, which is exactly how this happens:
class Contract:
def __init__(self, customer, yearly_kwh):
self.customer = customer
self.yearly_kwh = yearly_kwh
def monthly_advance(self):
"""Advance payment collected each month, in euros."""
return round(self.yearly_kwh * 0.27 / 12, 2)
class PrepaidContract(Contract): # "a prepaid contract is a contract" ... or is it?
def monthly_advance(self):
raise NotImplementedError("prepaid customers top up; there is no advance")
contracts = [Contract("C-10044", 2900), PrepaidContract("C-10051", 1800)]
for contract in contracts:
print(contract.customer, contract.monthly_advance())
C-10044 65.25 NotImplementedError: prepaid customers top up; there is no advance
The base class promised a monthly advance; the subclass refuses.
Every loop over contracts now needs an isinstance check
or a try, and the next developer will forget one. The
fix is to make the base class promise something every contract can
keep:
class Contract:
def __init__(self, customer, yearly_kwh):
self.customer = customer
self.yearly_kwh = yearly_kwh
def amount_due_this_month(self):
return round(self.yearly_kwh * 0.27 / 12, 2)
class PrepaidContract(Contract):
def amount_due_this_month(self):
return 0.0 # nothing to collect: keeps the promise
contracts = [Contract("C-10044", 2900), PrepaidContract("C-10051", 1800),
Contract("C-10063", 3400)]
total = sum(contract.amount_due_this_month() for contract in contracts)
print(f"to collect this month: EUR {total:.2f}")
to collect this month: EUR 141.75
Signs of an LSP violation: a subclass method that raises "not supported", ignores its arguments, or accepts fewer inputs than the parent. Often the honest answer is that the "is-a" relationship was wrong, and the two classes should not share a parent at all.
Interface segregation
The
interface segregation principle
(ISP) says that clients should not be forced to depend on methods
they do not use. A large interface couples every user to every
method in it: change the signature of rebuild_indexes
and the usage report, which never calls it, must be rebuilt and
retested. Worse, a read-only report that holds a repository with
delete_all can call it by mistake.
from typing import Protocol
class MeterRepository(Protocol): # what the storage code offers
def readings_for(self, meter: str, day: str) -> list[int]: ...
def add(self, meter: str, at: str, wh: int) -> None: ...
def delete_all(self, meter: str) -> None: ...
def rebuild_indexes(self) -> None: ...
class ReadingReader(Protocol): # what the usage report needs: one method
def readings_for(self, meter: str, day: str) -> list[int]: ...
def daily_usage_kwh(reader: ReadingReader, meter: str, day: str) -> float:
return sum(reader.readings_for(meter, day)) / 1000
The same storage class can implement both; the report simply asks for the smaller one. Interfaces defined by what their clients need, rather than by what an implementation happens to offer, stay small and stable.
Dependency inversion
The
dependency inversion principle
(DIP) says that high-level modules (business rules) should not
depend on low-level modules (database drivers, mail libraries); both
should depend on abstractions. And the abstraction belongs to the
high-level side: the billing code defines what a
Notifier must do, and the mail code implements it.
from typing import Protocol
class Notifier(Protocol): # owned by the billing code
def send(self, customer: str, text: str) -> None: ...
class InvoiceSender:
def __init__(self, notifier: Notifier): # depends on the abstraction only
self.notifier = notifier
def invoice_ready(self, customer, amount):
self.notifier.send(customer, f"Your invoice of EUR {amount:.2f} is ready")
class EmailNotifier: # a detail: one way to send
def send(self, customer, text):
print(f"email to {customer}: {text}")
class RecordingNotifier: # a fake for tests
def __init__(self):
self.sent = []
def send(self, customer, text):
self.sent.append((customer, text))
InvoiceSender(EmailNotifier()).invoice_ready("C-10044", 84.1)
fake = RecordingNotifier()
InvoiceSender(fake).invoice_ready("C-10051", 41.5)
assert fake.sent == [("C-10051", "Your invoice of EUR 41.50 is ready")]
print("test passed:", fake.sent[0][1])
email to C-10044: Your invoice of EUR 84.10 is ready test passed: Your invoice of EUR 41.50 is ready
InvoiceSender can now be tested without a mail server
(with a
test double), and switching to SMS or a portal message is a new class, not a
change to billing. Passing the notifier in through the constructor
is called dependency injection; it, and what DIP means for a whole
application, are on
Layers and Boundaries.
DRY, KISS and YAGNI
Three short principles act as a counterweight to the ones above. SOLID tends to add structure; these ask whether the structure is earning its keep.
DRY: don't repeat yourself
DRY ("don't repeat yourself"), from Andy Hunt and Dave Thomas's The Pragmatic Programmer (1999), says every piece of knowledge should have one authoritative representation in a system. The key word is knowledge. The VAT rate, the rule for when a day tariff starts, the format of a customer number: each should be defined in exactly one place, so that changing it once changes it everywhere. How that looks inside Python code is on Code Style.
DRY is not about lines that look alike. The portal's "estimate your bill" and KBS's real invoice both multiply kWh by a price, but they answer to different people and change for different reasons (the estimate is allowed to round, the invoice is not). Merging them into one shared function couples the portal to billing for the sake of two identical lines. Duplication is cheap to remove later; a wrong shared abstraction is expensive to untangle.
KISS: keep it simple
KISS ("keep it simple, stupid", often softened to "keep it short and simple") says that the simplest design that meets the requirements is usually the best, because every extra part is something to understand, test, run and fix at three in the morning. A nightly export that writes one CSV file is better than a message pipeline if a CSV file is all anyone needs.
YAGNI: you aren't gonna need it
YAGNI ("you aren't gonna need it"), from Extreme Programming, says: do not build features or extension points for needs you only expect. A speculative feature costs time now, delays what is needed now, must be maintained until it is used, and is often wrong by the time it is. YAGNI does not mean "no design"; it means paying for flexibility when the need is real, and keeping the code clean enough that adding it later is cheap.
Composition over inheritance
"Favor object composition over class inheritance" is one of the
oldest pieces of design advice, from the 1994
Design Patterns book. Inheritance couples a subclass
tightly to its parent's internals, and the Liskov example above
shows how easily an "is-a" turns out to be false. Holding a
collaborator and delegating to it, as
InvoiceSender holds a Notifier, keeps the
parts separate and swappable. It is explained with examples on
Object-Oriented Concepts; the patterns built on it are on
Structural Patterns.
Principles in tension
The principles are heuristics, not laws, and they contradict each other on purpose. Applying one without looking at the others is how code becomes over-engineered or tangled.
| Pulls one way | Pulls the other way | A reasonable middle |
|---|---|---|
| Open-closed: add extension points | YAGNI: don't build for imagined needs | Add the interface when the second variant arrives, not before |
| DRY: one representation | Low coupling: don't share across boundaries | Share knowledge within a module; tolerate similar code across modules that change for different reasons |
| Single responsibility: split by reason to change | KISS: fewer parts | Split when two groups really edit the same code |
| Dependency inversion: interfaces everywhere | KISS: no needless indirection | Invert dependencies on things that are slow, external or likely to change (databases, mail, other services) |