Die positive Wirkung von SOLID auf eine flache Aufwandskurve

Eine Herausforderung während der Entwicklung eines Softwareproduktes ist es, eine Architektur aufzubauen, mit welcher sich das System stetig erweitern lässt. Scheitert man daran, verursacht das Probleme bei der Integration von Änderungen (Steifheit), unvorsehbare Fehler nach Anpassungen (Fragilität) und nicht wiederverwendbaren Code (Unbeweglichkeit). Ein solcher Systemzustand führt zu exponentiell ansteigenden Aufwänden bei neuen Anforderungen, dargestellt an der linken Aufwandskurve.

Agile Softwareentwicklung zielt stattdessen auf eine flache Aufwandskurve — wie rechts dargestellt.

Die Verwendung der SOLID-Prinzipien sind ein Ansatz, dies in einem objektorientierten Umfeld zu erreichen. Die SOLID-Prinzipien wurden von Robert C. Martin zusammengetragen [Martin2000] und stellen Grundlagen für den Entwurf einer langlebigen, flexiblen Softwarearchitektur dar.

SOLID steht für

Im Folgenden betrachten wir die einzelnen Prinzipien und beleuchten, welche Auswirkungen sie auf den Verlauf von Aufwandskurven haben können. Zur Veranschaulichung verwenden wir das Beispiel eines CurrencyConverters: eine Java-Anwendung zur Umrechnung von Wechselkursen.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that each software module should have one and only one reason to change. [Martin2014a]

Jede Klasse hat genau eine Aufgabe. Nur wenn sich an dieser Aufgabe etwas ändert, muss die Klasse angepasst werden.

Die dargestellten Klassen haben folgende Verantwortungen:

  • CurrencyConverter - Kennt den Algorithmus zur Umrechnung
  • ExchangeRates -  Repräsentation der Wechselkurse. (bspw. 1€ = 1,11$)
  • ConversionRateRetriever - Schnittstelle für die Bereitstellung von Wechselkursen
  • CurrencyImporter - Importiert aktuelle Wechselkurse
  • CurrencyParser - Wandelt die importierten Wechselkurse in eine einheitliche Datenstruktur um, die jeder Kombination von zwei Währungen eine Umrechnungsrate zuordnet

Die klare Verantwortung jeder Klasse hilft uns, diese möglichst klein und mit wenig Abhängigkeiten zu erstellen.

Das SRP unterstützt eine flache Aufwandskurve, indem durch die entstehende Übersichtlichkeit einfacher entschieden werden kann, welche Komponenten im Code für ändernde Anforderungen angepasst werden müssen. Außerdem können genauere Vorhersagen zu Aufwänden gemacht werden, da sich die Auswirkungen einer Änderung leichter abschätzen lassen.

Open Closed Principle (OCP)

You should be able to extend the behavior of a system without having to modify that system. [Martin2014b]

Es soll möglich sein, das Verhalten eines Moduls zu erweitern, ohne das bestehende Verhalten zu verändern.

Um dieses Beispiel zu beleuchten, gehen wir davon aus, dass es für unseren CurrencyConverter die Anforderung gibt, ein weiteres Format zu parsen.

Hier der Code des ursprünglichen Parsers (wir sparen uns die eigentliche Logik der Methode, da diese hier nicht von Bedeutung ist):

Eine naheliegende Lösung, die unsere Anforderung erfüllt, aber gegen das OCP verstößt, kann so aussehen: Wir ergänzen einen Parameter, um zu prüfen, welches Format vorliegt. Für den jetzigen Fall reicht ein einfacher Schalter — oder ein Aufzähltyp, sollten weitere Parser hinzukommen. Dadurch lagern wir die Logik des Parsens in private Methoden aus und haben sogar den Code “aufgeräumt”. 

Wieso verstößt diese Lösung gegen das OCP und wie kann sich das auf die Aufwandskurve auswirken?

Zunächst mussten wir das Verhalten der ursprünglichen Methode anpassen. Dies birgt Fehlerpotential. Hinzu kommt die Frage, wie wir damit umgehen, wenn sich unser Kunde weitere Parser wünscht. Dies bedeutet, dass der if/else-Block immer weiter wachsen muss und weitere Anpassungen in der Klasse nötig sind. Ab einer gewissen Zahl von Verschachtelungen kann die Klassengröße wieder zu einem Problem werden.

Wie würde nun eine SOLIDe Lösung dagegen aussehen?

Die Klasse CurrencyParser wird zu einem Interface mit zwei implementierenden Klassen. Die erste Klasse parst das bestehende, die zweite Klasse das neue Format.

