Blog
Warum ein vollständiger Neubau oft riskanter ist als schrittweises Refactoring
Ein vollständiger Neubau verspricht saubere Architektur, zwingt das Team aber, Geschäftsregeln, Integrationen und Produktionsausnahmen neu zu entdecken. Schrittweises Refactoring reduziert Risiko, indem es eine Grenze nach der anderen ersetzt.
Eine Neuentwicklung beginnt mit einer unvollständigen Spezifikation
Eine bestehende PHP-Anwendung enthält oft Geschäftswissen, das nie dokumentiert wurde: eine alte Kundenvereinbarung, einen Sonderfall beim Import oder eine nach einem Vorfall ergänzte Berechtigungsprüfung. Wer den Code ersetzt, muss entscheiden, was mit diesem Verhalten geschehen soll. Klassen in ein neueres Framework zu übertragen, reicht dafür nicht aus.
Nicht jede historische Regel ist deshalb erhaltenswert. Manche werden weiterhin gebraucht, andere sind Behelfslösungen oder Fehler. Zunächst gilt es, benötigtes Verhalten von bewusst gewünschten Änderungen zu trennen.
Was in die Aufwandsschätzung gehört
Ein nicht mehr unterstütztes Framework, eng gekoppelte Module oder unzuverlässige Deployments sind gute Gründe, über eine Ablösung nachzudenken. Eine neue Implementierung kann Einschränkungen beseitigen, deren Umgehung viel kostet. Die Schätzung muss aber mehr als die Entwicklung abdecken: Anforderungen ermitteln, Integrationen und Berechtigungen nachbilden, Daten migrieren, Berichte prüfen, Anwender schulen und Wiederherstellungsverfahren vorbereiten.
Alte und neue Implementierung können währenddessen nebeneinander bestehen. Das kann Korrekturen an zwei Stellen, Synchronisierung, zusätzliche Umgebungen und mehr Betriebswissen erfordern. Dauer und Aufwand hängen von Umfang und Umstellungsplan ab. Weder ein vollständiger Funktionsstopp noch ein dauerhaft hinterherlaufendes neues System sind zwangsläufig. Auch die schrittweise Modernisierung kostet: Temporäre Adapter und Migrationscode brauchen Verantwortliche und einen Plan für ihren Abbau.
Ausgangspunkt: die Preisberechnung einer Bestellung
Der folgende hypothetische Ausschnitt könnte aus einer Symfony- oder Laravel-Anwendung stammen. Typen wie Order und Money gehören zum Beispielprojekt; ausgelassene Methoden und Abhängigkeiten sind keine Framework-APIs. Die später verwendeten readonly-Klassen setzen PHP 8.2 oder neuer voraus.
final class LegacyOrderPriceCalculator
{
public function calculate(Order $order): Money
{
$total = $order->lineTotal();
if ($order->customer()->usesHistoricContract()) {
$total = $total->subtract(
$this->historicDiscount($order),
);
}
if (
$order->country() === 'DE'
&& $order->wasImportedBeforeTaxMigration()
) {
return $this->legacyGermanTaxCalculation(
order: $order,
amount: $total,
);
}
return $this->standardTaxCalculation(
order: $order,
amount: $total,
);
}
}Der historische Rabatt wird vor beiden Steuerberechnungszweigen abgezogen. Der Zweig für Deutschland steht für einen erfundenen Sonderfall im Altsystem, nicht für eine tatsächliche deutsche Steuervorschrift. Die Berechnungsmethoden sind bewusst ausgelassen.
Vor der Ablösung muss geklärt werden, welche Bestellungen welchen Pfad nehmen, wie gerundet wird und ob alte Belege reproduzierbar bleiben müssen. Die fachlich Verantwortlichen entscheiden, welche Regeln weiterhin benötigt werden. Eine neue Implementierung sollte eine unverstandene Regel nicht stillschweigend durch eine andere ersetzen.
Charakterisierungstests dokumentieren Verhalten, nicht dessen Richtigkeit
Ein Charakterisierungstest hält ein bestehendes Ergebnis fest, damit es sich beim Refactoring nicht unbemerkt ändert. Das erste Beispiel betrifft eine importierte Bestellung:
it('bewahrt das Ergebnis für importierte Bestellungen', function (): void {
$order = OrderBuilder::new()
->forCountry('DE')
->importedBeforeTaxMigration()
->withNetAmount('199.95')
->build();
$result = $this->calculator()->calculate($order);
expect($result->currency())->toBe('EUR')
->and($result->amount())->toBe('237.94');
});Das zweite erfasst einen Kunden mit einer historischen Vereinbarung:
it('bewahrt das Ergebnis für den Altvertrag', function (): void {
$order = OrderBuilder::new()
->forHistoricContractCustomer()
->withNetAmount('1000.00')
->build();
$result = $this->calculator()->calculate($order);
expect($result->amount())->toBe('950.00');
});Die Beträge 237.94 und 950.00 sind hypothetische Vergleichswerte, keine überprüften Buchhaltungsergebnisse eines Kunden. Aus dem Ausschnitt lassen sie sich nicht herleiten: Berechnungsmethoden und Standardwerte der Testdaten fehlen. In einem echten Test sind OrderBuilder und calculator() projektspezifische Hilfsmittel. Die Testdaten müssen Steuerbehandlung, Währung, Rabatt und Rundung eindeutig festlegen.
Beobachtetes Verhalten wird zunächst festgehalten. Auffällige Ergebnisse werden mit den Prozessverantwortlichen geklärt. Tests zur Bewahrung des bisherigen Verhaltens sollten von Tests für eine genehmigte fachliche Änderung unterscheidbar sein. Zwei erfolgreiche Tests belegen nicht die Richtigkeit aller Preise.
Bei der Datenmigration laufen Änderungen weiter
Historische Daten können uneinheitliche Nullwerte, Duplikate, fehlende Verweise oder Felder mit wechselnder Bedeutung enthalten. Das sind Prüfbereiche, keine pauschale Diagnose jeder Datenbank. Eine manuell korrigierte Adresse kann verlässlicher sein als das Ergebnis eines neuen Parsers; zwei ähnliche Kundensätze können unterschiedliche Personen betreffen.
Eine Migration braucht deshalb neben der Transformation auch einen Abgleich: Sind alle relevanten Datensätze erfasst, Verweise gültig und wichtige Summen stimmig? Für unklare Fälle muss es ein abgestimmtes Verfahren geben.
Expand-and-contract trennt kompatible Ergänzungen vom späteren Entfernen überholter Strukturen. Bei einer Adressmigration könnten die Schritte so aussehen:
Schritt Voraussetzung für den nächsten Schritt
Strukturen ergänzen Alter Code funktioniert weiterhin
Daten nachziehen Altbestände in begrenzten Stapeln umwandeln
Abgleichen Änderungen und Sonderfälle klären
Lesezugriffe umstellen Neue Daten erfüllen die Abnahmekriterien
Altstrukturen entfernen Keine Leser oder Schreiber benötigen sie
Während des Übergangs: Schreibverantwortung festlegen.Neue Spalten mit erlaubtem null sind nur sinnvoll, wenn das Modell fehlende Werte zulässt. Schemaänderungen können außerdem Tabellen sperren oder die Datenbank belasten. Geplant werden muss die tatsächliche Datenbankoperation, nicht nur das Deployment der Anwendung.
Doppeltes Schreiben ist eine Möglichkeit, keine Pflicht. Ein einzelner Schreibpfad mit Kompatibilitätsadapter, Änderungserfassung oder eine geplante Schreibpause kann besser passen. Werden beide Darstellungen aktualisiert, brauchen Teilfehler und konkurrierende Änderungen eine Konsistenz- und Abgleichstrategie. Ein anhand von IDs fortschreitender Nachlauf erfasst nicht automatisch frühere Datensätze erneut, die danach geändert wurden.
Dieser Dienst zeigt einen begrenzten Migrationsstapel. Konstruktor und Deklarationen von customers und legacyAddressParser sind ausgelassen:
final class CustomerAddressMigrationService
{
// Konstruktor und Abhängigkeitsfelder ausgelassen.
public function migrateBatch(
int $afterId,
int $limit,
): MigrationBatchResult {
if ($afterId < 0 || $limit < 1) {
throw new \InvalidArgumentException(
'Ungültiger Cursor oder ungültige Stapelgröße.',
);
}
$customers = $this->customers
->findLegacyAddressBatch(
afterId: $afterId,
limit: $limit,
);
foreach ($customers as $customer) {
if ($customer->hasStructuredAddress()) {
continue;
}
$address = $this->legacyAddressParser
->parse($customer->legacyAddress());
$customer->setStructuredAddress($address);
}
$this->customers->saveAll($customers);
return MigrationBatchResult::fromCustomers($customers);
}
}findLegacyAddressBatch() muss hier einen vollständig geladenen Stapel liefern, sortiert nach einer stabilen, eindeutigen ganzzahligen ID, mit id > afterId und einem Limit. MigrationBatchResult::fromCustomers() muss den Fortschritt anhand des letzten geprüften Datensatzes bestimmen, auch wenn die Schleife ihn übersprungen hat, und einen leeren Stapel erkennen.
Die Prüfung verhindert eine erneute Transformation, belegt aber weder Aktualität noch Richtigkeit der Adresse. Auch saveAll() bedeutet nicht automatisch einen Transaktions-Commit. Das sind Projektmethoden, deren Verträge Persistenz, Transaktionsverantwortung und Fehlerbehandlung festlegen müssen. Ein Fortschrittsstand darf erst nach dem Commit der zugehörigen Schreibvorgänge gespeichert werden. Sichere Wiederholungen erfordern zudem Schutz vor konkurrierendem Überschreiben und ein Verfahren zur Wiederherstellung oder zum Abgleich teilweise erledigter Arbeit. Der Ausschnitt allein garantiert weder Idempotenz noch eine sicher fortsetzbare Migration.
Eine Funktion hinter einem sinnvollen Vertrag ersetzen
Für die Preisberechnung kann eine kleine, anwendungseigene Schnittstelle den Aufrufern eine stabile Abhängigkeit bieten:
interface CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult;
}Ein Adapter macht die bestehende Implementierung über diesen Vertrag verfügbar:
final class LegacyCustomerPricingAdapter
implements CustomerPricingInterface
{
public function __construct(
private readonly LegacyPricingManager $legacy,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$legacyResult = $this->legacy->calculate(
customerId: $customer->getId(),
lines: $order->lines(),
);
return PriceResult::fromLegacyResult($legacyResult);
}
}Das hilft nur, wenn die Aufrufer tatsächlich die Schnittstelle verwenden, statt sie zu umgehen und auf den alten Manager zuzugreifen. PriceResult::fromLegacyResult() muss vereinbarte Währung, Genauigkeit und Bedeutung des Ergebnisses erhalten. Eine gemeinsame Schnittstelle garantiert noch keine gleichwertigen Berechnungen.
Die neue Implementierung erfüllt denselben Vertrag. Dieses Gerüst bricht ausdrücklich mit einer Ausnahme ab, weil der neue Algorithmus nicht Teil des Beispiels ist:
final class ModernCustomerPricingService
implements CustomerPricingInterface
{
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
throw new \LogicException(
'Die neue Berechnung ist noch nicht implementiert.',
);
}
}Dieses Gerüst darf in einer ausgelieferten Anwendung nicht ausgewählt werden. Vor der Aktivierung muss die Berechnung implementiert und überprüft sein.
Branch by Abstraction und Strangler Fig setzen an unterschiedlichen Grenzen an
Bei Branch by Abstraction bestehen Implementierungen hinter einem gemeinsamen Vertrag nebeneinander. Aufrufer werden auf diesen Vertrag umgestellt, anschließend wird der Ersatz eingeführt. Die Darstellung zeigt Abhängigkeiten und Implementierungen, keinen zeitlichen Methodenablauf:
Aufrufer hängen ab von: CustomerPricingInterface
Implementierungen des Vertrags:
LegacyCustomerPricingAdapter
ModernCustomerPricingService
Auswahl:
Deployment-Konfiguration -> Dependency Injection
Kundenkontext -> CustomerPricingFactoryEine Auswahl zur Laufzeit ist sinnvoll, wenn die Migrationsentscheidung tatsächlich vom Kunden oder der Anfrage abhängt:
final class CustomerPricingFactory
{
public function __construct(
private readonly LegacyCustomerPricingAdapter $legacy,
private readonly ModernCustomerPricingService $modern,
private readonly PricingMigrationPolicy $policy,
) {
}
public function forCustomer(
Customer $customer,
): CustomerPricingInterface {
if ($this->policy->usesModernPricing($customer)) {
return $this->modern;
}
return $this->legacy;
}
}Die Factory wählt die Implementierung; die Schnittstelle beschreibt den Berechnungsvertrag. Legt die Deployment-Konfiguration eine Implementierung für alle fest, genügt meist normale Dependency Injection. Eine kundenweise Umstellung benötigt außerdem konsistente Zuordnung und kompatible Daten. Das Zurücksetzen eines Schalters reicht nicht, wenn alter Code neue Daten nicht mehr lesen kann.
Strangler Fig ersetzt Anwendungsteile an einer Routing- oder Fassadengrenze. Beispielsweise kann GET /api/orders/{id} schrittweise an einen neuen Lesedienst übergeben werden, während der öffentliche Endpunkt bestehen bleibt. Ein interner Abfragevertrag könnte so aussehen:
interface OrderDetailsQueryInterface
{
public function get(
int $orderId,
string $locale,
): ?OrderDetailsDto;
}null steht hier für eine fehlende Bestellung, nicht für eine Berechtigungsentscheidung. Beide Pfade müssen Zugriffskontrollen und den öffentlichen Antwortvertrag erhalten. Branch by Abstraction betrifft eine interne Abhängigkeitsgrenze, Strangler Fig die schrittweise Ablösung von Anwendungsfunktionen. Die Ansätze lassen sich kombinieren, sind aber keine austauschbaren Bezeichnungen. Es handelt sich um etablierte Techniken, nicht um GiSoft-Erfindungen; Quellen stehen am Ende.
Berechnungen vergleichen, ohne Geschäftsvorgänge doppelt auszuführen
Bei einer Berechnung ohne Seiteneffekte kann die Ausführung beider Implementierungen Unterschiede vor der Umstellung sichtbar machen. Dafür brauchen sie gleichwertige Eingabestände, Referenzdaten und Rundungsregeln. Sonst erscheint etwa ein zwischenzeitlich geänderter Wechselkurs oder ein verändertes Eingabeobjekt als Implementierungsfehler.
Dieser synchrone Diagnosedienst liefert das maßgebliche Ergebnis zurück, sofern beide Berechnungen und die Meldung von Abweichungen abgeschlossen werden:
final class ComparingCustomerPricingService
implements CustomerPricingInterface
{
public function __construct(
private readonly CustomerPricingInterface $official,
private readonly CustomerPricingInterface $candidate,
private readonly PricingComparisonReporter $reporter,
) {
}
public function calculate(
Customer $customer,
OrderDraft $order,
): PriceResult {
$officialResult = $this->official->calculate(
customer: $customer,
order: $order,
);
$candidateResult = $this->candidate->calculate(
customer: $customer,
order: $order,
);
if (!$officialResult->equals($candidateResult)) {
$this->reporter->reportDifference(
customerId: $customer->getId(),
official: $officialResult,
candidate: $candidateResult,
);
}
return $officialResult;
}
}equals() benötigt einen fachlich definierten Vergleich: Währung, Beträge, Rundung und relevante Einzelbestandteile statt Objektidentität oder beliebiger Gleitkommatoleranzen. Keine der Implementierungen darf gemeinsame Eingaben oder Ergebnisse verändern.
Fehler sind hier nicht isoliert. Eine Ausnahme der neuen Berechnung, lange Laufzeit oder ein Fehler im Reporter kann die Anfrage weiterhin verzögern oder scheitern lassen. Im Produktivbetrieb braucht es klare Regeln für solche Fehler sowie Ausführungsgrenzen. Ist Isolation erforderlich, kann ein separater Vergleichsauftrag mit einem gespeicherten Eingabestand geeigneter sein, einschließlich eigener Zustellregeln und Ressourcenlimits. Dass das neue Ergebnis nicht zurückgegeben wird, macht seine Berechnung nicht folgenlos.
Der Reporter erhält möglicherweise sensible Kunden- und Preisdaten. Umfang, Zugriff und Aufbewahrungsdauer sollten begrenzt werden; vollständige Objektdumps sind nicht nötig.
Eine Zahlungsentscheidung verdeutlicht die Grenze zwischen Berechnung und Handlung:
final readonly class PaymentDecision
{
public function __construct(
public bool $allowed,
public string $reason,
public Money $amount,
) {
}
}Beide Implementierungen dürfen eine Entscheidung berechnen. Die Zahlung auslösen darf nur der autorisierte, maßgebliche Pfad nach den bestehenden Berechtigungs- und Fachprüfungen. allowed ist selbst keine Berechtigung. Wiederholte Zustellung oder ein Timeout mit unklarem Ergebnis beim Anbieter erfordern weiterhin eine dauerhafte Vorgangsverfolgung und geeignete Idempotenzmechanismen. Ein Vergleich zweier Rückgabewerte leistet das nicht.
Dasselbe gilt für E-Mails, Lagerbewegungen und Erstattungen. Außerdem macht readonly ein enthaltenes Money-Objekt nicht unveränderlich; dieser Typ muss seine eigenen Garantien bieten.
Schreibverantwortung nicht im Repository verstecken
Eine Repository-Grenze kann Fachdiensten ein stabiles Modell bieten, während sich die Speicherung ändert:
interface CustomerRepositoryInterface
{
public function get(int $id): Customer;
public function save(Customer $customer): void;
}Ein Adapter bildet zwischen diesem Modell und der alten Datendarstellung ab:
final class LegacyCustomerRepository
implements CustomerRepositoryInterface
{
public function __construct(
private readonly LegacyCustomerGateway $gateway,
private readonly LegacyCustomerMapper $mapper,
) {
}
public function get(int $id): Customer
{
$row = $this->gateway->findRequired($id);
return $this->mapper->toDomain($row);
}
public function save(Customer $customer): void
{
$this->gateway->save(
$this->mapper->toLegacyData($customer),
);
}
}In diesem Beispielvertrag braucht get() eine definierte Ausnahme für fehlende Datensätze. save() legt dagegen keinen Zeitpunkt für einen Transaktions-Commit fest. Gateway und Mapper sind Projektabhängigkeiten, keine Doctrine-APIs.
Die Abbildung muss Felder anderer Prozesse berücksichtigen und veraltete Schreibzugriffe verhindern. Während der Koexistenz muss feststehen, welches System für welches Feld zuständig ist. Ein separates Domänenmodell und ein Repository-Interface helfen bei konkreten Migrationsproblemen; sie sind keine Pflichtschichten für jede Entität.
Den öffentlichen API-Vertrag gesondert absichern
Ein internes OrderDetailsDto kann in eine öffentliche Antwort wie PublicOrderDto überführt werden. Diese Trennung hilft, Änderungen der Speicherung nicht unbeabsichtigt in die API zu tragen:
final readonly class PublicOrderDto
{
/**
* @param list<PublicOrderLineDto> $lines
*/
public function __construct(
public int $id,
public string $number,
public string $status,
public string $currency,
public array $lines,
public string $createdAt,
) {
}
}Der Typ string für createdAt erzwingt kein Datumsformat. Die PHPDoc-Angabe beschreibt Listenelemente für die statische Analyse, validiert aber keine Eingabedaten.
Auch ein kleiner Antworttest ist nützlich:
it('liefert die erforderlichen Antwortfelder', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/orders/1001');
self::assertResponseIsSuccessful();
$payload = json_decode(
(string) $client->getResponse()->getContent(),
true,
512,
JSON_THROW_ON_ERROR,
);
expect($payload)
->toBeArray()
->toHaveKeys([
'id',
'number',
'status',
'currency',
'lines',
'createdAt',
]);
});Dieser Pest-Ausschnitt setzt eine Konfiguration auf Basis von Symfony WebTestCase, eine bekannte Testbestellung und gegebenenfalls Authentifizierung voraus. Er prüft eine erfolgreiche Antwort, gültiges JSON und vorhandene Schlüssel. Den gesamten Vertrag schützt er nicht.
Kompatibilitätstests müssen gegebenenfalls auch genaue Statuscodes, Werttypen, Nullwerte, Datumsformate, verweigerte Zugriffe, Fehlerantworten, Reihenfolge, Paginierung und übersetzte Felder abdecken. Der bestehende Vertrag bleibt maßgeblich, sofern eine Änderung nicht ausdrücklich mit den API-Nutzern abgestimmt wurde.
Typen und Architekturtests gezielt einsetzen
PHPStan kann Unstimmigkeiten an Adaptergrenzen aufdecken. Die folgenden Ausschnitte sind Methodendeklarationen aus Schnittstellen, keine eigenständigen Funktionen. Der erste beschreibt eine Liste projektspezifischer Datenzeilenobjekte:
/**
* @return list<LegacyCustomerRow>
*/
public function findLegacyCustomersForMigration(
int $afterId,
int $limit,
): array;Ein Mapper-Vertrag kann stattdessen eine konkrete Array-Struktur festlegen:
/**
* @return array{
* id: int,
* email: string,
* legacy_status: string|null,
* created_at: string
* }
*/
public function toLegacyData(Customer $customer): array;Der Mapper muss Quelldaten weiterhin korrekt validieren und interpretieren. Präzise Annotationen unterstützen die Analyse, beweisen aber nicht, dass ein historischer Status fachlich richtig übersetzt wurde. Für projektspezifische Architekturvorgaben braucht PHPStan eine passende Konfiguration oder zusätzliche Regeln.
Pest-Architekturtests können ausgewählte Abhängigkeitsregeln ausdrücken:
arch('der Kern hängt nicht von Altinfrastruktur ab')
->expect('App\Core')
->not->toUse('App\Infrastructure\Legacy');
arch('API-Controller verwenden kein EntityManagerInterface')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('der Kern hängt nicht vom Anbieter-SDK ab')
->expect('App\Core')
->not->toUse('Vendor\ExternalSdk');Die Namespaces dienen als Beispiel; Vendor\ExternalSdk ist ein Platzhalter. Sie müssen zum tatsächlichen Code und zur installierten Version der Pest-Architekturtests passen. Die Controller-Regel verbietet eine direkte Abhängigkeit von EntityManagerInterface, nicht jeden möglichen Datenbankzugriff. Die SDK-Regel schließt das SDK nur aus App\Core aus; sie belegt nicht, dass alle SDK-Zugriffe innerhalb der Infrastruktur liegen. Dynamisch aufgelöste Dienste und indirekte Auswirkungen benötigen weiterhin ein Review.
Den gesamten Lesepfad messen, nicht nur SQL-Anweisungen zählen
Die folgenden Zahlen für GET /api/en/products sind vollständig hypothetisch. Sie veranschaulichen einen Zielkonflikt, keinen GiSoft-Benchmark:
Messgröße Bestehend Kandidat
SQL-Abfragen 8 3
Datenbankzeit 75 ms 110 ms
Serialisierung 60 ms 190 ms
P95-Antwortzeit 280 ms 430 ms
Speicher 42 MB 118 MBGrößere Joins, zusätzliche Hydrierung oder aufwendigeres Mapping können den Vorteil weniger Abfragen aufheben. Die gezeigten Teilzeiten dürfen nicht zur Berechnung von P95 addiert werden: Dieses Perzentil beschreibt die Verteilung vollständiger Antwortzeiten.
Ein belastbarer Vergleich benötigt vergleichbare Daten, Antwortgrößen, Anfragemischung, Nebenläufigkeit, Cache-Zustände und Umgebungen. Neben dem häufigsten Pfad gehören repräsentative historische Fälle in die Messung. Akzeptable Fehlerraten und Antwortzeiten sollten vor der Einführung vereinbart sein.
KI-gestützte Änderungen klar begrenzen
Ein Programmierassistent kann beim Herauslösen eines Adapters oder beim Umstellen von Aufrufern helfen. Plausibler Code kann trotzdem die Bedeutung von null, einen Rabattzweig oder ein API-Feld verändern. Entscheidend im Review ist das tatsächliche Verhalten, nicht nur ein übersichtlicher Diff.
Ein brauchbarer Auftrag benennt öffentliche und fachliche Verträge, verweist auf Charakterisierungstests und begrenzt die Änderung auf eine Schnittstellengrenze. Fehlende Tests sollten entstehen, bevor das zu schützende Verhalten verändert wird. Abfrageänderungen brauchen Messungen. Gezielte Tests und statische Analyse müssen relevante Abhängigkeiten und Aufrufer einbeziehen, nicht automatisch nur bearbeitete Dateien.
Änderungen an Finanzlogik, Berechtigungen oder Datenschema brauchen die Freigabe einer verantwortlichen Person. Festgehalten werden sollte, was geprüft wurde und was offenbleibt. KI-Unterstützung ersetzt diese Entscheidung nicht.
Wann eine vollständige Neuentwicklung sinnvoll sein kann
Eine Neuentwicklung kann die bessere Wahl sein, wenn sich das Produkt grundlegend geändert hat, ein kleines System gut verstanden ist oder die bisherige Plattform wichtige Betriebsanforderungen nicht zu vertretbaren Kosten erfüllen kann. Umgekehrt können unbekannte Regeln und eine schwierige Datenmigration den vermeintlich sauberen Neustart teuer machen. Keiner dieser Punkte entscheidet die Frage allein.
Hilfreicher als eine Ja/Nein-Wertung sind konkrete Fragen:
- Was muss kompatibel bleiben? Ein bewusst kleinerer Ersatz ist etwas anderes als das Versprechen, alles nachzubilden.
- Wo lässt sich die Änderung abgrenzen? Gute Schnittstellen begünstigen eine schrittweise Ablösung; ihre Einführung kostet ebenfalls.
- Wie werden Daten und Schreibzugriffe umgestellt? Schwierige Koexistenz belastet beide Ansätze, während ein einmaliger Wechsel Risiken bündelt.
- Was kann die Organisation betreiben und überprüfen? Personal, regulatorische Vorgaben, Nutzerabnahme, laufende Weiterentwicklung und Wiederherstellungsmöglichkeiten gehören dazu.
Ein Datenbankbackup ist noch kein vollständiger Rücksetzplan. Seine Wiederherstellung kann spätere Schreibvorgänge verlieren oder bereits ausgeführten externen Aktionen widersprechen. Das passende Wiederherstellungs- oder Reparaturverfahren sollte vor der Umstellung erprobt sein; ebenso müssen die Abnahmekriterien feststehen.
Ein praktikabler Einstieg
Wählen Sie eine wichtige, klar begrenzte Funktion und dokumentieren Sie ihr Verhalten gemeinsam mit Entwicklung und Prozessverantwortlichen. Sichern Sie die benötigten Fälle ab, klären Sie die Datenverantwortung und schaffen Sie nur die für die Ablösung erforderliche Grenze. Erproben Sie den Kandidaten in begrenztem Umfang mit ausdrücklichen Abnahme- und Wiederherstellungsbedingungen.
Die Ergebnisse dieses Versuchs sollten den weiteren Plan beeinflussen. Laufende Produktarbeit bleibt neben der Migration sichtbar; Übergangscode wird entfernt, sobald keine Aufrufer oder Datenabhängigkeiten ihn mehr benötigen. Ein kleiner Schritt ist dann wertvoll, wenn er eine wichtige Frage beantwortet, nicht als Selbstzweck.
Modernisierung soll benötigtes Geschäftsverhalten und Datenintegrität erhalten und zugleich künftige Änderungen sicherer machen. Das kann zu einer schrittweisen Ablösung oder zu einer neuen Anwendung führen. Maßgeblich sind Erkenntnisse über dieses System, nicht die Vorliebe für alten oder neuen Code.
Quellen
Die folgenden Quellen beschreiben die benannten Migrationstechniken und die Werkzeugsyntax. Die Bestell- und Adressbeispiele im Artikel sind illustrativ und schildern keine überprüfte Kundenimplementierung.
- Martin Fowler, Branch by Abstraction: https://martinfowler.com/bliki/BranchByAbstraction.html
- Martin Fowler, Strangler Fig: https://martinfowler.com/bliki/StranglerFigApplication.html
- Danilo Sato, Parallel Change (expand-and-contract): https://martinfowler.com/bliki/ParallelChange.html
- PHPStan, PHPDoc-Typen: https://phpstan.org/writing-php-code/phpdoc-types
- Pest, Architekturtests: https://pestphp.com/docs/arch-testing
