Inhalt

Die SOLID-Prinzipien sind Software-Entwurfsprinzipien, welche bei der Erstellung von wartbaren, erweiterbaren Anwendungen in der objektorientierten Programmierung helfen. Ich stelle die Prinzipien einzeln vor, wobei insbesondere ihre Bedeutung für Software-Architekturen erläutert wird.

Dieser Post ist der erste Teil einer Reihe von Blogposts, welche das Buch „Clean Architecture“ von Robert C. Martin zusammenfasst und in den Kontext der App-Entwicklung einordnet.

Einleitung

Ich bin nicht Fan von vielen Prominenten, weder von Rockstars noch von Fußballern, aber ein Mann hat mich in seinen Bann gezogen, seitdem ich eines Tages in der Hochschulbibliothek über sein Buch „Clean Code“ gestolpert bin. Seitdem sauge ich jedes verlautbare Wort des Autors wie ein Schwamm in mir auf. Die Rede ist vom Softwareentwicklungsguru Robert C. Martin aka „Uncle Bob“.

Schon länger sorgt sein Konzept der „Clean Architecture“ (nicht nur) unter App-Entwicklern für Furore. Ich selbst entwickle meine Apps mit der Clean Architecture und war daher gespannt endlich Uncle Bobs neustes Buch zu eben diesem Thema in die Finger zu bekommen.

Was ist Clean Architecture?

Clean Architecture ist eine Softwarearchitektur. Ich glaube ihre Ursprünge hat sie in der Webentwicklung genommen, aber sie lässt sich auf so ziemlich alle Arten von Anwendungen übertragen, somit auch auf Apps. Die Clean Architecture macht sich so ziemlich von allem unabhängig, was man instinktiv als den Kern seiner Anwendung beschreiben würde: Frameworks, Datenbank, UI. Wenn mich jemand noch vor wenigen Jahren nach der Architektur meiner Anwendung gefragt hätte, hätte ich vermutlich so etwas in der Art gesagt:

Ich verwende Framework X, als ORM kommt Y zum Einsatz und außerdem nutze ich diese coole neue UI-Component-Library Z.

Clean Architecture macht all diese Komponenten zu Nebendarstellern und stellt die Business-Logik in den Mittelpunkt. Command-Line-UI oder grafisches UI? Nur eine Implementierungsdetail! Lokale Datenbank oder Remote-Server? Implementierungsdetail! Bluetooth- oder NFC-Übertragung? Implementierungsdetail! Clean Architecture sorgt dafür, dass alles was nicht zur Businesslogik gehört wie ein Plugin ausgewechselt werden kann. Dadurch wird das Programm auch wunderbar testbar, da alle Abhängigkeiten leicht durch Mocks ersetzt werden können.

Wie die Architektur konkret umgesetzt wird? Darum geht es natürlich in dem Buch „Clean Architecture“ und somit auch in der folgenden Serie von Blog-Posts. Zunächst beschäftigen wir uns jedoch mit den Architektur-Grundlagen – den sogenannten SOLID-Prinzipien.

SOLID – die Grundlagen guten Softwaredesigns

Die SOLID-Prinzipien helfen uns dabei Funktionen und Datenstrukturen in Klassen zu bündeln und diese Klassen untereinander sinnvoll zu verbinden. Vielen werden die SOLID-Prinzipien bereits ein Begriff sein. In „Clean Architecture“ werden sie jedoch vor allem aus der Sicht eines Architekten beleuchtet, statt eher codenah ausgelegt zu werden. Also dranbleiben und dazulernen!

First things first: Was genau heißt SOLID nochmal?

  • S – Single Responsibility Prinzip
  • O – Open/Closed Prinzip
  • L – Liskovsches Substitutionsprinzip
  • I – Interface Segregation Prinzip
  • D – Dependency Inversion Prinzip

1. Das Single-Responsibility-Prinzip