Mit dieser Lösung wird das OCP erfüllt. Die Logik der ursprünglichen parseCurrencies-Methode musste nicht angefasst werden. Sollten beispielsweise noch zehn weitere Parser gewünscht werden, behalten wir leicht überschaubare Klassen. Die Aufteilung der Parser hilft uns, diese automatisiert zu testen. Durch das Verhindern der If/else-Entscheidung vermeiden wir unnötige Komplexität in Testfällen.

Liskov Substitution Principle (LSP)

Derived classes must be substitutable for their base classes. [Martin2005]

Objekte von Unterklassen müssen sich grundsätzlich so verhalten, wie ihre Oberklassen.

Ein einfaches Beispiel aus unserem CurrencyConverter, welches dieses Prinzip verletzt, kann so aussehen:

Einer unserer neu entwickelten CurrencyParser implementiert die benötigte Methode nicht, sondern löst eine Exception aus. Neben dieser recht offensichtlichen Variante des Verstoßes gibt es noch weitere Möglichkeiten wie zum Beispiel verschärfte Vor- oder abgemilderte Nachbedingungen.

Exemplarisch für Verletzungen des LSP sind Klienten mit Wissen über das Verhalten einer spezialisierten Klasse.

Aber warum sorgt eine Verletzung des LSP dafür, dass unsere Aufwandskurve steiler wird? Der Klient benötigt Spezialwissen über die Schnittstelle, die er benutzt. Das sorgt bereits für eine größere Fehleranfälligkeit und erschwert außerdem die Wiederverwendung. Die Sonderbehandlung im Code der Klienten zeigt sich häufig durch if/else-Bedingungen und könnte wie folgt aussehen:

Interface Segregation Principle (ISP)

ISP states that no client should be forced to depend on methods it does not use. [Martin2002]

Schnittstellen sollen möglichst klein sein und eine hohe Kohäsion aufweisen. 

Dieses Prinzip schauen wir uns anhand des CurrencyImporters an. Bei dem Importer handelt es sich um ein Interface mit zwei Implementierungen und zwei zu implementierenden Methoden. Der CurrencyImporterWeb lädt aktuelle Währungskurse mit Hilfe einer API aus dem Internet. Der CurrencyImporterStatic bezieht Währungskurse aus einer statischen Datei.

Mit dem Wunsch, den Timeout des Requests vom CurrencyImporterWeb von außen zu setzen, ist die Methode setTimeout() mit in das Interface gewandert. 

Für den CurrencyImporterStatic ist diese Methode nicht von Bedeutung. Trotzdem muss dieser Importer das Interface implementieren um nicht gegen das LSP zu verstoßen.
Besser ist die Aufteilung auf zwei Schnittstellen:

Dadurch wird verhindert, dass der Importer für statische Inhalte die Möglichkeit bieten muss, einen Timeout zu setzen. Die Einhaltung dieses Prinzips hilft dabei, die Größe der Klassen möglichst klein zu halten.

Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g. interfaces). Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions. [Martin1994]

Module höherer Ebene (z.B. UI) sollen nicht von Modulen niedrigerer Ebene (z.B. Zugriff auf Datenbank) und bevorzugt von Abstraktionen, denn von Implementierungen, abhängen. Abstraktionen ändern sich seltener als Implementierungen und sind daher besser als Abhängigkeit geeignet.

Mit der Umsetzung dieses Prinzips wird der Kontrollfluss zur Kompilierzeit umgekehrt, so dass die untere Schicht die Verantwortung über die Implementierung hat. Diese Umkehr des Kontrollflusses zur Kompilierzeit wird als “Inversion of Control” bezeichnet.

In unserem Beispiel ist das Modul der höheren Ebene der CurrencyParser. Dieser lädt Wechselkurse über den ConversionRateRetriever: unser Beispiel für ein Modul niedrigerer Ebene.

Dieses Design bietet verschiedene Vorteile für eine flache Aufwandskurve. Zum einen muss  keine Internetverbindung bestehen, um den CurrencyParser zu testen. Dadurch wird auch der Test stabiler, da er nicht mehr davon abhängt, dass die Website online ist und die erwartete Antwort unverändert zurück liefert. Ändert sich die Klasse HttpConversionRateRetriever, ist es ausgeschlossen, dass man aufgrund dieser Änderung auch die Klasse CurrencyParser ändern muss und dadurch neue Fehler ("Regressionen") verursacht. Außerdem wird Komplexität reduziert. Um den CurrencyParser zu verstehen, ist es nicht nötig auch noch den HttpConversionRateRetriever zu verstehen.

Fazit

Die SOLID-Prinzipien sind weder eine neue Idee, noch sind sie fixe Regeln, die den Anspruch haben, auf jede Problemstellung eine Antwort zu haben. Das Einhalten der Prinzipien trägt jedoch nachweislich zu einer flachen Aufwandskurve bei, welche ein notwendiges Kriterium darstellt, um Agilität zu erreichen.