Eine Klasse, die einen Nutzer in der Datenbank speichert, ihm eine Willkommens-E-Mail schickt und eine Quittung formatiert, ist eine Änderung davon entfernt, zwei Funktionen zu zerstören, mit denen sie nichts zu tun hat. Fassen Sie das E-Mail-Template an, riskieren Sie den Schreibzugriff auf die Datenbank. Fassen Sie das Quittungslayout an, riskieren Sie die E-Mail. SOLID ist der Sammelbegriff für fünf Regeln gegen genau diese Art von Verknotung, jede aus einem anderen Blickwinkel. Sie teilen eine Idee: Eine Klasse soll nur einen Grund haben, sich zu ändern, und der Rest des Codes soll davon abhängen, was etwas zu tun verspricht, nicht davon, wie es das tut.

Die Prinzipien stammen aus Robert C. Martins Paper von 2000, “Design Principles and Design Patterns”. Michael Feathers hat sie später in die Reihenfolge gebracht, die das Akronym SOLID ergibt. Keines der fünf hat etwas mit Syntax oder einer bestimmten Sprache zu tun. Es geht darum, wo die Nähte in Ihrem Code verlaufen, damit eine Änderung an einer Stelle auch dort bleibt.

Wofür SOLID steht, und warum fünf Regeln statt einer

  • S, Single Responsibility: Eine Klasse hat genau einen Grund, sich zu ändern.
  • O, Open/Closed: Neues Verhalten kommt durch neuen Code, nicht durch Änderungen an Code, der schon funktioniert.
  • L, Liskov Substitution: Eine Subklasse muss sich überall wie ihre Elternklasse verhalten, wo diese verwendet wird.
  • I, Interface Segregation: Zwingen Sie eine Klasse nicht, Methoden zu implementieren, die sie nicht braucht.
  • D, Dependency Inversion: Hängen Sie von einer Abstraktion ab, nicht von einer konkreten Klasse.

Sie überlappen sich absichtlich. Dependency Inversion ist das, was Open/Closed in der Praxis funktionieren lässt: Eine neue Implementierung lässt sich nur hinzufügen, ohne alten Code anzufassen, wenn dieser Code gegen ein Interface geschrieben wurde. Interface Segregation ist Single Responsibility, angewendet auf Interfaces statt auf Klassen. Behandeln Sie die fünf als Blickwinkel auf ein Ziel, nicht als fünf Kästchen zum Abhaken.

Single Responsibility Principle: ein Grund zur Änderung

Hier eine Klasse, in der sich zwei Gründe zur Änderung verstecken:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

    def save(self, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (self.name, self.email),
        )

    def send_welcome_email(self, smtp_client) -> None:
        smtp_client.send(
            to=self.email,
            subject="Welcome",
            body=f"Hi {self.name}, thanks for signing up.",
        )

Eine Schemaänderung und eine Änderung am E-Mail-Template betreffen jetzt beide User. Wer save() testet, braucht eine Datenbankverbindung (oder einen Mock dafür), und wer send_welcome_email() testet, braucht einen SMTP-Client, ohne dass sich einer der beiden Tests für die jeweils andere Methode interessiert. Hier beginnt auch die Verknotung, die in Was ist Spaghetti-Code und wie man ihn refactort beschrieben wird: Eine Klasse nimmt immer mehr fremde Aufgaben auf, weil sie schon da war.

Trennen Sie die Aufgaben:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email


class UserRepository:
    def save(self, user: User, db_connection) -> None:
        db_connection.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email),
        )


class WelcomeEmailer:
    def send(self, user: User, smtp_client) -> None:
        smtp_client.send(
            to=user.email,
            subject="Welcome",
            body=f"Hi {user.name}, thanks for signing up.",
        )

Jetzt ändert sich jede Klasse aus ihrem eigenen Grund, und jede lässt sich mit einem Fake testen, der eine kleine Aufgabe abdeckt. “Ein Grund zur Änderung” heißt nicht “eine Methode”. UserRepository könnte um find_by_email und delete wachsen und hätte trotzdem eine einzige Verantwortung, weil jede Methode derselben Aufgabe dient: Nutzer zu persistieren.

Open/Closed Principle: Verhalten erweitern, ohne funktionierenden Code anzufassen

Ein Rechner, der auf einem Typ-Tag verzweigt:

1
2
3
4
5
6
7
class AreaCalculator:
    def area(self, shape: dict) -> float:
        if shape["type"] == "circle":
            return 3.14159 * shape["radius"] ** 2
        elif shape["type"] == "rectangle":
            return shape["width"] * shape["height"]
        raise ValueError(f"unknown shape: {shape['type']}")

Ein Dreieck hinzuzufügen bedeutet, area() wieder zu ändern. Schlimmer noch: Jede andere Funktion, die auf shape["type"] verzweigt, muss gefunden und genauso geändert werden. Jede neue Form ist eine Änderung an bereits getestetem Code, mit dem Risiko, dabei die Fälle Kreis und Rechteck zu zerschießen.

Definieren Sie den Vertrag einmal und fügen Sie neue Formen als neue Klassen hinzu:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius

    def area(self) -> float:
        return 3.14159 * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def area(self) -> float:
        return self.width * self.height


def total_area(shapes: list[Shape]) -> float:
    return sum(shape.area() for shape in shapes)

Triangle hinzuzufügen heißt: eine neue Klasse, die area() implementiert. total_area ändert sich nie, Circle und Rectangle auch nicht. Genau das meint “offen für Erweiterung, geschlossen für Modifikation”: Neues Verhalten kommt als neuer Code, und der schon getestete Code bleibt unberührt.

Liskov Substitution Principle: Eine Subklasse muss die Zusagen der Elternklasse einhalten

Der klassische Fehlschlag ist ein Square, das als Subklasse von Rectangle gebaut wird:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height

    def set_width(self, width: float) -> None:
        self.width = width

    def set_height(self, height: float) -> None:
        self.height = height

    def area(self) -> float:
        return self.width * self.height


class Square(Rectangle):
    def set_width(self, width: float) -> None:
        self.width = width
        self.height = width

    def set_height(self, height: float) -> None:
        self.width = height
        self.height = height
1
2
3
4
def resize_and_check(rect: Rectangle) -> None:
    rect.set_width(5)
    rect.set_height(4)
    assert rect.area() == 20

resize_and_check funktioniert mit jedem Rectangle. Geben Sie ein Square hinein, und die Assertion schlägt fehl: Das Setzen der Höhe hat stillschweigend auch die Breite verändert, die Fläche ist also 16, nicht 20. Square besteht die Typprüfung als Rectangle, mypy hat also nichts zu melden, und trotzdem verletzt es die Erwartungen, auf die sich jeder Aufrufer von Rectangle verlassen durfte. Genau das ist der Kern von Liskov-Verstößen: Sie betreffen selten Methodensignaturen, die Tools ohnehin schon abfangen, sondern eine Subklasse, die stillschweigend ändert, was Aufrufer annehmen dürfen.

Lösen Sie das nicht mit einem Sonderfall für Quadrate innerhalb von resize_and_check. Lösen Sie es, indem Square gar keine Subklasse von Rectangle wird. Ein Quadrat hat keine zwei unabhängig veränderbaren Dimensionen, kann also den Vertrag eines Rectangle nicht einhalten. Beide können das Shape-Interface aus dem Open/Closed-Beispiel implementieren, ohne Vererbung zwischen ihnen.

Interface Segregation Principle: Keine ungenutzten Methoden auf eine Klasse zwingen

Ein Interface, das unzusammenhängende Fähigkeiten bündelt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from abc import ABC, abstractmethod

class Printer(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

    @abstractmethod
    def scan(self, document) -> None: ...

    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printer):
    def print(self, document) -> None:
        ...  # funktioniert tatsächlich

    def scan(self, document) -> None:
        raise NotImplementedError("this printer cannot scan")

    def fax(self, document) -> None:
        raise NotImplementedError("this printer cannot fax")

Jeder Code, der ein Printer-Objekt in der Hand hält, muss jetzt außerhalb des Typsystems wissen, welche Methoden auf welcher Instanz sicher sind. Das Interface verspricht drei Fähigkeiten und liefert eine, plus zwei Minen.

Trennen Sie das Interface nach Fähigkeit statt nach Gerät:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Printable(ABC):
    @abstractmethod
    def print(self, document) -> None: ...

class Scannable(ABC):
    @abstractmethod
    def scan(self, document) -> None: ...

class Faxable(ABC):
    @abstractmethod
    def fax(self, document) -> None: ...


class OldInkjetPrinter(Printable):
    def print(self, document) -> None: ...


class AllInOnePrinter(Printable, Scannable, Faxable):
    def print(self, document) -> None: ...
    def scan(self, document) -> None: ...
    def fax(self, document) -> None: ...

