12. Entwurfsmuster I – SOLID: S und O
Nach diesem Kapitel kannst du:
- erklären, warum eine Klasse mit mehreren Verantwortlichkeiten schwer zu ändern und zu testen ist.
- eine Klasse mit mehreren Gründen zur Änderung nach dem Single Responsibility Principle aufteilen.
- begründen, warum eine austauschbare Strategie hinter einem Interface das Open-Closed-Prinzip umsetzt, und eine
if-Kette als Verstoß erkennen.
Erinnere dich an das Fußballteam aus Kapitel 11: Der Trainer ruft “Eckball, Variante 2!”, und alle wissen, was zu tun ist. Das funktioniert nur, weil jede Position eine klare Aufgabe hat. Wer im Tor steht, hält; wer den Block stellt, stellt den Block. Müsste eine Spielerin gleichzeitig anspielen, blocken und das Tor hüten, ginge bei jeder Änderung des Spielzugs etwas anderes schief. Genau das verlangt das S von Klassen. Und wenn der Trainer eine neue Eckball-Variante einführt, übt das Team sie zusätzlich ein, statt die eingeübten Varianten umzuschreiben. Das ist die Idee hinter dem O.
In Kapitel 11 hast du die fünf SOLID-Prinzipien im Überblick gesehen. Dieses Kapitel zeigt die ersten beiden, S und O, je an einem Beispiel mit “schlecht” und “besser”. L folgt in Kapitel 13, I und D in Kapitel 14.
flowchart LR accTitle: S und O im Überblick, jeweils vom schlechten zum besseren Entwurf S1["S: eine Klasse, drei Aufgaben"] -->|aufteilen| S2["drei Klassen, je ein Grund zur Änderung"] O1["O: if-Kette über den Kundentyp"] -->|Strategie hinter Interface| O2["neuer Typ = neue Klasse"]
S: Eine Klasse hat einen Grund, sich zu ändern
Abschnitt betitelt „S: Eine Klasse hat einen Grund, sich zu ändern“Single Responsibility Principle (SRP). Eine Klasse (oder ein Modul) sollte nur einen einzigen Grund haben, sich zu ändern. Anders formuliert: Eine Klasse sollte nur für eine einzige Aufgabe bzw. Verantwortlichkeit zuständig sein.
Das Problem, das es löst: Wenn eine Klasse mehrere Verantwortlichkeiten bündelt, führt eine Änderung an einer Verantwortlichkeit oft zu ungewollten Nebeneffekten bei den anderen. Solche Klassen sind schwer zu testen, schwer zu verstehen und werden mit der Zeit zu “God Objects”, die alles können und nichts richtig.
interface InvoiceItem { price: number;}
// bad: three reasons to change in one classclass Invoice { calculateTotal(items: InvoiceItem[]): number { return items.reduce((sum, item) => sum + item.price, 0); }
printInvoice(items: InvoiceItem[]): void { console.log("Invoice:", this.calculateTotal(items)); }
saveToDatabase(items: InvoiceItem[]): void { database.save("invoices", items); }}Diese Invoice-Klasse hat drei Gründe, sich zu ändern: Änderungen an der Berechnungslogik, am Ausgabeformat und an der Art der Datenspeicherung - drei unterschiedliche Verantwortlichkeiten in einer Klasse.
Aufgeteilt hat jede Klasse genau einen dieser Gründe:
flowchart LR accTitle: Die Klasse Invoice wird in drei Klassen mit je einer Zuständigkeit aufgeteilt A["Invoice: rechnen, drucken, speichern"] -->|aufteilen| B["InvoiceCalculator: rechnen"] A -->|aufteilen| C["InvoicePrinter: drucken"] A -->|aufteilen| D["InvoiceRepository: speichern"]
Den Code der aufgeteilten Fassung und die genauere Erklärung findest du unter Weitere Infos.
Wo begegnet dir das? Eine Komponente in deinem Jahresprojekt, die Daten lädt, validiert, umrechnet und anzeigt, hat dieselben vier Gründe, sich zu ändern. Wer sie in Datenzugriff, Logik und Darstellung aufteilt, wendet SRP an. Auch in einem Spiel würdest du Rendering, Physik und Sound nicht in einer einzigen Player-Klasse mischen.
Auflösung
calculateTax verstößt nicht gegen SRP: Beide Methoden ändern sich aus demselben Grund, nämlich wenn sich die Berechnungslogik ändert. Gemeint ist ein Grund zur Änderung, nicht eine einzige Methode. sendByMail in InvoicePrinter dagegen bringt einen zweiten Grund dazu (Versandweg statt Ausgabeformat) und gehört in eine eigene Klasse.
O: Erweitern, ohne Bestehendes anzufassen
Abschnitt betitelt „O: Erweitern, ohne Bestehendes anzufassen“Open/Closed Principle (OCP). Software-Einheiten (Klassen, Module, Funktionen) sollen offen für Erweiterung, aber geschlossen für Modifikation sein. Man soll neues Verhalten hinzufügen können, ohne bestehenden, funktionierenden Code zu ändern.
Das Problem, das es löst: Wenn man bestehenden Code bei jeder neuen Anforderung anfassen muss, steigt das Risiko, etwas zu beschädigen, das vorher funktioniert hat. Zusätzlich müssen bereits getestete Codeteile erneut getestet werden.
// bad: every new customer type means opening this method againclass DiscountCalculator { calculate(customerType: string, price: number): number { if (customerType === "regular") return price; if (customerType === "premium") return price * 0.9; if (customerType === "vip") return price * 0.8; throw new Error(`unknown customer type ${customerType}`); }}Jeder neue Kundentyp erfordert eine Änderung dieser Methode - die Klasse ist nicht “geschlossen für Modifikation”.
Mit einer Strategie hinter einem Interface sieht die Struktur so aus:
classDiagram
accTitle: DiscountCalculator hängt nur von der Abstraktion DiscountStrategy ab
class DiscountStrategy {
<<interface>>
+apply(price) number
}
class RegularDiscount
class PremiumDiscount
class VipDiscount
class DiscountCalculator {
+calculate(strategy, price) number
}
DiscountStrategy <|.. RegularDiscount
DiscountStrategy <|.. PremiumDiscount
DiscountStrategy <|.. VipDiscount
DiscountCalculator ..> DiscountStrategy
Ein neuer Kundentyp bedeutet jetzt: eine neue Klasse hinzufügen, die DiscountStrategy implementiert - DiscountCalculator selbst bleibt unverändert.
if-Kette (schlecht) | Strategie hinter Interface (besser) | |
|---|---|---|
| Neuer Kundentyp | bestehende Methode öffnen und ändern | neue Klasse hinzufügen |
| Risiko für Bestehendes | jede Änderung kann regular oder vip beschädigen | bestehende Klassen bleiben unberührt |
| Erneutes Testen | alle Zweige | nur die neue Klasse |
Wo begegnet dir das? Ein Webshop, der neben Kreditkarte und Rechnung eine weitere Zahlungsart anbieten will, oder eine App mit mehreren Abo-Stufen: Wenn jede neue Variante eine Änderung an derselben if-Kette bedeutet, ist das OCP verletzt.
Weitere Infos
Abschnitt betitelt „Weitere Infos“Diese Abschnitte gehören nicht zum Vortrag. Lies sie vor der Übung nach: Sie enthalten den vollständigen Code der besseren Fassungen und die genaueren Erklärungen.
S: die aufgeteilte Fassung im Code
Abschnitt betitelt „S: die aufgeteilte Fassung im Code“// better: one reason to change per classclass InvoiceCalculator { calculateTotal(items: InvoiceItem[]): number { return items.reduce((sum, item) => sum + item.price, 0); }}
class InvoicePrinter { print(total: number): void { console.log("Invoice:", total); }}
class InvoiceRepository { save(items: InvoiceItem[]): void { database.save("invoices", items); }}Beispielprojekt: 12_entwurfsmuster_solid_s_o_examples/ex01_srp_invoice
Genauer erklärt: Stell dir vor, das Ausgabeformat ändert sich von der Konsole auf ein PDF. In der ersten Fassung öffnest du Invoice und riskierst, dabei die Berechnung zu beschädigen, die gar nichts mit dem Format zu tun hat. Um nur die Berechnung zu testen, brauchst du außerdem die Datenbank mit. In der zweiten Fassung änderst du ausschließlich InvoicePrinter, und InvoiceCalculator lässt sich ohne jede Infrastruktur testen. Kleinere, fokussierte Klassen sind leichter wiederzuverwenden und leichter zu verstehen, weil ihr Name und Zweck übereinstimmen.
O: die Strategie-Fassung im Code
Abschnitt betitelt „O: die Strategie-Fassung im Code“// better: extension via abstraction, no change to existing classesinterface DiscountStrategy { apply(price: number): number;}
class RegularDiscount implements DiscountStrategy { apply(price: number): number { return price; }}
class PremiumDiscount implements DiscountStrategy { apply(price: number): number { return price * 0.9; }}
class VipDiscount implements DiscountStrategy { apply(price: number): number { return price * 0.8; }}
class DiscountCalculator { calculate(strategy: DiscountStrategy, price: number): number { return strategy.apply(price); }}Beispielprojekt: 12_entwurfsmuster_solid_s_o_examples/ex02_ocp_discount
Genauer erklärt: Bestehender, bereits getesteter Code bleibt stabil. Erweiterungen erfolgen additiv statt invasiv, was das Risiko von Regressionsfehlern senkt. Eine Frage bleibt allerdings offen: Irgendwo muss jemand entscheiden, welche Strategie zu welchem Kundentyp gehört. Genau diese Entscheidung nimmt dir das Factory-Muster in Kapitel 16 ab.
Auflösung
In der if-Kette änderst du DiscountCalculator.calculate selbst, also bestehenden und getesteten Code. In der Strategie-Variante schreibst du eine neue Klasse StudentDiscount und änderst keine bestehende. Trotzdem muss irgendeine Stelle beim Kundentyp “student” die neue Strategie auswählen. Dieses “Wer wählt aus?” ist das Problem, das Factory löst.
Zusatzmaterialien
Abschnitt betitelt „Zusatzmaterialien“- Robert C. Martin, Agile Software Development, Principles, Patterns, and Practices bzw. Clean Architecture - S und O in voller Tiefe vom Autor, der das Akronym SOLID geprägt hat.
- Wikipedia: Single-responsibility principle - kurze Definition mit Herkunft, gut zum Nachschlagen, wenn im Code-Review “die Klasse macht zu viel” fällt.
- Wikipedia: Open–closed principle - zeigt, dass OCP ursprünglich über Vererbung formuliert wurde und heute meist über Interfaces umgesetzt wird.
- refactoring.guru: Strategy - die ausgearbeitete Form der Rabatt-Lösung, gute Vorbereitung auf Kapitel 17.
Zum Anhören
Abschnitt betitelt „Zum Anhören“Du kannst dir diesen Text mit der Vorlesefunktion deines Browsers oder Betriebssystems kostenlos anhören, etwa mit “Laut vorlesen” in Microsoft Edge. Das eignet sich gut zum Wiederholen vor der Schularbeit.
Lernergebnisse: Was du nach diesem Kapitel können solltest
Abschnitt betitelt „Lernergebnisse: Was du nach diesem Kapitel können solltest“- Erklären: erklären, warum eine Klasse mit mehreren Verantwortlichkeiten schwer zu ändern und zu testen ist, und SRP als “ein Grund zur Änderung” statt “eine Methode” verstehen.
- Anwenden: eine Klasse mit mehreren Gründen zur Änderung nach dem Single Responsibility Principle aufteilen.
- Analysieren: begründen, warum eine austauschbare Strategie hinter einem Interface das Open-Closed-Prinzip umsetzt, und eine
if-Kette als Verstoß erkennen.
Passende Übungen
Abschnitt betitelt „Passende Übungen“- Aufgabe 08 - Jahresprojekt Sprint 2 - SRP, OCP und LSP (gemeinsam mit Kapitel 13 in einer Woche): Kata: eine Abo-Abrechnung nach SRP teilen, Tarife hinter ein Interface legen, den Vertrag einer Unterklasse prüfen (Wesentlich_überwiegend); eine Stelle des Projekts nach SRP teilen und testen, LSP-Prüfung aller
extendsundimplements(Wesentlich_vollständig). PlusPlus: OCP an einerif-Kette des Projekts; MandalorianPlus: Vertragstest und Grenzen des OCP.
Autoren
Abschnitt betitelt „Autoren“GAM
CC-BY-SA