Es gibt Definitionen des SRP die folgendes besagen:

Ein Modul sollte nur einen Grund haben geändert zu werden.

Gründe für Änderungen gibt es potentiell sehr viele und daher formuliert Uncle Bob das Prinzip etwas anders:

Ein Modul sollte nur einem Akteur gegenüber verantwortlich sein.

Das klingt erstmal komplizierter, ist aber eine bessere Prüfregel, wenn man beim Entwickeln überprüfen will, ob man gegen das SRP verstößt. Folgendes Beispiel hilft beim Verständnis:

Beispiel für das Single-Responsibility-Prinzip

Die Employee-Klasse ist für drei Akteure relevant

Hier ist leicht zu sehen, dass die Klasse gegen das SR-Prinzip verstößt, weil die Klasse für drei Akteure relevant ist:

  • Die Methode calculatePay() wurde für den CFO (Chief Financial Officer) implementiert.
  • reportHours() ist für die Personalabteilung, also den COO von Interesse
  • save() dient natürlich zum persistieren des Objekts, fällt also in die Verantwortung des CTO

Jeder dieser Akteure hat potenziell Gründe die Klasse ändern zu wollen, sei es weil Stundensätze angepasst werden, das Arbeitszeitmodell sich ändert oder ein neues Datenbanksystem eingeführt wird. Das Problem liegt darin, dass Änderungen, die der CFO veranlasst auch Auswirkungen auf den COO haben können, beispielsweise wenn sowohl calculatePay() als auch reportHours() eine gemeinsame Funktion regularHours() aufrufen. Ein Entwickler aus dem einen Team kann nur schwer absehen, welche Auswirkungen seine Änderungen auf andere Funktionen haben. Wir kennen dieses Problem zur genüge: An einer Stelle wird ein Bug gefixt, dafür taucht an anderer Stelle plötzlich ein neuer auf, weil die Änderungen unerwartete Nebeneffekte hatten.

Lösung

Das Single-Responsibility-Prinzip verlangt, dass jede Zuständigkeit in ein eigenes Modul wandert – in der objektorientierten Programmierung heißt das: In eine eigene Klasse.

Anwendung des Single-Responsibity-Prinzips

Anwendung des Single-Responsibity-Prinzips

Jede Verantwortlichkeit wird in eine eigene Klasse ausgelagert, wobei alle Klassen auf der gleichen einfachen Datenstruktur EmployeeData operieren. Die einzelnen Klassen wissen jedoch nichts voneinander und stehen für sich.

Da dem Projekt durch Anwendung des SRP viele weitere Klassen hinzugefügt werden, müssen Aufrufer viel mehr Objekte instanziieren und verwalten. Eine Möglichkeit dieses Problem zu lösen, besteht in der Einführung von Facade-Objekten, welche den Zugriff auf die einzelnen Subklassen in einer Schnittstelle zusammenfasst.

Wem dieser Ansatz zu weit geht, der ist vielleicht glücklicher damit die ursprüngliche Employee-Klasse mit den Daten und den wichtigsten Funktionen beizubehalten, alle anderen Funktionen an eigene Klassen zu delegieren.

Die wichtigsten Funktionen bleiben in der ursprünglichen Klasse, für alle anderen fungiert die Klasse als Facade.

Ausblick

Es ist nicht immer leicht das SRP zu befolgen. Zum einen bedarf es Disziplin die zusätzlichen Klassen zu erstellen und in Facade-Klassen zu bündeln. Zum anderen wird das Projekt durch die Vielzahl an Klassen schnell unübersichtlich, weshalb eine sinnvolle Aufteilung in Komponenten erforderlich ist. Mit diesem Thema beschäftigt sich übrigens der zweite Teil dieser Artikelreihe.

2. Das Open-Closed-Prinzip

Module sollten sowohl offen für Erweiterungen als auch verschlossen für Modifikationen sein.

