Legacy Code modernisieren und dabei in ein neues Projekt portieren: Dafür waren ungefähr zwei Wochen vorgesehen. Da ich parallel andere Aufgaben hatte, konnte ich realistisch nur etwa die Hälfte meiner Zeit investieren. Effektiv blieb damit ungefähr eine Arbeitswoche.
In dieser Timebox hatte ich bereits neue Klassen angelegt, Adapter geschrieben und den großen Controller in kleinere Teile zerlegt. Das war kein Aufräumen um des Aufräumens willen. Ich hatte konkrete Schwachstellen im alten Aufbau identifiziert und gesehen, wie sich die Verantwortlichkeiten besser schneiden ließen. Mit einem ersten Durchstich wollte ich prüfen, ob diese Richtung tragfähig war. Der Code sah deutlich besser aus als vorher. Der Kalender nicht.
Dann begann ich, das tatsächliche Verhalten zu übertragen. Dabei merkte ich, dass ich für einen großen Teil der fachlichen Regeln weder einen sicheren Platz noch einen verlässlichen Test hatte. Der neue Code war aufgeräumter. Mein Vertrauen in seine fachliche Richtigkeit sank trotzdem mit jedem Schritt.
Nun stand ich vor der eigentlichen Entscheidung: Sollte ich den Legacy Code neu schreiben und die neue Architektur auf der grünen Wiese weiterbauen? Oder sollte ich den Legacy Code portieren und ihn schrittweise in diese Richtung entwickeln?
Aus einer Portierung hatte ich stillschweigend einen Neubau gemacht. Das Zielbild war nicht das Problem. Der Weg dorthin war es.
Inhalt
Warum der Neubau zunächst vernünftig wirkte
Die Aufgabe klang überschaubar: Eine größere bestehende Funktionalität sollte aus einem alten Repository in ein neues Projekt umziehen. Das Feature war im bisherigen System implementiert und im Einsatz. Ich musste es in der neuen Umgebung wieder nutzbar machen. Der enge Zeitrahmen passte zu einer Portierung, nicht zu einer fachlichen Neuentwicklung.
Besonders ein großer Controller fiel sofort auf. Er hatte mehr als ein Dutzend Abhängigkeiten, fragte mehrere Domain-Services ab, bereitete Informationen für die UI auf und baute Rückkanäle für Interaktionen zusammen:
class FeatureController(
private val navigationDomain: NavigationDomainService,
private val mediaDomain: MediaDomainService,
private val connectivityDomain: ConnectivityDomainService,
private val climateDomain: ClimateDomainService,
private val chargingDomain: ChargingDomainService,
private val assistanceDomain: AssistanceDomainService,
private val codingService: CodingService,
private val configurationService: ConfigurationService,
private val contentRepository: ContentRepository,
private val permissionService: PermissionService,
private val uiNavigator: UiNavigator,
private val logger: Logger,
// weitere projektspezifische Anbindungen
)Gekapselte Komponenten für Menü- und View-Zustände, Layout-Mapping, Ergebnisse oder Callbacks gab es noch nicht. Auch die Verarbeitung einer Auswahl steckte direkt in privaten Funktionen des Controllers. Die Klasse hatte alles eingesammelt, was irgendwo zwischen Fachlogik und UI liegen geblieben war.
Der erste Durchstich bestätigte zunächst meine Annahme. Die neuen Grenzen wirkten sinnvoll, und einige Abhängigkeiten ließen sich erkennbar besser kapseln. Mein Fehler war nicht, diese Richtung zu untersuchen. Ich hielt den Durchstich zu früh für den Start der eigentlichen Umsetzung. Ich glaubte, Portierung und Modernisierung in einem Zug erledigen zu können.
Also baute ich auf dem Durchstich weiter. Die Klassen wurden kleiner und entsprachen eher dem, wie ich den Code heute bauen würde. Dabei hatte ich allerdings vor allem seine Form bewertet. Den fachlichen Zusammenhang zwischen den Teilen verstand ich noch nicht.
Der Controller hatte zu viele Verantwortlichkeiten. Daraus folgte aber nicht, dass sich jede davon unabhängig verschieben ließ. Manche Abhängigkeiten wurden gemeinsam gebraucht, um eine fachliche Entscheidung zu treffen.
Das merkte ich erst, als die neue Struktur das alte Verhalten übernehmen sollte. Plötzlich musste ich entscheiden, wo Kombinationen aus Feature-Zuständen, Verfügbarkeiten und Kodierungswerten künftig hingehörten. Darauf hatte ich noch keine belastbare Antwort. Die verbleibende Zeit lief währenddessen weiter.
Die neue Struktur kannte das alte Verhalten nicht
Besonders deutlich wurde das bei der Feature-Verfügbarkeit. Im Code gab es einen statischen Katalog mit mehr als 100 möglichen Funktionen. Welche davon tatsächlich verfügbar waren, entschied sich erst zur Laufzeit. Dafür wurden Zustände aus mehreren fachlichen Domänen mit Kodierungswerten und Konfigurationen verbunden.
data class FeatureDefinition(
val id: FeatureId,
val domain: Domain,
val codingRules: Set<CodingRule>,
val configurationRules: Set<ConfigurationRule>,
)
private val staticFeatureCatalog = listOf(
FeatureDefinition(
id = FeatureId.NAVIGATION_SEARCH,
domain = Domain.NAVIGATION,
codingRules = setOf(CodingRule.NAVIGATION, CodingRule.ONLINE_SERVICES),
configurationRules = setOf(ConfigurationRule.SEARCH_ALLOWED),
),
FeatureDefinition(
id = FeatureId.MEDIA_RADIO,
domain = Domain.MEDIA,
codingRules = setOf(CodingRule.RADIO),
configurationRules = setOf(ConfigurationRule.MEDIA_SOURCE_ALLOWED),
),
// mehr als 100 weitere statische Einträge
)
@VisibleForTesting
private fun resolveAvailableFeatures(): List<FeatureDefinition> {
val domainStates = mapOf(
Domain.NAVIGATION to navigationDomain.featureStates(),
Domain.MEDIA to mediaDomain.featureStates(),
Domain.CONNECTIVITY to connectivityDomain.featureStates(),
Domain.CLIMATE to climateDomain.featureStates(),
Domain.CHARGING to chargingDomain.featureStates(),
Domain.ASSISTANCE to assistanceDomain.featureStates(),
)
return staticFeatureCatalog.filter { feature ->
val domainAllows = domainStates
.getValue(feature.domain)
.isAvailable(feature.id)
val codingAllows = codingService.matches(feature.codingRules)
val configurationAllows =
configurationService.matches(feature.configurationRules)
domainAllows && codingAllows && configurationAllows
}
}Ein Katalog mit mehr als 100 Einträgen traf auf dynamische Zustände aus sechs Domänen. Jeder Eintrag konnte eigene Kodierungs- und Konfigurationsregeln mitbringen. Keine einzelne Abhängigkeit konnte beantworten, ob ein Feature verfügbar sein sollte. Erst ihr Zusammenspiel lieferte das Ergebnis.
Ich hätte die Methode einfach in eine neue Klasse verschieben können. Der Controller wäre kleiner gewesen. Die fachlichen Fragen wären geblieben: Welche Regeln gehören zusammen? Welche sind domänenspezifisch? Und wie weise ich nach, dass bei mehr als 100 Einträgen keine seltene Kombination verloren geht?
Die Feature-Verfügbarkeit blieb zudem nicht isoliert. Aus ihrem Ergebnis entstanden UI-Einträge und Rückkanäle für Interaktionen. Eine unbemerkte Änderung konnte beeinflussen, was angezeigt wurde und was nach einer Bedienung geschah.
Meine neue Struktur wusste, welche Klassen ich gern hätte. Sie wusste nicht, welche Regeln sie bewahren musste.
Der Auftrag war eine Portierung
Der Auftrag bestand nicht nur darin, Code an einen neuen Ort zu verschieben. Auch die sichtbaren Inhalte änderten sich deutlich. Neue Inhalte kamen hinzu, andere verschwanden. Menüpunkte wurden zusammengeführt oder aufgeteilt.
Die Mechaniken darunter blieben jedoch bestehen. Nach welchen Regeln ein Menüpunkt angezeigt wurde, wie seine Verfügbarkeit ermittelt wurde und was er bei einer Bedienung auslöste, änderte sich nicht. Die neue Menüstruktur musste weiterhin nach diesen Regeln funktionieren.
Diese Unterscheidung hatte ich beim Start nicht sauber gemacht. Mit dem Neubau hatte ich das Wissen über das bestehende Produkt beiseitegelegt und musste jede Regel, Ausnahme und Kombination erneut rekonstruieren. Aus Kundensicht gab es dafür wenig zu gewinnen. Wichtig war, dass die überarbeitete Menüstruktur im neuen Projekt zuverlässig funktioniert.
Die bessere innere Struktur war vor allem ein Wunsch des Entwicklerteams. Ein sinnvoller Wunsch, aber kein Grund, die Fachlogik nebenbei neu zu bauen.
Neue Inhalte brauchen nicht automatisch eine neue Fachlogik.
Spätestens hier wurde der Zeitrahmen zum Entscheidungskriterium. Mit effektiv etwa einer Arbeitswoche konnte ich auf dem bekannten Verhalten aufbauen oder einen fachlichen Neubau fortsetzen, dessen Umfang ich noch nicht einmal sicher benennen konnte.
Also stoppte ich den Neubau.
Die architektonische Richtung verwarf ich damit nicht. Ich änderte den Weg dorthin.
Copy-and-paste als Reißleine
Der nächste Schritt war technisch wenig glamourös. Ich kopierte den relevanten alten Code in das neue Projekt.
Lauffähig war er damit natürlich noch nicht. Die Abhängigkeiten passten nicht zur neuen Umgebung, und der Controller ließ sich nicht einfach kompilieren. Beim erneuten Verbinden zeigten sich aber nicht nur falsche Schnittstellen. In mehreren Domains fehlten Funktionen, die der übergreifende Mechanismus voraussetzte. Andere Anbindungen lieferten ihre Daten noch nicht in der benötigten Form.
Der übernommene Code zwang das neue Projekt zu einer einfachen Bestandsaufnahme: Was davon ist wirklich schon angeschlossen?
Offene Abweichungen markierte ich direkt an der betroffenen Stelle. Eine unvollständige Anbindung an den Routing-Service sah etwa so aus:
class NavigationDomainAdapter(
private val routingService: RoutingService,
) : NavigationDomainService {
override fun featureStates(): FeatureStates {
// TODO: Der RoutingService liefert noch keine Feature-Zustände.
// Die Verfügbarkeit der Navigation muss hier abgebildet werden,
// damit der globale Feature-Katalog sie auswerten kann.
return FeatureStates.empty(Domain.NAVIGATION)
}
}Der leere Rückgabewert war keine fertige Lösung. Er hielt die Lücke sichtbar, während die umgebende Integration weiter aufgebaut werden konnte. Fehlende Funktionen und unpassende Anbindungen bekamen einen klaren Marker, bis sie für den globalen Mechanismus umgesetzt waren.
Damit wurde die Copy-and-paste-Portierung zugleich zu einer Prüfung auf Vollständigkeit. Sie zeigte sehr konkret, was dem neuen Projekt noch fehlte.
Das Ziel blieb zunächst eng: den bekannten Stand wieder lauffähig machen. Jede gleichzeitige Verbesserung hätte eine weitere Veränderung eingebracht. Bei einem Fehler wäre unklar gewesen, ob beim Übertragen etwas verloren ging oder das Refactoring das Verhalten verändert hatte.

