Inhalt

Häufig werden Klassen in Komponenten zusammengefasst, um sie mit anderen Entwicklern im selben Team, im selben Unternehmen oder mit der Community zu teilen und wiederverwenden zu können. Doch nach welchen Kriterien können Klassen in Komponenten aufgeteilt werden und wie werden Komponenten zu Gesamtsystemen zusammengesetzt?

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

Komponenten-Kohäsion

Kohäsion beschreibt, wie gut eine Programmeinheit eine logische Aufgabe oder Einheit abbildet.

Wir sind im Zeitalter der Codewiederverwendung angekommen. Bevor ich mich selbst an die Umsetzung eines Features in einer App mache, schaue ich auf Github, ob nicht schon ein anderer Entwickler diese Feature implementiert hat es und mir in Form einer Software-Komponente (oder auch Library, Paket usw.) zur Verfügung stellt.

Es ist aber auch sinnvoll das eigene Projekt in Komponenten zu zerlegen:

  • Komponenten sind wiederverwendbar, z.B. an anderer Stelle im gleichen Projekt, in einem anderen Projekt des gleichen Unternehmens oder eben durch Drittentwickler.
  • Komponenten können unabhängig von anderen Komponenten getestet und gewartet werden.
  • Das Aufteilen von Klassen in Komponenten kann helfen die Komplexität zu reduzieren und die Code-Kopplung zu reduzieren.

Doch wie entscheide ich, welche Klassen zusammengehören, um eine hohe Kohäsion zu erreichen?
Zu dieser Frage liefert „Clean Architecture“ drei Ansatzpunkte:

1. Reuse/Release Equivalence Prinzip

Zum Einstieg erstmal ein eher triviales Prinzip: Klassen, die in einer Komponenten zusammengefasst werden, müssen gemeinsam released werden können. Das heißt, es muss Sinn ergeben, dass sie unter der gleichen Release-Nummer und mit der gleichen Release-Dokumentation veröffentlicht werden.

Andersherum gilt das Prinzip auch: Klassen, die gleichzeitig released werden, sollten in der gleichen Komponente zusammengefasst sein, um Aufwände bei der Software-Verteilung (Herausgeber) und Komponenten-Integration (Nutzer) zu reduzieren.

2. Common Closure Prinzip

Klassen, die sich zur gleichen Zeit und aus dem gleichen Grund ändern, sollten in der selben Komponente zusammengefasst werden. Klassen, die sich zu verschiedenen Zeiten oder aus verschiedenen Gründen ändern, sollten separiert bleiben.

Wenn zwei Klassen physisch oder konzeptionell eng gekoppelt sind, sodass sie bei einer möglichen Änderung vermutlich beide betroffen sind, dann sollten sie in einer gemeinsamen Komponente zusammengefasst sein. Bei Änderungen ist so nur eine minimale Anzahl an Komponenten betroffen, was den Aufwand zur Veröffentlichung, Validierung und zum Deployment der Komponenten reduziert.

Wer bereits den letzten Teil dieser Blogreihe über die SOLID-Prinzipien gelesen hat, dem ist vielleicht die Ähnlichkeit des Common-Closure-Prinzips (CCP) mit dem Single-Responsibility-Prinzip (SRP) aufgefallen. Das SRP besagt, dass eine Klasse nur einen Grund haben sollte sich zu ändern. Das CCP ist quasi das Äquivalent dieses Prinzips auf Komponenten-Ebene.

3. Common Reuse Prinzip

Zwinge den Nutzern Deiner Komponente keine Abhängigkeiten auf, die sie nicht benötigen.

Manche Klassen kann man einfach nur mit anderen zusammen verwenden und das ist auch okay so. Diese Klassen sollten in eine gemeinsame Komponente. Aber Du warst bestimmt schonmal in der Situation, in der Du eine Komponente einbinden wolltest, aber gezögert hast, weil diese Komponente viel mehr konnte als benötigt war. Zögerlich sein ist hierbei absolut gerechtfertigt. Wenn eine Komponente viel kann, gibt es potentiell viele Gründe für Änderung. Und jede Änderung veranlasst Dich zu überprüfen, ob du Dir die aktualisierte Komponente ins Projekt holst. Auch wenn sich an den von Dir verwendeten Klassen und Funktionen gar nichts geändert hat, musst Du alles überprüfen und neu kompilieren. Das dauert in meinem aktuellen Projekt 12 Minuten und ich tue alles, um es zu vermeiden.