Konkret heißt das: Erweiterungen – also neue Features – sollen so wenig Änderungen wie möglich an bestehendem Code erfordern. Realisiert werden kann dieses Prinzip durch die Anwendung zweier andere Prinzipien:

  1. Das Single-Responsibility-Prinzip stellt sicher, dass Dinge, die sich aus verschiedenen Gründen ändern, auch in verschiedenen Klassen realisiert sind.
  2. Das Dependency-Inversion-Prinzip (das kommt gleich noch) stellt sicher, dass die Abhängigkeiten zwischen diesen Dingen vernünftig organisiert sind.

Der Schlüssel zur Umsetzung des OCP besteht in der Verwendung von Abstraktion (des Informatikers Liebling). Stellen wir uns ein System vor, das einen Finanzreport auf einer Webseite ausgibt. Nun soll ein weitere Ansicht zum Ausdrucken hinzugefügt werden. Das Open-Closed-Prinzip besagt ja, dass das bestehende System die Erweiterung ermöglichen soll, ohne bestehenden Code ändern zu müssen. Dies setzt voraus, dass die Berechnung des Finanzreports von der visuellen Aufbereitung getrennt wurde (SRP).

Beispiel für das Open-Closed-Prinzip

Der FinancialReportController arbeitet mit einer abstrakten Version des Presenters.

Das Diagramm zeigt den Aufbau des Fiananzreport-Systems nach dem OCP. Der FinancialReportController beinhaltet die Businesslogik zur Erstellung eines Finanzreports. Der ScreenPresenter kümmert sich um die Darstellung einer Website.

Anstatt dass der FinancialReportController direkt den ScreenPresenter aufruft, wird zunächst der abstrakte FinancialReportPresenter (z.B. ein Java-Interface oder Swift-Protocol) in der gleichen Komponente erstellt, in welcher auch der Controller definiert ist. Der PrintPresenter und der ScreenPresenter implementieren nun dieses Interface.

Dadurch haben wir die Forderungen des Open-Closed-Prinzips umgesetzt:

  1. Eine Umkehrung der Abhängigkeiten: Die Businesslogik-Komponente ist nun nicht mehr von den Darstellungskomponenten abhängig. Wir können Änderungen an der Darstellung vornehmen, ohne den Controller anpassen zu müssen. Die Businesslogik ist für Modifikation geschlossen.
  2. Wir haben einen klar definierten Erweiterungspunkt geschaffen (den FinancialReportPresenter). Dieser erlaubt uns beliebt viele weitere Darstellungen hinzuzufügen, ohne bestehenden Code anfassen zu müssen. Das System ist offen für Erweiterung.

Ausblick

Um das Open-Closed-Prinzip zu realisieren sollten abstrakte Erweiterungspunkte definiert werden (z.B. durch Interfaces). Diese können dann von beliebig vielen Implementierungen mit konkretem Verhalten gefüllt werden. Die Kunst besteht darin, die Stellen im Programm zu identifizieren, welche künftig ggf. erweitert werden könnten.

3. Das Liskovsche Substitutionsprinzip

Das Liskovsche Substitutionsprinzip (LSP), auch Ersetzungsprinzip genannt, besagt, dass eine Unterklasse stets als Objekt seiner Oberklasse verwendet werden können muss. D.h. eine abgeleitete Klasse darf Erweiterungen enthalten, aber nichts an den grundlegenden Eigenschaften der Superklasse ändern.

Okay, wir brauchen ein Beispiel: Eine Klasse Rectangle definiert die Funktionen setHeight() und setWidth(). Eine Kindklasse Ellipse erbt von Rectangle.

Das Liskovsche Substitutionsprinzip wird bei der Vererbung von Rectangle zu Ellipse eingehalten.

Ellipse erbt von Rectangle. Ergibt das Sinn?