Der alte Code war kein Vorbild für die neue Architektur. Er war die vollständigste Referenz für das vorhandene Verhalten.
Erst den bekannten Stand lauffähig machen. Dann verändern.
Kompilierender Code bewies allerdings noch nicht, dass das Verhalten erhalten geblieben war. Dafür brauchte ich die vorhandenen Tests. Ausgerechnet die waren selbst Teil des Problems.
Die Tests waren schlecht. Ihr Wissen nicht.
resolveAvailableFeatures() war eine private Hilfsmethode. Damit die Tests direkt darauf zugreifen konnten, war sie mit @VisibleForTesting annotiert:
@VisibleForTesting
private fun resolveAvailableFeatures(): List<FeatureDefinition> {
// komplexe Auswertung der Feature-Verfügbarkeit
}Der Test hing damit an einem Implementierungsdetail des Controllers. Für einen einzelnen Fall musste zudem die gesamte Klasse mit mehr als einem Dutzend Abhängigkeiten aufgebaut werden:
@Test
fun `navigation search is available when all rules match`() {
val controller = FeatureController(
navigationDomain = navigationDomainWith(
FeatureId.NAVIGATION_SEARCH,
),
mediaDomain = mockk(relaxed = true),
connectivityDomain = mockk(relaxed = true),
climateDomain = mockk(relaxed = true),
chargingDomain = mockk(relaxed = true),
assistanceDomain = mockk(relaxed = true),
codingService = codingAllowing(
CodingRule.NAVIGATION,
CodingRule.ONLINE_SERVICES,
),
configurationService = configurationAllowing(
ConfigurationRule.SEARCH_ALLOWED,
),
contentRepository = mockk(),
permissionService = mockk(),
uiNavigator = mockk(),
logger = mockk(),
// weitere für den Controller benötigte Mocks
)
val result = controller.resolveAvailableFeatures()
assertThat(result.map { it.id })
.contains(FeatureId.NAVIGATION_SEARCH)
}Mehrere dieser Abhängigkeiten hatten mit der geprüften Regel nichts zu tun. Sie waren nur nötig, weil der Test den gesamten Controller instanziierte. Das war ein Anti-Pattern. Der Test beschrieb trotzdem einen gültigen fachlichen Fall: Welche Zustände mussten zusammenkommen, damit die Navigationssuche verfügbar war?
Deshalb räumte ich die Tests nicht sofort auf. Ich übernahm sie zusammen mit dem Controller und brachte sie im neuen Projekt wieder zum Laufen. @VisibleForTesting, der direkte Zugriff auf die private Methode und das umfangreiche Mocking blieben zunächst erhalten.
Erst als die Tests wieder grün waren, begann das Refactoring. Die Feature-Verfügbarkeit wanderte zusammen mit den benötigten Domain-Services sowie der Kodierungs- und Konfigurationsauswertung in den neuen FeatureAvailabilityResolver. resolveAvailableFeatures() wurde dort zu einer regulären öffentlichen Methode.
Der fachliche Testfall blieb erhalten. Er lief nun gegen die neue Komponente:
@Test
fun `navigation search is available when all rules match`() {
val resolver = FeatureAvailabilityResolver(
navigationDomain = navigationDomainWith(
FeatureId.NAVIGATION_SEARCH,
),
mediaDomain = mediaDomainWithoutFeatures(),
connectivityDomain = connectivityDomainWithoutFeatures(),
climateDomain = climateDomainWithoutFeatures(),
chargingDomain = chargingDomainWithoutFeatures(),
assistanceDomain = assistanceDomainWithoutFeatures(),
codingService = codingAllowing(
CodingRule.NAVIGATION,
CodingRule.ONLINE_SERVICES,
),
configurationService = configurationAllowing(
ConfigurationRule.SEARCH_ALLOWED,
),
)
val result = resolver.resolveAvailableFeatures()
assertThat(result.map { it.id })
.contains(FeatureId.NAVIGATION_SEARCH)
}Acht Abhängigkeiten sind immer noch keine Kleinigkeit. Sie werden für die Feature-Verfügbarkeit aber tatsächlich gebraucht. Die fachfremden Abhängigkeiten und der direkte Testzugriff auf die private Controller-Methode waren verschwunden.
Auch andere Lücken ließen sich nun schließen. Sichtbare UI-Ergebnisse und Rückkanäle waren vorher nur für einzelne Konstellationen geprüft. Nach dem Herauslösen der jeweiligen Komponenten konnten wir diese Varianten gezielter testen.
Wie UI-Tests dieses Sicherheitsnetz bei Refactorings ergänzen, zeige ich in diesem Video:
Die Kopplung durfte verschwinden. Das Wissen aus den Testfällen nicht.
Die Abhängigkeiten zeigten den Schnitt
Für die weitere Aufteilung begann ich nicht mit einem Klassendiagramm. Ich sah mir an, welche Abhängigkeiten von welchen Aufgaben tatsächlich gebraucht wurden.
Der erste Durchstich blieb dabei eine Orientierung, aber keine Vorlage, die der Bestand um jeden Preis erfüllen musste. Jeder neue Schnitt musste sich nun am tatsächlichen Verhalten und an den vorhandenen Tests bewähren.
Die sechs Domain-Services sowie Kodierung und Konfiguration gehörten zur Feature-Verfügbarkeit. Der Aufbau der sichtbaren Ergebnisse brauchte andere Abhängigkeiten. Interaktionen und Rückkanäle bildeten eine weitere Aufgabe.

