Software design and clean code: Python

Sefism.

Chapter 02

SOLID principles

Five principles for classes that are easy to extend and test, each with the smell it prevents. Learn them as questions to ask of a design, not as rules to recite.

Single responsibility principle (SRP)

Also called SRP, one reason to change, cohesion

What
A class should have one reason to change, meaning it answers to one group of people or one kind of decision. Pricing rules, printing and saving are three reasons.
Why
When a change to the invoice layout cannot break tax calculation, you change code with confidence and tests stay small.
Python
# Before: Invoice calculated, printed and saved itself.
class Invoice:
    def total(self) -> float: ...          # pricing rules only

class InvoicePrinter:
    def print(self, inv: Invoice) -> str:
        return f"Total: {inv.total()}"

class InvoiceRepository:
    def save(self, inv: Invoice) -> None: ...  # database only

Watch out

Reading it as one method per class and splitting code into dozens of tiny classes that always change together.

Open/closed principle (OCP)

Also called OCP, open for extension, closed for modification

What
Code should be open for extension and closed for modification: you add behaviour by adding new code, such as a new class, rather than editing working code.
Why
Every edit to tested code risks breaking it. When a new discount type is a new class, the checkout code that already works is left alone.
Python
from typing import Protocol

class Discount(Protocol):
    def apply(self, price: float) -> float: ...

class StudentDiscount:
    def apply(self, price: float) -> float:
        return price * 0.8

# A new discount is a new class. checkout never changes.
def checkout(price: float, discount: Discount) -> float:
    return discount.apply(price)

Watch out

Building extension points for changes nobody has asked for; wait until the second or third variation appears.

Liskov substitution principle (LSP)

Also called LSP, substitutability, behavioural subtyping

What
A subtype must work anywhere its parent type is expected without surprising the caller: it may not demand more, promise less or throw where the parent would not.
Why
Polymorphism only works if every subtype keeps the parent's promises. Breaking them brings back type checks in calling code.
Example
If Square extends Rectangle, setWidth on a Square must also change its height, so code that sets width 5 and height 4 and expects area 20 gets 16. Square is not a safe Rectangle.

Watch out

Overriding a method to throw "not supported", which tells you the subtype was never a true is-a.

Interface segregation principle (ISP)

Also called ISP, role interface, fat interface

What
Clients should not be forced to depend on methods they do not use. Prefer several small, role-based interfaces to one large one.
Why
A basic printer should not have to implement fax. Small interfaces keep implementations honest and make test doubles tiny.
Python
from typing import Protocol

# Before: one Machine class with print, scan and fax.
class Printer(Protocol):
    def print(self, doc: Doc) -> None: ...

class Scanner(Protocol):
    def scan(self) -> Doc: ...

def print_all(p: Printer, docs: list[Doc]) -> None:
    for d in docs:
        p.print(d)          # needs printing, nothing else

Watch out

Splitting every interface into one-method pieces that no client actually uses separately.

Dependency inversion principle (DIP)

Also called DIP, depend on abstractions

What
High-level policy should not depend on low-level details; both should depend on an abstraction. OrderService talks to a Notifier interface, not to an SMTP client.
Why
You can switch email for SMS, or use a fake in a unit test, without touching the business rules. The rules become the stable centre of the code.
Python
from typing import Protocol

class Notifier(Protocol):
    def send(self, to: str, msg: str) -> None: ...

class OrderService:
    def __init__(self, notifier: Notifier) -> None:   # injected
        self._notifier = notifier

    def place(self, email: str) -> None:
        self._notifier.send(email, "Order placed")

Watch out

Creating the dependency with new inside the class and calling it DIP because the field type is an interface.

Dependency injection

Also called DI, IoC container, constructor injection, inversion of control

What
Passing an object the things it needs, usually through its constructor, instead of letting it create or look them up itself. A DI container can wire this up automatically.
Why
It is the everyday technique that makes dependency inversion real, and the main reason code becomes unit testable.
Example
Spring in Java and the built-in container in ASP.NET Core wire constructors for you. In Python and TypeScript, passing arguments by hand in one composition root is often enough.

Watch out

Constructors with eight injected services, which is a single responsibility problem that DI makes visible, not one it caused.

DRY, KISS and YAGNI

Also called DRY, KISS, YAGNI, don't repeat yourself, you aren't gonna need it

What
DRY: every piece of knowledge has one home. KISS: prefer the simplest design that works. YAGNI: do not build for needs you only imagine.
Why
They balance SOLID: principles applied too early create layers nobody needs, and these three keep designs small until change proves otherwise.

Watch out

Merging two pieces of code that look alike but change for different reasons, so every later change needs a flag.

02 · SOLID principles2 / 11