Im mathematischen Sinne wäre diese Vererbung nicht korrekt. Eine Ellipse ist kein Rechteck. Je nach Anwendung kann die Vererbung in der OOP aber Sinn ergeben. Das Liskovsche Substitutionsprinzip wird auch eingehalten. An jeder Stelle im Programm wo ein Rectangle verwendet wird, könnte auch eine Ellipse stehen, ohne das Programm zu verändern!

Aber wie sieht es im folgenden Beispiel aus?

Die Beziehung Quadrat -> Recheckt erfüllt das Liskovsche Substitutionsprinzip nicht.

Ein Quadrat ist doch ein Rechteck, oder?

Intuitiv würden wir sagen: Klar ein Quadrat ist ein Rechteck! Aber im Gegensatz zum Rechteck können Höhe und Breite bei einem Quadrat nicht unabhängig voneinander geändert werden. Wir können eine Instanz von Square also nicht anstelle einer Instanz von Rectangle verwenden ohne für Chaos und Verwirrung zu sorgen. Ein Aufrufer von Rectangle müsste immer überprüfen, ob es sich bei dem Rectangle nicht vielleicht um ein Square handelt, um dann Höhe und Breite auf die gleichen Werte zu setzen. Das LSP würde verletzt werden.

LSP auf Architektur-Ebene

Das LSP kann nicht nur auf Klassen-Ebene verwendet werden. Man kann es genauso gut auf größere Systemschnittstellen anwenden, z.B. eine REST-Api. „Uncle Bob“ bemüht das Beispiel einer Taxi-Vermittlungsapp, mit der Taxen von verschiedenen Anbietern gebucht werden können. Alle diese Unternehmen haben sich auf eine einheitliche REST-Schnittstelle zu ihren Systemen geeinigt (sowas dauert meistens lange, aber kommt tatsächlich auch manchmal unter Konkurrenten zustande). Für unsere Vermittlungsapp ist das jedenfalls toll. Wir brauchen nur die Base-URLs aller teilnehmenden Taxi-Unternehmen zu speichern (z.B. api.acme-taxi.com oder abc-taxi.de/api) und können dann die URLs für die Zugriffe auf die APIs nach dem gleichen Schema zusammenbauen.

z.B. abc-taxi.de/api/driver/Bob/pickupTime/153/destination/ORD

Was aber, wenn die Entwickler eines neuen Taxi-Unternehmens Lazy-Cab sich nicht an die Spezifikation halten und aus destination einfach dest machen? Dann müssten wir diesen Sonderfall immer berücksichtigen.

if ( baseUrl.startsWith("lazy-cab.com") )

Ab diesem Moment fangen die Dinge an kompliziert zu werden. Unerklärliche Bugs werden auftauchen. Was ist, wenn sich die Domain von Lazy-Cab ändert? Wir müssten jedenfalls extra Aufwand betreiben, um den Sonderfall zu berücksichtigen, was unsere schöne, leicht verständliche Architektur „verschmutzen“ würde.

4. Das Interface-Segregation-Prinzip

Das Interface-Segregation-Prinzip (ISP) ist schnell erklärt und einfach zu verstehen. Es besagt, dass ein Aufrufer nicht gezwungen werden sollte, von einem größeren Interface abhängig zu sein, als eigentlich notwendig. D.h. eine Schnittstelle sollte nur so viele Methoden beinhalten, wie der Konsument der Schnittstelle benötigt. Andernfalls veranlasst jede Änderung an der Schnittstelle, dass die abhängige Klasse erneut kompiliert und deployed wird, selbst wenn sich an den von ihr genutzten Methoden gar nichts geändert hat.

Die Lösung besteht darin, zu große Interfaces in mehrere kleine zu unterteilen. Dies ist natürlich nur in streng typisierten Sprachen wie Java notwendig. Skriptsprachen benötigen das Konzept des Interfaces gar nicht, demnach findet das Interface-Segregation-Prinzip auf Klassenebene keine Anwendung (hier greift im Zweifel eher wieder das Single-Resposibility-Prinzip).