Einige dieser Aufgaben hatten im alten Controller noch keine eigene Abstraktion. Menü- und View-Zustände wurden direkt verarbeitet, ebenso die Auswahl eines Eintrags. Layout-Mapping, Result-Aufbau und Callback-Erzeugung waren private Funktionen. Beim Herauslösen entstanden daraus der LayoutMapper, die ResultFactory und die CallbackFactory. Damit wurden auch diese Schritte getrennt testbar.
class FeatureAvailabilityResolver(
private val navigationDomain: NavigationDomainService,
private val mediaDomain: MediaDomainService,
private val connectivityDomain: ConnectivityDomainService,
private val climateDomain: ClimateDomainService,
private val chargingDomain: ChargingDomainService,
private val assistanceDomain: AssistanceDomainService,
private val codingService: CodingService,
private val configurationService: ConfigurationService,
)
class FeatureLayoutBuilder(
private val contentRepository: ContentRepository,
private val permissionService: PermissionService,
private val layoutMapper: LayoutMapper,
private val resultFactory: ResultFactory,
)
class FeatureInteractionHandler(
private val uiNavigator: UiNavigator,
private val logger: Logger,
private val callbackFactory: CallbackFactory,
)
class FeatureController(
private val availabilityResolver: FeatureAvailabilityResolver,
private val layoutBuilder: FeatureLayoutBuilder,
private val interactionHandler: FeatureInteractionHandler,
)Der FeatureAvailabilityResolver blieb mit acht Abhängigkeiten vergleichsweise groß. Sie gehörten jedoch zu derselben fachlichen Entscheidung: Ist ein Eintrag aus dem statischen Katalog unter den aktuellen Bedingungen verfügbar?
Der Resolver musste nicht wissen, wie daraus ein UI-Ergebnis entstand. Der FeatureLayoutBuilder brauchte keine Kenntnisse über die sechs Domain-Services. Für den FeatureInteractionHandler war der Aufbau des Layouts irrelevant. Auch der Controller musste die neu entstandenen Helfer nicht einzeln kennen.
Die Abhängigkeiten waren dabei nur ein Hinweis auf den Schnitt. Entscheidend blieb die gemeinsame Aufgabe. Zwei Services gehören nicht automatisch zusammen, nur weil sie in derselben Methode auftauchen.
Kapsle eine Aufgabe zusammen mit den Abhängigkeiten, die nur sie benötigt.
Der Controller hatte danach nur noch drei direkte Abhängigkeiten. Seine Rolle blieb erkennbar, aber die zuvor privaten Arbeitsschritte waren gezielt testbar. Nach jedem kleinen Umbau konnten die portierten Tests prüfen, ob das Verhalten erhalten blieb.
Legacy Code modernisieren: Ein Neubau braucht einen fachlichen Grund
Am Ende war die Funktionalität im neuen Projekt wieder nutzbar. Der ursprüngliche Entwickler erkannte den Aufbau wieder und konnte sich schnell orientieren. Gleichzeitig waren die Verantwortlichkeiten enger geschnitten und besser testbar.
Copy-and-paste war dafür eine Zwischenstufe. Es ist keine allgemeine Architekturstrategie und kein Grund, problematischen Code dauerhaft zu behalten. In dieser Portierung war es der schnellste Weg zu einem bekannten Verhalten, gegen das wir jede weitere Änderung prüfen konnten.
Die enge Timebox war kein Argument gegen sauberen Code. Sie war ein Argument dagegen, Portierung, fachlichen Neubau und Refactoring gleichzeitig zu versuchen. Für mich lag darin am Ende der Kern einer verantwortungsvollen Software-Modernisierung: die Schritte so zu trennen, dass jede Veränderung überprüfbar blieb.
Ein vollständiger Neubau wäre gerechtfertigt gewesen, wenn sich die zugrunde liegenden Abläufe und Mechaniken geändert hätten. Wenn neue Regeln bestimmen, welche Features verfügbar sind oder was bei ihrer Bedienung passiert, ist das alte Verhalten nicht mehr vollständig das Ziel. Dann kann eine neue Struktur von diesen Anforderungen ausgehen. Dafür braucht es allerdings auch Zeit, um die neuen Regeln zu spezifizieren und abzusichern. Beides gab der Auftrag nicht her.
In diesem Fall änderte sich die sichtbare Menüstruktur stark. Die Mechaniken darunter blieben bestehen. Ein neues Projekt und neue Inhalte waren deshalb kein fachlicher Grund, sie neu zu entwickeln.
Die Frage, die ich heute vor einem solchen Neubau stellen würde, lautet:
Hat sich die Fachlogik geändert oder nur ihre Umgebung und sichtbare Struktur?
Wenn die Fachlogik weiterhin gilt, ist der alte Code eine wichtige Referenz. Dann würde ich zuerst portieren und absichern. Neu bauen würde ich erst, wenn auch die fachlichen Regeln neu sind.
Der erste architektonische Durchstich war trotzdem nützlich. Er hatte die Schwachstellen und eine mögliche Entwicklungsrichtung sichtbar gemacht. Der Fehler war, ihn sofort zum Ausgangspunkt eines vollständigen Neubaus zu machen.
Heute würde ich ihn als Hypothese behandeln: zuerst klären, welches Verhalten bleiben muss. Dann den bestehenden Code abgesichert in die gewünschte Richtung entwickeln. Das Zielbild darf bestehen bleiben, ohne dass der Weg dorthin auf der grünen Wiese beginnen muss.


