Der Satz fällt fast immer gleich. „Das alte System will keiner mehr anfassen. Wir bauen das neu, diesmal in Go." Oder in Rust, oder in irgendwas, das gerade jung und sauber klingt. Ich sitze dann da, nicke, und sage meinen Standardsatz: „Drei Fragen, bevor wir das entscheiden." Nicht weil ich Rewrites grundsätzlich für Unsinn halte. Sondern weil ich beide Varianten gesehen habe, gut und schlecht ausgegangen. Meine Bilanz über die Fälle, die als „wir bauen neu" bei mir auf dem Tisch lagen, steht bei eins zu neun: auf einen, bei dem der Neubau richtig war, neun, bei denen Refactoring billiger und sicherer gewesen wäre. Keine Statistik, meine Erinnerung – aber konsistent genug, dass ich inzwischen erst frage, bevor ich zustimme. Über den einen Fall und die drei Fragen, die ihn von den anderen trennen, geht dieser Text.
Warum der Reflex so verführerisch ist
Der Rewrite-Reflex ist selten technisch begründet. Er ist psychologisch. Ein frischer Start, eine Sprache, die man mag, eine Architektur, die man diesmal richtig macht. Kein fremder Code, in dem man erst zwei Wochen lesen muss, bevor man eine Zeile ändern darf. Sichtbarer Impact statt stiller Wartung. Das ist menschlich, und ich nehme es ernst. Wer das Bedürfnis kleinredet, verliert den Raum.
Nur: Nichts davon sagt etwas darüber, ob der Neubau eine gute Idee ist. Es sagt etwas darüber, dass der bestehende Code unangenehm zu lesen ist. Das sind zwei verschiedene Dinge, und der Reflex verwechselt sie. „Ich will das nicht anfassen" wird zu „das kann man nicht mehr warten". Und schon ist aus einer Bequemlichkeit eine Architekturentscheidung mit siebenstelligem Preisschild geworden.
Was ein Rewrite wirklich kostet
Die Entwicklungszeit ist der kleinste Posten, und trotzdem der einzige, der geschätzt wird. Was in den Kalkulationen fehlt, die mir auf den Tisch kommen, ist der Rest.
Der erste Posten sind die laufenden Kosten des Doppelbetriebs. Solange das Neue nicht alles kann, was das Alte kann, laufen beide – und dieses „alles" ist fast immer größer, als der Plan annimmt. In der alten Codebase stecken Dinge, die niemand mehr auf dem Schirm hat: ein Reklamations-Sonderpfad für einzelne Großkunden, ein Nacht-Job, der Bestände mit einem Lieferanten-EDI abgleicht, eine handgeschriebene Steuer-Rundung für Gutscheine. Jede dieser Sachen wird im „fertigen" Neubau zum eigenen kleinen Projekt. Nichts davon steht im Pflichtenheft, weil es niemand mehr weiß. So wird aus einem halben Jahr geplantem Parallelbetrieb schnell das Doppelte oder Dreifache.
Der zweite Posten ist der Wissensverlust: das, was im alten Code steckt und im neuen fehlt. Legacy-Code ist auch eine Sammlung von Entscheidungen, die mal einen Grund hatten. Manche Gründe sind weggefallen, klar – aber welche? Das steht nirgends. Beim Neubau wird jede entweder mühsam rekonstruiert oder stillschweigend weggeworfen, und die weggeworfenen kommen in Monat vier als Production-Incident zurück.
Der dritte Posten sind die organisatorischen Nebenwirkungen, die keiner budgetiert. Compliance zuerst: in regulierten Sektoren ist die alte Anwendung zertifiziert und abgenommen, der Neubau nicht. „Wir bauen neu" heißt bei einer Banking-Anwendung eben auch „wir re-zertifizieren". Und dieser Kalender richtet sich nicht nach eurem Sprint. Dazu die Kunden, die in der Übergangsphase abspringen, weil das Neue Monat um Monat „fast fertig" ist und das Alte solange keine Verbesserung mehr bekommt.
Rechne ich das zusammen, steht dem geschätzten Rewrite-Aufwand oft ein Vielfaches gegenüber. Nicht weil Entwickler schlecht schätzen, sondern weil sie das Falsche schätzen.
Die drei Fragen
Damit zu den drei Fragen, die ich stelle, bevor ich einen Neubau mittrage. Wenn keine davon mit einem klaren Ja beantwortet wird, ist es kein Rewrite-Fall, sondern ein Refactoring-Fall, der sich als Rewrite verkleidet.
Erste Frage: Ist die Plattform tot? Nicht „alt". Tot. Gibt es für Sprache oder Framework keine Sicherheits-Updates mehr, und – das ist der entscheidende Teil – existiert kein realistischer Upgrade-Pfad nach oben? Ob ein Pfad realistisch ist, hängt selten an der Sprachversion allein, sondern an den Frameworks, am Hosting und an den Abhängigkeiten, die mithochwollen müssen. Ein klarer Fall ist ein uraltes, aufgegebenes Framework, dessen Nachfolgeversion nie existiert hat und für das keine Migrationsbrücke geschrieben wurde – da hilft kein schrittweiser Sprung, weil die Zwischenstationen selbst fehlen. Der Gegenpol: PHP 5.6 ist in diesem Sinne nicht tot. Der Pfad 5.6 → 7.4 → 8.x ist steinig, aber er existiert, Route für Route, mit Tests dazwischen. Tot heißt: kein realistischer Upgrade-Pfad mehr, egal wie viel Geduld man mitbringt. So ein Fall lag bei mir mal auf einem internen Planungstool, das auf einem kommerziellen Framework lief, dessen Anbieter 2012 dichtgemacht hatte – kein Nachfolger, keine Community, der letzte Patch von 2011. Da war der Neubau des Tools am Ende billiger als der Versuch, das tote Framework unter der Anwendung wegzuoperieren. Solche Fälle gibt es. Nur eben selten.
Zweite Frage: Steckt ihr in einer Lizenzfalle? Eine proprietäre Abhängigkeit, die nicht mehr supportet wird und aus der man nicht rauskommt, ohne den tragenden Teil neu zu bauen. Alte Vendor-Frameworks mit Lock-in, eine kommerzielle Komponente, deren Hersteller es nicht mehr gibt und deren Quelltext niemand hat. Wenn das Herz der Anwendung an so etwas hängt, ist die Migration innerhalb des Bestehenden manchmal teurer als der kontrollierte Neubau des betroffenen Teils. Betont: des betroffenen Teils. Selten der ganzen Anwendung.
Dritte Frage: Ist das Datenmodell gebrochen? Nicht unschön, gebrochen. Die fachlichen Anforderungen haben das zugrundeliegende Modell so überholt, dass kein Refactor mehr drankommt, weil jede Änderung an zwanzig Stellen gleichzeitig bricht. Das ist der seltenste der drei Fälle, und der, bei dem ich am längsten hinschaue, bevor ich zustimme. Meistens ist ein „gebrochenes" Datenmodell nämlich nur ein unbequemes, und unbequem repariert man im laufenden Betrieb, Tabelle für Tabelle.
Drei Fragen. In den allermeisten Gesprächen bleibt es bei drei Mal Nein. Und dann reden wir über den anderen Weg.
Was in allen anderen Fällen besser funktioniert
Der andere Weg heißt: die Schmerzquelle finden und gezielt herausoperieren, während der Rest weiterläuft. Fast nie ist die ganze Codebase das Problem. Meistens sind es ein, zwei Stellen, an denen sich der Frust konzentriert, und die färben auf die Wahrnehmung des gesamten Systems ab. „Keiner will das anfassen" heißt oft „diese eine Klasse will keiner anfassen".
Ein konkreter Fall, der beinahe neu gebaut worden wäre. Eine Logistik-Anwendung, Kern-Team aus drei Entwicklern, keine CI, und mittendrin ein TourenplanExporter mit einer Methode von 1.140 Zeilen. Die berechnete Fahrtrouten, formatierte sie für drei verschiedene Abnehmer und schrieb sie in eine Datei – alles in einer Methode, SQL, Rechenlogik und Ausgabe ineinander verwoben. Genau diese Klasse war der Grund, warum das Team „das ganze System" neu bauen wollte. War es aber nicht. Es war diese Methode.
Vorher, der Klumpen (gekürzt):
public function export(int $tourId): void
{
$rows = DB::query(
"SELECT s.*, k.plz, k.lat, k.lng
FROM stops s JOIN kunden k ON k.id = s.kunde_id
WHERE s.tour_id = " . $tourId . " ORDER BY s.pos"
)->fetchAll();
$out = '';
$km = 0.0;
$prev = null;
foreach ($rows as $r) {
if ($prev !== null) {
// Haversine, direkt inline
$dLat = deg2rad($r['lat'] - $prev['lat']);
$dLng = deg2rad($r['lng'] - $prev['lng']);
$a = sin($dLat/2)**2 + cos(deg2rad($prev['lat']))
* cos(deg2rad($r['lat'])) * sin($dLng/2)**2;
$km += 6371 * 2 * asin(sqrt($a));
}
// ... 40 Zeilen Sonderregeln für Zeitfenster ...
$out .= sprintf("%s;%s;%.1f\n", $r['plz'], $r['name'], $km);
$prev = $r;
}
file_put_contents("/exports/tour_{$tourId}.csv", $out);
Mailer::send('dispo@kunde.de', 'Tour', ['datei' => "tour_{$tourId}.csv"]);
}
Der Reflex sagt: unrettbar, neu bauen. Der zweite Blick sagt: da drin steckt eine reine Funktion, die niemand isoliert hat. Die Kilometer-Berechnung mit ihren Zeitfenster-Sonderregeln ist der Teil, der wehtut und der fachlich wichtig ist – und der hängt an nichts außer den Eingabedaten. SQL und Dateiausgabe drumherum sind langweilig und funktionieren.
Nachher, die reine Rechnung herausgezogen und unter Test:
public function export(int $tourId): void
{
$stops = $this->loadStops($tourId); // SQL, unverändert
$zeilen = $this->berechneTour($stops); // pure, jetzt testbar
$this->schreibeCsv($tourId, $zeilen); // I/O, unverändert
}
/** @param Stop[] $stops @return ExportZeile[] */
public function berechneTour(array $stops): array
{
$zeilen = [];
$km = 0.0;
$prev = null;
foreach ($stops as $stop) {
if ($prev !== null) {
$km += $this->distanz->km($prev, $stop);
}
$km = $this->zeitfenster->anpassen($stop, $km);
$zeilen[] = new ExportZeile($stop->plz, $stop->name, $km);
$prev = $stop;
}
return $zeilen;
}
Zwei Wochen Extraktion, Stück für Stück, ohne das Verhalten zu ändern. Dann drei Wochen für die Test-Infrastruktur drumherum: Characterization Tests auf berechneTour(), damit die Zeitfenster-Sonderregeln festgeschrieben sind, bevor jemand sie anfasst. Wie ich solche Tests aufsetze, wenn ich den Code noch nicht ganz verstehe, steht in einem früheren Logbuch. Nach diesen fünf Wochen war der Schmerz weg. Nicht 100 Prozent – aber der Teil, der das Team zum Neubau treiben wollte, war jetzt eine getestete, lesbare Methode. Der Neubau-Plan lag bei geschätzten sieben Monaten. Wir haben ihn nicht gebraucht.
Wenn ganze URL-Bereiche schrittweise abgelöst werden sollen statt einer einzelnen Methode, ist das Strangler-Fig-Pattern das Mittel der Wahl – dazu gibt es einen eigenen Artikel. Für Migrationen innerhalb einer Klasse, wie hier beim TourenplanExporter, reicht Branch-by-Abstraction: neue Implementierung neben der alten, ein Schalter im Code, alter Pfad bleibt lauffähig, bis der neue bewiesen ist. Warum genau das hier passte und nicht Strangler? Weil es keine saubere URL-Grenze gab, an der ein Proxy hätte schneiden können. Die Kante, an der man migrieren wollte, lag mitten in der Klasse – zwischen Rechnung und Ausgabe – nicht an einer Route. Und für eine Kante mitten im Code zieht man ein Interface, keinen Proxy.
Stolperfallen
Rewrite als politisches Werkzeug. Ein neuer CTO will ein eigenes Erbe, keinen fremden Code erben. Verständlich, menschlich, und ein schlechter Grund für eine Architekturentscheidung. Wenn die Begründung für den Neubau sich ändert, sobald man nach Zahlen fragt, war es nie eine technische Frage.
Rewrite als Recruiting-Trick. „Wir bauen mit Rust, dann kriegen wir wieder gute Leute." Funktioniert kurz. Die guten Leute kommen, bauen ein Jahr am Neuen, und dann kommt der Teil, den niemand mag: die alten Sonderfälle nachbauen. Ab da fühlt sich der Neubau auch wie Legacy an, nur ohne die Jahre Betriebshärte. Die Fluktuation, vor der man weglaufen wollte, ist wieder da.
Der Doppelbetrieb, den niemand budgetiert hat. Das ist der teuerste. Zwei Systeme, die dasselbe tun, über eineinhalb bis drei Jahre, mit doppeltem Betrieb, doppeltem Support und einem Team, das mental zwischen beiden pendelt. Wenn im Rewrite-Plan keine ehrliche Zeile für den Parallelbetrieb steht, ist der Plan nicht fertig gerechnet.
Wenn Sie gerade zwischen Neubau und Sanierung stehen: Welcher Pfad der richtige ist, hängt an Ihrem konkreten Fall, nicht an einer allgemeinen Regel. Ein halbstündiges Gespräch reicht in der Regel, um das zu sortieren – und woran ich es festmache. Termin buchen.