Das ISP kann aber auch auf größere Komponenten als Klassen angewendet werden.

Das Interface-Segregation-Prinzip gilt auch auf Komponenten-Ebene.

Auch auf Komponenten-Ebene sollte man nicht von mehr abhängig sein, als unbedingt nötig.

Stellen wir uns vor, wir arbeiten an System S. Wir finden das Framework F auf Github, dass genau die Funktionalität beinhaltet, die wir benötigen. Leider hängt F aber von Datenbank D ab. Wir wollen aber gar keine Datenbank-Persistenz, sondern nur Funktionen in F aufrufen, die ohne die Datenbank auskommen. Trotzdem hängen wir mit der Verwendung von Framework F auch indirekt von D ab. Ändert sich D, müssen wir unser System S auch neu kompilieren. Fehler in D können auch Auswirkungen auf uns haben, obwohl wir überhaupt nichts mit D am Hut haben wollen.

Zusammenfassung

Was heißt das jetzt praktisch gesehen? Wir sollten unsere Schnittstellen so klein wie möglich halten. Ein Client einer Klasse, oder auch eines Moduls sollte nur die Funktionalität angeboten bekommen, die er auch wirklich benötigt. Wir erreichen dies, indem wir kleinere Klassen/Module entwickeln (Single-Responsibility-Prinzip) oder auch eine Klasse mehrere Interfaces implementieren lassen. Als Dank für die Mühe erhalten wir eine schlankere, stabilere Software, die weniger anfällig bei Änderungen und einfacher zu verstehen ist.

5. Das Dependency-Inversion-Prinzip

Last but not least kommen wir zum Dependency-Inversion-Prinzip (DIP). Hier wird es nochmal etwas kniffliger, dafür ist das Prinzip meiner Meinung nach auch besonders relevant.

In flexiblen Systemen beziehen sich Quellcode-Abhängigkeiten lediglich auf Abstraktionen statt auf konkrete Typen.

Bestimmt habt ihr schonmal die OOP-Weisheit gehört: „Immer gegen die Schnittstelle implementieren, nicht gegen die Implementierung“ – was letztlich dieselbe Aussage ist. Doch nochmal zurück auf Anfang. Wie sieht ein Programm aus, in dem keine Dependency-Inversion angewendet wird?

Die Abhängigkeiten zeigen von High-Level-Modulen zu Low-Level-Modulen.

Die Abhängigkeiten zeigen von High-Level-Modulen zu Low-Level-Modulen.

Alle Programme haben einen Einstiegspunkt (hier: Main). Üblicherweise ist der Kontrollfluss von High-Level-Modulen (HL-Module) zu Low-Level-Modulen (LL-Module) gerichtet. Beispielsweise könnte in Main eine MainView instanziiert werden, welche wiederum Subviews aus LL-Modulen verwaltet. Wenn keine Abstraktion angewendet wird ist es nur logisch, dass die Quellcode-Abhängigkeiten auch von High → Low zeigen.

Warum ist das schlecht? Zunächst mal wird gegen das Open-Closed-Prinzip verstoßen, wenn keine Abstraktionen und damit Erweiterungspunkte vorgesehen sind (nur als kleine Erinnerung). Zum anderen erzeugt dieser Ansatz eng gekoppelte Abhängigkeiten. Änderungen in LL-Modulen führen unweigerlich zu Problemen in den HL-Modulen. Generell wollen wir daher verhindern von Details abzuhängen und drehen den Spieß um: Die LL-Module sollen von den HL-Modulen abhängig sein. Statt sich direkt mit den LL-Modulen herumzuschlagen, definieren die HL-Module einfach selbst Schnittstellen mit denen sie arbeiten. LL-Module können diese Schnittstellen dann implementieren. Dadurch werden LL-Klassen gezwungen nach den Regeln des HL-Moduls zu spielen und nicht andersherum. Das sieht dann wie folgt aus:

Die Abhängigkeiten zeigen von Low-Level-Modulen zu High-Level-Modulen.

Die Abhängigkeiten zeigen von Low-Level-Modulen zu High-Level-Modulen.

Wir sehen, dass der Kontrollfluss natürlich immer noch von High → Low zeigt. Die Quellcode-Abhängigkeiten sind aber umgedreht. Ziel erreicht!

Aber Moment, ein Interface kann nicht instanziiert werden! Woher bekommt ein HL-Objekt eine Instanz der Schnittstelle zum LL-Objekt? Uncle Bob schlägt hierfür das Abstract-Factory-Pattern vor. Ich selbst verwende Dependency-Injection-Frameworks, die es für nahezu jede Sprache und Plattform gibt, aber auch recht einfach selbst gebaut werden können. Für Android ist der Defacto-Standard derzeitig (2017) Dagger2. Swift-Developer können einen Blick auf Swinject oder Dip werfen.

Dependency Injection (DI) übernimmt die Erzeugung von Objekten. Statt also selbst mit new zu hantieren, konfigurieren wir an einer Stelle das DI-Framework und erhalten dann unsere Abhängigkeiten über den Konstruktor oder über Setter-Methoden überreicht.

Darf ich gar keine konkreten Typen mehr nutzen?

Doch. Es wäre nicht ratsam immer gegen eine Schnittstelle zu implementieren. Nehmen wir z.B. die String-Klasse. Diese hat eine sehr stabile Schnittstelle, d.h. es ist unwahrscheinlich, dass die Signatur sich ändern wird. Hier kann man getrost direkt zugreifen. Die meisten 3rd-Party-Bibliotheken und eigenen Klassen sollten jedoch hinter Interfaces versteckt werden, da Änderungen äußert wahrscheinlich sind und Schnittstellen im Allgemeinen stabiler sind.

Zusammenfassung

Wir sind die SOLID-Prinzipien und deren Bedeutung auf der Architektur-Ebene durchgegangen.

  • Das Single-Responsibility-Prinzip besagt, dass jede Funktion, jede Klasse und jedes Modul nur verantwortlich gegenüber eines Akteurs sein soll. In der Praxis bedeutet dass: Kleine Klassen und Funktionen, dafür viele davon.
  • Das Open-Closed-Prinzip besagt, dass ein/e Klasse/Modul erweiterbar sein soll, ohne bestehenden Code ändern zu müssen. Dies setzt voraus, dass Erweiterungspunkte durch Abstraktionen (Interfaces oder abstrakte Klassen) vorgesehen werden.
  • Das Liskovsche Substitutionsprinzip verlangt, dass eine abgeleitete Klasse immer anstelle ihrer Superklasse stehen können muss, d.h. dass sie das grundlegende Verhalten nur erweitert, aber nicht übermäßig verändert. Sonst machen wir dem Aufrufer das Leben schwer.
  • Das Interface-Segregation-Prinzip tritt für kleine Interfaces ein, die gerade so viele Funktionen beinhalten, wie ein Aufrufer benötigt.
  • Das Dependency-Inversion-Prinzip besagt, dass Low-Level-Module von High-Level-Modulen abhängig sein sollen und nicht umgekehrt. Nur so wird eine lose Kopplung erreicht. Wie so oft ist die Verwendung von Abstraktionen der Schlüssel der Lösung.

Wenn Du es bis hierhin durchgehalten hast, vielen Dank fürs Lesen! Ich hoffe Du konntest etwas für Dich mitnehmen.

Im nächsten Teil dieser Zusammenfassung von Uncle Bobs Buch „Clean Architecture“ geht es um Software-Komponenten und nach welchen Kriterien Klassen zu solchen Komponenten zusammengefasst werden können.

Bis dann!

Beitragsbild: Mauricio Artieda on Unsplash


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.