Clean Architecture
Haben unsere Schichten etwa Staub angesetzt?
Architekturstile wie Clean Architecture von Robert “Uncle Bob” Martin oder die Hexagonale Architektur von Alistair Cockburn stoßen in den letzten Jahren auf immer mehr Interesse. Sie zielen darauf ab, den fachlichen Kern einer Anwendung unabhängig von Frameworks, Datenbanken und sonstiger Infrastuktur entwickeln und testen zu können. Neben verbesserter Test- und Wartbarkeit erlaubt dieser Ansatz auch, Infrastruktur-Komponenten erst spät festzulegen und auch später noch leicht durch andere zu ersetzen. Der Artikel erläutert die Nachteile traditioneller Drei-Schichten-Architekturen und die Verbesserungen, die sich mit Clean Architecture erzielen lassen.
Die traditionelle Drei-Schichten-Architektur
Mit “Schichten” sind im Folgenden immer logische Schichten (engl. layer) gemeint, nicht die typischen physikalischen Schichten (engl. tier) Client, Server und Datenbank. In diesem logischen Sinn sieht eine Drei-Schichten-Architektur, wie sie sicher noch in einer Vielzahl von Systemen zum Einsatz kommt, folgendermaßen aus (links sind nur die Schichten und ihre Abhängigkeiten dargestellt, rechts exemplarische Klassen in diesen Schichten):
Die Präsentationsschicht (Presentation oder User Interface Layer) ist verantwortlich für die Darstellung der Anwendung gegenüber dem Anwender. Sie interpretiert dessen Eingaben und ruft die zugehörige Funktionalität in der Business-Logik-Schicht auf (oft Business Logic oder auch Domain Layer genannt). In der Regel ist es für diese Funktionalität erforderlich, Daten, die in einer Datenbank persistent gespeichert sind, auszuwerten oder zu ändern. Zu diesem Zweck ruft die Business-Logik-Schicht eine Datenzugriffsschicht (Data Access Layer) auf, die die nötige Kommunikation mit der Datenbank abwickelt.
Das grundlegende Prinzip von Schichtenarchitekturen ist, dass die Komponenten einer Schicht nur von Komponenten in darunter liegenden Schichten abhängig sein dürfen (häufig sogar nur von der unmittelbar darunter liegenden Schicht), während sie von den darüber liegenden Schichten überhaupt nichts wissen. Die höheren Schichten können daher problemlos ausgetauscht oder geändert werden, ohne dass die weiter unten liegenden Schichten angepasst werden müssen.
Die Schichtung an sich ist nicht problematisch, aber die traditionelle Ausprägung der Drei-Schichten-Architektur bringt folgende Nachteile mit sich:
- Alles hängt von der Datenzugriffsschicht und die wiederum vom Datenbank-Schema ab. Letztlich dreht sich daher alles zu allererst um die Datenbank. Während alle Datenbank-Hersteller das sicher als die natürliche Ordnung der Dinge ansehen, ist das in Zeiten von agiler Entwicklung, evtl. gar in Kombination mit Domain-Driven Design (DDD), kaum praktikabel. Schließlich bedeutet es (zumindest ohne weitere Anstrengungen zur Entkopplung, siehe unten), dass Änderungen an der Anwendung, die persistente Daten betreffen, zuerst in der Datenbank vorgenommen und dann nach oben durch die Schichten propagiert werden. Weil Datenbanken nur schwerfällig und aufwändig zu testen sind, macht man es lieber gleich beim ersten Mal “richtig” und / oder sträubt sich später, ein nur noch mäßig passendes Design für neue Anforderungen zu optimieren, wenn es nicht unbedingt sein muss. Beide Verhaltensweisen (“Big Design Up Front” und “Never touch a running system”) sind berüchtigte Fortschrittshemmnisse aus Zeiten des Wasserfall-Modells.
- Wenn die Business-Logik-Schicht direkt die Datenzugriffsschicht und diese direkt die Datenbank aufruft, kann nicht einmal die Business-Logik unabhängig von der Datenbank getestet werden. Das macht das Test-Setup in jedem Fall aufwändig und die Test-Ausführung langsam. Macht man sich nicht die Mühe, vor jedem Test einen exakt definierten Ausgangszustand in der Datenbank herzustellen, sondern sorgt nur dafür, dass bestimmte Daten vorhanden und bestimmte andere nicht vorhanden sind, ist das Test-Ergebnis auch weniger gut vorhersehbar.
- Die Business-Logik-Schicht enthält den fachlichen Kern der Anwendung (in seinem Buch Clean Architecture nennt Uncle Bob das die “Policy”, also die Geschäftsregeln), der von technischen Details wie Datenbanken und Web-Frameworks eigentlich stark abstrahiert ist. Durch die Abhängigkeit von der sehr technischen Datenzugriffsschicht muss die Business-Logik-Schicht aber auch jedes Mal angepasst werden, wenn sich technische Details des Datenzugriffs ändern.
- Zwar ist die Business-Logik-Schicht von der Präsentationsschicht prinzipiell unabhängig, viele GUI-Frameworks verleiten Entwickler aber geradezu dazu, kleine oder auch größere Teile der Business-Logik in Klassen wie Controllern unterzubringen, die vom GUI-Framework benötigt werden und daher in der Präsentationsschicht angesiedelt sind. Das läuft der mit der Schichten-Architektur bezweckten Separation of Concerns zuwider.
- Erschwerend kommt hinzu, dass die einführende Dokumentation vieler GUI- (und auch anderer) Frameworks den Fokus auf eine möglichst einfache Erläuterung der Konzepte des Frameworks mit möglichst kurzen Code-Beispielen legt und dafür Architekturaspekte vernachlässigt. Da werden z.B. in einer GUI-Klasse auch mal schnell ein paar fachliche Berechnungen und vielleicht sogar DB-Zugriffe durchgeführt. Bei den DB-Zugriffen ist den meisten Entwicklern klar, dass das in Produktions-Code sauberer getrennt werden muss, bei den fachlichen Berechnungen aber nicht so unbedingt.
Entkopplung durch Dependency Inversion
In der oben beschriebenen “Reinform” wird die Drei-Schichten-Architektur aufgrund der vielen Nachteile bei neuen Systemen kaum noch angewendet, und auch ältere Systeme werden vor anstehenden Änderungen zumindest in den betroffenen Teilen mittels Refactoring oft so weit umgebaut, dass die Business-Logik nicht mehr direkt mit der Datenbank gekoppelt ist. Dazu wendet man zunächst das Dependency Inversion Principle (DIP) an, indem man ein Interface extrahiert, so dass die Business-Logik zukünftig nur noch von diesem Interface abhängt, nicht mehr von der konkreten Implementierung, die immer gleich die Datenbank aufruft:
In Unit Tests für die entsprechenden Teile der Business-Logik kann man nun das Interface durch ein Test-Double (z.B. ein Mock-Objekt) implementieren, das exakt die für den Test notwendigen Antworten liefert, ohne dazu eine Datenbank aufzurufen. Natürlich laufen solche Tests viel schneller als Tests mit Datenbank und sind auch viel besser beherrschbar, weil man alles unter Kontrolle hat.
Was ist mit der Abhängigkeit auf Architektur-Ebene?
Durch die Anwendung des DIP haben wir erreicht, dass MyService
nicht mehr direkt von MyDataAccessObject
abhängt, was die Testbarkeit enorm verbessert hat. Aus Architektursicht (linke Seite des obigen Diagramms) hat sich aber nichts geändert: Die fachliche Business-Logik hängt immer noch von der technischen Komponente für den Datenzugriff ab. Wird das neue Interface als reiner Implmentierungs-Kunstgriff zur Verbesserung der Testbarkeit erstellt, aber nach wie vor an den technischen Erfordernissen des Datenzugriffs ausgerichtet und in der Datenzugriffsschicht angesiedelt, bleibt auch das Problem erhalten, dass die Business-Logik immer wieder an technisch motivierte Änderungen in der Datenzugriffsschicht angepasst werden muss.
Um dieses Problem zu lösen, muss man noch etwas mehr umdenken: Nicht die Datenzugriffsschicht sollte eine Low-Level-Schnittstelle zum Zugriff auf die Datenbank anbieten, sondern die Business-Logik sollte eine fachlich motivierte High-Level-Schnittstelle vorgeben, die ein der Fachlichkeit angemessener Persistenz-Mechanismus umzusetzen hat! Die fachlichen Probleme, die eine typische Enterprise-Anwendung löst, haben in der Regel nichts damit zu tun, dass bestimmte Datensätze in einer Datenbank-Tabelle manipuliert oder gefunden werden müssen, entsprechend wird man auch nichts derartiges in so einer Schnittstelle vorfinden. Eine Business-Logik-Komponente für das Mahnwesen wird aber z.B. den Bedarf haben, unbezahlte Rechnungen zu finden, die bis zu einem bestimmten Datum fällig gewesen wären, und dafür vielleicht eine Methode wie Collection<Invoice> findUnpaidInvoicesDueBy(Date dueDate)
fordern, die genau diese Rechnungen zurückliefern soll.
Uncle Bob bezeichnet eine solche Schnittstelle als Gateway bzw. Gateway-Interface. Eric Evans hat für das analoge Konstrukt in Domain-Driven Design den Begriff Repository geprägt. Die Idee ist ähnlich, der Begriff Gateway betont allerdings den Zugriff über ein Netzwerk, während der Begriff Repository diesen Aspekt herunterspielt. Da hier primär Clean Architecture beschrieben werden soll, bleiben wir bei Gateway:
Wie man sieht, geht die Abhängigkeit auf Architektur-Ebene jetzt vom Datenzugriff zur Business-Logik, womit die Business-Logik von technischen Details des Datenzugriffs unabhängig wird!
Da die von der Gateway-Implementierung geforderten Fähigkeiten fachlich definiert sind, können im Gateway-Interface keine technischen Details auftauchen, die nur für einen bestimmten Persistenzmechanismus zutreffen. Entsprechend einfach sollte es möglich sein, neben einer Implementierung für – sagen wir – Oracle auch eine Implementierung für PostgreSQL oder auch nicht-relationale Datenbanken wie MongoDB zu erstellen. Damit wird es vergleichsweise einfach möglich, die Datenbank-Technologie (oder auch nur den OR-Mapper, wenn ein solcher zur Implementierung verwendet wird) später zu wechseln oder mehrere Datenbanken für die gleiche Anwendung zu unterstützen (etwa für unterschiedliche Kundeninstallationen).
Mit dieser Architektur ist es sogar möglich, die Business-Logik (oder einen signifikaten Teil davon, z.B. für ein Minimum Viable Product) bereits zu implementieren und zu testen, wenn man noch gar keine Datenbank am Laufen hat und sich noch nicht einmal auf eine bestimmte Datenbank festgelegt hat! Das erlaubt es, diese technische Entscheidung zu vertagen, bis man genügend über die tatsächlichen Anforderungen an ein erfolgreiches Produkt weiß, um die Vor- und Nachteile bestimmter Datenbank-Technologien im Hinblick auf diese Anforderungen zu bewerten.
Ist für die GUI auch etwas zu beachten?
Wie in der letzten Abbildung zu sehen, sind sowohl GUI- als auch Datenzugriffs-Komponenten nun abhängig von der Business-Logik, während die Business-Logik von beidem unabhängig ist. Um das auch für die GUI zu garantieren, empfiehlt Clean Architecture, zunächst die Business-Logik nicht nur unabhängig von der Datenbank, sondern auch unabhängig von der GUI und zugehörigen Frameworks zu entwickeln, rein auf Basis von fachlich definierten Use Cases, die von Tests angetrieben werden (TDD - Test Driven Development).
Die Use-Case-Implementierung gibt dazu eine Schnittstelle vor (inkl. der mitzugebenden Daten), über die die GUI (oder ein Unit Test) den Use Case auslösen kann. Der Use Case ruft seinerseits entsprechende fachliche Logik auf und liefert das Ergebnis in einem fachlich angemessenen Format zurück. Dem Anwender geeignet Rückmeldung zu geben ist dann wieder Aufgabe der GUI und nicht Teil der Business-Logik.
Dieses Vorgehen stellt sicher, dass die Business-Logik nicht vom verwendeten GUI-Framework abhängig ist, so dass dieses Framework sich wieder relativ leicht austauschen lässt, ohne die Business-Logik ändern zu müssen. Das erleichtert auch ungemein, parallel mehrere GUI-Clients zu unterstützen (z.B. neben einem Web-Client auch native Clients für Android und iOS).
Clean Architecture
Der resultierende Architekturstil “Clean Architecture”, bei dem die Business-Logik als fachlicher Kern der Anwendung unabhängig von allen I/O-Details und Frameworks entwickelt und getestet wird, wird bevorzugt in Form von konzentrischen Ringen dargestellt - hier gibt es nicht mehr wie bei traditionellen Schichten-Architekturen “weiter oben” (näher an der GUI) und “weiter unten” (näher an der Datenbank), sondern “weiter innen” (näher am fachlichen Kern) und “weiter außen” (näher an den technischen Details):
Die Business-Logik ist hier noch einmal aufgeteilt in den innersten fachlichen Kern (“Entities”, gelb), der “anwendungsneutral” rein durch das geschäftliche Umfeld des Unternehmens bestimmt wird, und die konkrete Umsetzung zugehöriger Use Cases in der aktuellen Anwendung (“Use Cases”, rot) - das entspricht auch grob der in DDD üblichen Aufteilung in “Domain Model” und “Application” (jedenfalls ist das eine zweckmäßige Vereinfachung, wenn man beides zusammen einsetzen will). Der nächste Ring (“Interface Adapters”, grün) enthält Adapter, die zwischen den für Use Cases und Entities geeignetsten Datenformaten und den von den Frameworks und Treibern des äußersten Rings (“Frameworks & Drivers”, blau) benötigten Formaten konvertieren. Der äußerste Ring enthält neben technischen Frameworks in der Regel nur Glue Code, der die Frameworks des blauen mit den Interface-Adaptern des grünen Rings verbindet.
Wichtig ist dabei insb. die “Dependency Rule”, angedeutet durch die Pfeile zwischen den Ringen: Code-Abhängigkeiten dürfen nur von außen nach innen, aber nie von innen nach außen gehen. Damit insb. der fachliche Kern von technischen Belangen unbehelligt bleibt, darf er von diesen technischen Belangen auch nichts wissen!
Uncle Bob hat seine eigene Zusammenfassung dieses Architekturstils auf dem Clean Coder Blog veröffentlicht.
Wofür lässt sich Clean Architecture einsetzen?
Klar ist, dass die Entkopplung der Ringe der Clean Architecture wie oben beschrieben nicht nur eine Menge Vorteile, sondern auch einiges an Arbeit mit sich bringt. Dieser Aufwand lohnt sich, wenn die umzusetzende Fachlichkeit eher komplex ist und voraussichtlich über einen längeren Zeitraum gewartet werden muss, weil sie wichtige Geschäftsprozesse unterstützt. Für einfache CRUD-Anwendungen (die i.W. nur einigermaßen schöne Masken zur direkten Bearbeitung von Datenbank-Tabellen benötigen) lohnt sich das natürlich nicht, da tut es vielleicht auch eine Low-Code-Plattform oder Microsoft Access. Für Wegwerf-Prototypen und kurzlebige Demonstratoren lohnt sich der Aufwand natürlich auch nicht, da die Clean Architecture ihre Vorteile erst ausspielt, wenn fachliche oder technische Änderungen anstehen, die dann jeweils nur einen kleinen, wohldefinierten Bereich der Anwendung betreffen.
Diese Einsatz-Empfehlung deckt sich übrigens recht gut mit den einschlägigen Empfehlungen zum Einsatz von Domain-Driven Design, weshalb sich beides auch sehr gut ergänzt. Für Microservices und Self-Contained Systems lohnt sich diese Kombination besonders, denn DDD hat mit dem Bounded Context ein sehr gutes Konstrukt zur fachlichen Schneidung von Microservices (oder Self-Contained Systems) und Clean Architecture hilft dabei, die Fachlichkeit eines Dienstes unabhängig von anderen Diensten zu entwickeln, auch wenn er zur Laufzeit mit den anderen Diensten interagieren muss.
Eine gute (vor allem nicht absolut triviale) Beispiel-Anwendung, die die Prinzipien von Clean Architecture (und auch Domain-Driven Design) beherzigt, ist etwa Microsofts eShopOnWeb für ASP.NET Core, den es als eShopOnContainers auch in einer Microservice-Variante gibt.