Das Common-Reuse-Prinzip (CRP) besagt also weniger, welche Klassen wir zusammen in eine Komponente packen sollten, sondern sagt viel mehr, dass keine Klassen beinhaltet sein sollten, die ein Aufrufer nicht unbedingt benötigt. Auch diesen Punkt kennen wir unter dem Namen „Interface-Segregation-Prinzip“ bereits aus den SOLID-Prinzipien.

Zwischenfazit

Das Problem an Prinzipien ist, dass es schwer ist ihnen treu zu bleiben. Vor allem, wenn sich verschiedene Prinzipien teilweise gegenseitig ausschließen! Das Reuse-Release Equivalence Prinzip und das Common-Closure-Prinzip tendieren dazu, Komponenten größer werden zu lassen. Die Anwendung des Common-Reuse-Prinzip hingegen lässt die Komponenten schrumpfen. Die Wahrheit liegt also wie so oft irgendwo in der Mitte. Auf der einen Seite sollten es nicht zu viele Komponenten werden, da jeder Release mit Aufwand verbunden ist. Auf der anderen Seite sind große Komponenten komplexer und erhöhen die Abhängigkeiten.

Komponenten-Kopplung

Jetzt wo wir geklärt haben, aus welchen Klassen sich eine Komponente zusammensetzten kann, gehen wir einen Schritt weiter raus und schauen, wie verschiedene Komponenten miteinander interagieren sollten.

Acyclic Dependency Prinzip

Eine Komponente wird meistens abhängig von einer oder mehreren anderen Komponenten sein. Dabei sollte unbedingt darauf geachtet werden, dass die Komponenten keine Zyklen bilden!

Zyklischer Abhängigkeitsgraph

Diese Komponenten bilden einen indirekten Abhängigkeitszyklus.

Zyklische Abhängigkeiten machen alles schwieriger. Stellen wir uns vor, wir wären für Komponente A verantwortlich und machen eine Änderung. Leider ist Komponente C von uns abhängig. Wegen unserer Änderung muss C auch geändert werden. Das wiederum erfordert Änderungen in Komponente B! Am nächsten Morgen kommen wir ins Büro und sehen, dass eine neue Version von Komponente B verfügbar ist, die wir dann erstmal in unser Projekt einbinden müssen. Aber was ist das? Wir müssen eine weitere Anpassung wegen Änderungen in B machen. Dann direkt den Kollegen von Komponente C anrufen, um sie über die Anpassung zu informieren… Okay Du merkst schon, worauf das hinausläuft.

Was tun?

Es gibt zwei Möglichkeiten zur Lösung:

  1. Wir wenden das Dependency-Inversion-Prinzip an: In Komponente B definieren wir ein Interface, dass von Komponente C implementiert wird. Komponente B hängt dann nicht mehr von C ab, sondern arbeitet mit ihrem selbst definierten Interface. Damit wäre der Zyklus durchbrochen.

    Durch Dependency-Inversion wird der Abhängigkeitszyklus durchbrochen.

  2. Nochmal überlegen… Warum sollten die Komponenten indirekt von sich selbst abhängig sein? Vermutlich verwendet nicht jede Komponente alle Klassen der anderen Komponente. In diesem Fall würden sie das Common-Reuse-Prinzip missachten. Wir sollten eine zu große Komponente splitten.

    Komponente C wurde in Komponente C1 und C2 aufgeteilt.

    Komponente C wurde aufgeteilt, wodurch der Zyklus aufgelöst werden konnte.

 

Übrigens: Komponenten sind selten in Stein gemeißelt. Solange das Projekt in Entwicklung ist, ändern sich auch die Abhängigkeiten, was eine Neugruppierung notwendig machen kann.

Stable-Dependency-Prinzip

Hier dreht sich alles um die Stabilität von Komponenten.

Eine Komponente ist umso stabiler, je weniger sie von anderen Komponenten abhängig ist und je mehr andere Komponenten von ihr abhängen.

Eine String-Komponente beispielsweise ist ziemlich stabil, weil sie so ziemlich keine Abhängigkeiten haben dürfte, dafür nahezu überall anders auftaucht. Deshalb wäre auch ziemlich schlecht, wenn sich etwas in der String-Komponente ändern würde. Das würde viele Änderungen in den abhängigen Komponenten notwendig machen. Also ein paar Regeln zur Stabilität:

  1. Eine stabile Komponente sollte sich möglichst selten ändern.
  2. Eine Komponente, die sich häufig ändert, sollte nicht stabil sein (d.h. andere Komponenten sollten möglichst nicht von ihr abhängen).
  3. Am besten sind wir nur von Komponenten abhängig, die stabiler sind, als wir selbst!

 