Eine Funktion, die nur drucken muss, nimmt ein Printable entgegen, und es gibt kein .scan(), das man versehentlich auf einem Gerät aufruft, das nie eines hatte. Der Vertrag ergibt sich aus dem, was das Objekt implementiert, nicht aus einem Kommentar, der davor warnt, welche Methoden eine Exception werfen.

Dependency Inversion Principle: Von Abstraktionen abhängen, nicht von konkreten Klassen

Ein Service, direkt auf eine Datenbankbibliothek gebaut:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class SQLiteUserRepository:
    def save(self, user) -> None:
        ...  # sqlite3-spezifischer Code


class UserService:
    def __init__(self):
        self.repository = SQLiteUserRepository()

    def register(self, user) -> None:
        self.repository.save(user)

UserService ist fest mit SQLite verschweißt. Der Wechsel zu Postgres, oder ein Fake-Repository in einem Test, bedeutet, UserService selbst zu ändern, statt nur eine Abhängigkeit auszutauschen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from abc import ABC, abstractmethod

class UserRepository(ABC):
    @abstractmethod
    def save(self, user) -> None: ...


class SQLiteUserRepository(UserRepository):
    def save(self, user) -> None:
        ...  # sqlite3-spezifischer Code


class UserService:
    def __init__(self, repository: UserRepository):
        self.repository = repository

    def register(self, user) -> None:
        self.repository.save(user)

UserService hängt jetzt von der Abstraktion UserRepository ab. Ein SQLiteUserRepository() zu bauen, oder für einen Test ein FakeUserRepository(), und es zu übergeben, ist ein eigener Schritt: genau das ist Dependency Injection. Die beiden Begriffe werden oft synonym verwendet, sind aber verschieden: Dependency Inversion ist die Designregel (von Abstraktionen abhängen), Dependency Injection ist die Technik, die das konkrete Objekt liefert. Was ist Dependency Injection? (Mit Beispielen) behandelt die Injection-Seite, einschließlich dessen, was ein DI-Container bringt, wenn eine Klasse mehr als eine oder zwei Abhängigkeiten braucht.

Wann SOLID zu Over-Engineering wird

Ein zwanzigzeiliges Skript in fünf Interfaces aufzuteilen, bringt nichts, wenn genau eine Implementierung existiert und keine zweite geplant ist. Interface Segregation und Dependency Inversion fügen beide eine Schicht Indirektion hinzu. Diese Schicht zahlt sich aus, wenn Sie tatsächlich Implementierungen austauschen, etwa Produktion gegen Test oder SQLite gegen Postgres, oder wenn Sie eine Klasse hinter einem Test-Double isolieren müssen, wie in Unit-Tests vs Integrationstests vs End-to-End-Tests beschrieben. Wird nie etwas ausgetauscht, ist die Abstraktion reiner Overhead.

Mehr Abstraktionen bedeuten nicht besseres Design. Ein Konstruktor mit acht injizierten Interfaces ist nicht SOLIDer als einer mit drei. Es bedeutet, dass die Klasse dahinter immer noch zu viele Verantwortlichkeiten hat, was Sie zum ersten Buchstaben zurückschickt.

SOLID ist auch objektorientiertes Vokabular. Viel guter Code hat überhaupt keine Klassen. Die Gewohnheiten dahinter gelten trotzdem: kleine Funktionen mit einer Aufgabe, und Abhängigkeiten, die übergeben werden, statt sie global zu holen. Das zählt auch in einem funktionalen Stil, selbst wenn keiner der fünf Buchstaben wörtlich zutrifft.

Wie man SOLID auf eine bestehende Codebasis anwendet

Bauen Sie keine ganze Codebasis nach einer Checkliste um. Wenden Sie zuerst Single Responsibility an, und nur dort, wo eine Klasse das Ändern bereits schmerzhaft macht: eine Datei, die jeden Sprint Merge-Konflikte verursacht, oder ein Test, der drei unzusammenhängende Mocks braucht, bevor er überhaupt laufen kann. Stellen Sie eine Abhängigkeit nur dann hinter eine Abstraktion, wenn eine zweite Implementierung oder ein Test-Fake ein aktueller Bedarf ist, kein hypothetischer. Die meisten Open/Closed- und Liskov-Probleme beginnen als Single-Responsibility-Problem, aus dem eine Subklasse gewachsen ist. Deshalb folgen die anderen drei Prinzipien meist von selbst, sobald diese beiden Gewohnheiten sitzen.

Verwandte Artikel