Clean Code auf der grünen Wiese ist leicht. Oder auch nicht. Aber Clean Code im Bestand ist garantiert nicht einfach. Wenn der Code über Jahre oder Jahrzehnte gewachsen ist, beseitigt man die vielen Verletzungen der Clean Code Prinzipien nicht mal eben so durch diverse Refactorings. Der eine Grund liegt in der fehlenden Testabdeckung, der andere in der Frage nach dem Sinn.
Fehlende Testabdeckung
Typischerweise sieht die Testabdeckung für Legacy Systeme nicht besonders gut aus. Da die Entwickler nicht von Anfang an automatisierte Tests geschrieben haben, gibt die Struktur der Software es in der Regel nicht her, Tests „mal eben so“ zu ergänzen. Das liegt in der Hauptsache an Abhängigkeiten und unklaren Verantwortlichkeiten der Methoden und Klassen. Die Clean Code Prinzipien Single Responsibility Principle (SRP), Dependency Inversion Principle (DIP) und Integration Operation Segregation Principle (IOSP) sind verletzt.

Der Sinn von Clean Code
Bevor man sich über Clean Code in einem Legacy Projekt Gedanken macht und Refactorings anwendet, ist es wichtig sich folgendes klarzumachen: Clean Code ist kein Selbstzweck. Im Vordergrund stehen immer die vier Werte:
- Korrektheit
- Wandelbarkeit
- Produktionseffizienz
- Kontinuierliche Verbesserung
Wenn also jemand im Team auf die Idee kommt, nun doch endlich mal aufzuräumen und die Clean Code Prinzipien anzuwenden, stellt sich die Frage: Warum? Welcher Wert soll durch die Einführung von Clean Code mittels Refactoring erreicht werden?
Typischerweise kommen hier die Werte Korrektheit und Wandelbarkeit zum Tragen.
Korrektheit
Hat das Legacy System massiv mit Bugs zu kämpfen, werden die Kunden langsam unruhig und suchen nach Alternativen. Das kann für die wirtschaftliche Situation eines Unternehmens unangenehme Folgen haben. In der Regel sind die Legacy Systeme die Cash Cows, tragen also am meisten zu den Einnahmen des Unternehmens bei. In dieser Situation ist es erforderlich, schnellstmöglich Tests zu ergänzen, um die Fehler in effizienter Weise beheben zu können.
Wandelbarkeit
Der Wert der Wandelbarkeit gerät in Gefahr, wenn die Entwickler nicht mehr in der Lage sind, effizient neue Features zu ergänzen oder Änderungen vorzunehmen. Dies liegt in der Regel an Methoden und Klassen, die viel zu viele Verantwortlichkeiten haben. Dadurch ergibt sich eine Struktur, die nur schwer nachvollziehbar ist. Doch bevor daran Änderungen vorgenommen werden können, müssen auch hier Tests dafür sorgen, dass durch die Refactorings nichts kaputt gemacht wird.
Warum sollte ein Team, das verantwortlich ist für ein Legacy System, sich also mit Clean Code befassen? Entweder es gilt, Bugs zu fixen, oder es gilt neue Features zu implementieren.