Stable-Abstraction-Prinzip

Und noch ein Prinzip…

Je stabiler eine Komponente, desto abstrakter sollte sie sein.

Erinnern wir uns nochmal an das Open-Closed-Prinzip (OCP) von den SOLID-Prinzipien. Das Prinzip zeigt einen Weg auf, wie eine Klasse/Modul/Komponente geschlossen für Änderung, aber offen für Erweiterung sein kann. Der Schlüssel zum Erfolg war die Abstraktion, d.h. die Definition von Erweiterungspunkten durch abstrakte Klassen und Interfaces.

Was nun, wenn wir neue Funktionen entwickeln wollen? Eine stabile Komponente darf sich nicht ändern, damit alle abhängigen Komponenten nicht ins Verderben gestürzt werden. Eben darum muss es voll und ganz auf die Abstraktionsmechanismen des OCP setzen. Auf diese Weise kann das Programm erweitert werden, ohne die stabile Komponente zu ändern.

Konkret heißt das, eine 100% stabile Komponente (d.h. selber keine Abhängigkeiten, aber viele abhängige Komponenten) sollte nur aus abstrakten Klassen bestehen. Andersherum, eine instabile Komponente (keine abhängige Komponenten) kann ruhig ausschließlich auf konkrete Klassen setzen.

Die Zone of Pain 😵

Wie wäre es um eine Komponente bestellt, die stabil, aber nicht abstrakt ist?

Diese Komponente wäre eine ziemlich steife Angelegenheit. Sie würde keine Möglichkeit bieten erweitert zu werden, weil sie nicht auf Abstraktion setzt. Weil aber so unheimlich viele andere Komponenten von ihr abhängig sind, kannst Du Dich schonmal darauf einstellen einige (viele) Unit-Tests anzupassen, wenn Du Änderungen vornimmst.

Die Zone of Uselessness 😴

Und andersherum? Eine instabile Komponente die nur aus abstrakten Klassen besteht?

Diese Komponente wäre komplett nutzlos, weil abstrakte Klassen und Interfaces keine Funktionalität beinhalten und noch nicht mal instanziiert werden können. Da aber keine anderen Komponenten von dieser Komponente abhängen, verstaubt sie einsam und verlassen in Deinem Repository.

Fazit

Es ist auch für App-Projekte (die ja meistens nicht allzu groß sind) sinnvoll das eigene Projekt in Komponenten aufzuteilen. So baust Du Dir im Laufe der Zeit einen eigenen Komponentenpool auf und schreibst nicht immer und immer wieder den gleichen Code.

Um zu entscheiden, welche Klassen in welche Komponenten kommen, haben wir drei Prinzipien kennengelernt:

  • Reuse/Release Equivalence: Klassen, die gemeinsam released werden, sollten zusammengefasst werden.
  • Common Closure: Klassen sollten so zusammengefasst werden, dass eine Änderung einer Anforderung möglichst alle Klassen der Komponente betrifft, dafür aber möglichst wenige Komponenten insgesamt.
  • Common Reuse: Klassen einer Komponente sollten gemeinsam genutzt werden. Nutzt Du eine, nutzt Du alle. So werden unnötige Abhängigkeiten vermieden.

Als nächstes haben wir gelernt, nach welchen Prinzipien die Abhängigkeiten von Komponenten untereinander geregelt sein sollte:

  • Acyclic Dependency: Komponenten sollten zyklenfrei sein, d.h.
    eine Komponente sollte nicht (auf Umwegen) wieder von sich selbst abhängen.
  • Stable Dependency: Eine Komponente, von der viele andere Komponenten abhängen, sollte sich möglichst selten ändern. Falls sich eine Komponente oft ändern muss, sollte sie also nicht viele abhängige Komponenten haben.
  • Stable Abstraction: Eine Komponente, von der viele andere Komponenten abhängen, sollte möglichst abstrakt sein (also abstrakte Klassen & Interfaces enthalten), um Erweiterungsmöglichkeiten zu bieten, ohne sich selbst zu oft zu ändern.

Ich hoffe dieser Artikel hilft Dir in Zukunft Komponenten für deine Projekte anzulegen und diese sinnvoll zu strukturieren. Vielen Dank fürs Lesen!

Im dritten Teil der Clean-Architecture-Blogreihe nähern wir uns dann endlich dem Thema Softwarearchitektur!

Beitragsbild von Michał Parzuchowski on Unsplash


Schreibe einen Kommentar

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