Das Ziel definieren
Bevor man damit beginnt, Tests zu ergänzen oder den Code zu refaktorisieren, muss das Ziel der Maßnahme definiert werden. Der erforderliche Aufwand, den gesamten Code „hübsch“ zu machen, ist zu hoch. Folglich muss ein Ziel definiert werden, um die Test- und Refactoring Maßnahmen sinnvoll auf Teile des Codes zu begrenzen. Geht es eher um Bugs, müssen die Stellen im Code identifiziert werden, an denen die Häufigkeit von Fehlern besonders groß ist. Dabei hilft ein Blick in die Versionskontrolle. Sie zeigt, welche Dateien häufig von Änderungen betroffen sind. Es sollte versucht werden, eine gute Testabdeckung in den Codebereichen zu erzielen, die von Fehlern oder Featurewünschen am meisten betroffen sind.
Tests stehen am Anfang
In beiden Fällen, Fehlerbehebung oder Featurewunsch, gilt es, das System unter Test zu stellen. Nur automatisierte Tests führen zur erforderlichen Effizienz. Versucht man weiterhin, die Korrektheit sicherzustellen, in dem per Hand getestet wird, bleibt man im bisherigen Muster. Man löst Probleme in der Regel nicht dadurch, immer wieder das gleiche zu tun (oder nicht zu tun).
Da die Struktur von Legacy Systemen es meist nicht hergibt, isolierte Unit Tests auf kleine Ausschnitte der Software zu schreiben, müssen zunächst Integrationstests her. Das bringt zwar die Testpyramide zunächst in Schieflage, weil eigentlich mehr Unit Tests als Integrationstests existieren sollten. Doch im Legacy Fall ist es der effizienteste und sicherste Weg, zunächst mit Integrationstests zu beginnen, da diese keine oder nur sehr geringe Änderungen am Code erfordern. Zu den Begriffen Integrationstest, Unit Tests etc. findest du hier einen Beitrag. Ferner können Docker und das TestContainers Projekt helfen.
Continuous Integration
Die besten Tests nützen nichts, wenn sie nicht regelmäßig ausgeführt werden. Das liegt auf der einen Seite in der Verantwortlichkeit der Entwickler. Auf der anderen Seite muss sichergestellt werden, dass Defekte schnellstmöglich erkannt werden, auch wenn ein Entwickler seinen Code in die Versionskontrolle eincheckt, ohne zuvor die Tests auszuführen.
Hier hilft Continuous Integration (CI). Falls noch nicht geschehen, sollte zu diesem Zeitpunkt unbedingt ein CI Prozess aufgesetzt werden. Mit Tools wie GitHub oder GitLab ist das mit überschaubarem Aufwand verbunden. Da zu diesem Zeitpunkt vor allem Integrationstests erstellt wurden, liegt die Herausforderung im CI Prozess darin, die Integrationstests auf dem CI Server auszuführen. Einerseits laufen sie länger, andererseits benötigen sie die externen Abhängigkeiten wie Datenbanken o.ä. Hier hilft der Einsatz von Docker, da dies auch auf dem CI Server unterstützt wird. Auch dazu habe ich bereite an anderer Stelle geschrieben.
Unit Tests ergänzen
Nachdem durch Integrationstests die Korrektheit in einem kleinen Ausschnitt der Software sichergestellt werden kann, sollte die Struktur in diesem Bereich verbessert werden mit dem Ziel, isolierte Unit Tests ergänzen zu können. Aber Achtung: die Testabdeckung ist nicht hoch genug, um hier per Hand zu refaktorisieren. In dieser Phase ist es zwingend, sich überwiegend auf toolgestützte Refactorings zu verlassen. Sie stellen sicher, dass die Semantik erhalten bleibt. Nützliche Refactorings, mit denen die Einführung von Unit Tests vorbereitet werden können, sind beispielsweise
- Extract Method
- Introduce Variable
- Introduce Parameter
Nachdem ein Codeausschnitt aus einer größeren Methode mit Extract Method extrahiert wurde, kann diese neue Methode isoliert unter Test gestellt werden.
Die Struktur verbessern
Mit steigender Testabdeckung sind nun auch umfangreichere Refactorings möglich, die nicht immer vollständig toolgestützt durchgeführt werden können. Das Ziel ist hierbei, den Code so zu strukturieren, dass er leichter verständlich wird. Es kommt hier vor allem das Single Responsibility Principle (SRP) zum Einsatz. Zwangsläufig kommt man bei der Verlagerung von Verantwortlichkeiten mit dem Thema Abhängigkeiten in Kontakt. Ein erster Schritt waren zuvor die Extract Method Refactorings. Nun kommen die Prinzipien Dependency Inversion Principle (DIP) und Integration Operation Segregation Principle (IOSP) zum Einsatz.
Durch Dependency Inversion und Dependency Injection können Abhängigkeiten im Test durch Attrappen ersetzt werden. Das sollte allerdings nur für externe Ressourcen verwendet werden, um diese vereinzelt im Test ersetzen zu können.
Für die gesamte Domänenlogik sollte auf das IOSP gesetzt werden.

Dopplungen beseitigen
Nachdem nun die Testabdeckung im kritischen Bereich der Software erhöht wurde, kann auch das Thema Don’t Repeat Yourself (DRY) angegangen werden. Dopplungen im Code sind ineffizient, da im Zweifel mehrere Stellen geändert werden müssen. Vor allem stellt sich aber jeweils die Frage, ob tatsächlich eine simple Dopplung vorliegt, oder ob es Unterschiede im kopierten Code gibt. Auch diese archäologische Arbeit ist völlig ineffizient. Im Zweifel ist es daher manchmal besser, mit Dopplungen zu leben.
Bevor man an die Beseitigung einer Dopplung im Code heran geht, muss geklärt werden, ob sich die beiden gleichen oder sehr ähnlichen Codeteile tatsächlich aus denselben Gründen ändern werden.
Manchmal liegt der gleiche Code vor, erfüllt jedoch unterschiedliche Anforderungen, die sich getrennt voneinander entwickeln können. Handelt es sich nicht um dieselbe Anforderung, sollte der Code getrennt bleiben.
Hilfreiche Refactorings im Kontext von DRY Verletzungen sind vor allem Extract Method und Introduce Parameter. Zunächst wird die Dopplung mit Extract Method in eine eigene Methode herausgezogen. Anschließend kann durch Einführung von Parametern mit Introduce Parameter versucht werden, die Unterschiede in den Dopplungen über Parameter herauszuarbeiten. Dies ermöglicht es oft, die Dopplung zu beseitigen, obschon es sich nicht um eine exakte Kopie handelt.
Fazit
Refactorings an Bestandscode müssen mit Bedacht ausgeführt werden. Die Pfadfinderregel kann jederzeit angewandt werden. Doch darunter fallen nur toolgestützte Refactorings, die mit einem Aufwand von wenigen Minuten durchgeführt werden können. Commit nicht vergessen! Jede dieser kleinen Refactorings muss in der Versionskontrolle nachvollziehbar und vor allem zurücknehmbar sein, falls etwas kaputt gegangen ist.
Der Ablauf in Stichworten:
- Das Ziel definieren
- Auswahl der betroffenen Bereiche, bspw. über die Änderungshäufigkeit
- Ergänzen von Integrationstests, um ein erstes Sicherheitsnetz aufzuspannen
- Continuous Integration (CI) aufsetzen, falls noch nicht vorhanden
- Toolgestützte Refactorings mit dem Ziel, Unit Tests ergänzen zu können
- Strukturelle Änderungen der Abhängigkeiten und Einführung von DIP und IOSP
- Dopplungen eliminieren
Dieser Ablauf ist nicht in Stein gemeißelt. Er soll verdeutlichen, das Refactorings an Legacy Code ein geordnetes Vorgehen erfordern.
Wir unterstützen Dich gerne mit unseren Trainings bei einem solchen Projekt!