Legacy-Modernisierung: Alte Systeme in moderne Web-Apps überführen
Modernisierung

Legacy-Modernisierung: Alte Systeme in moderne Web-Apps überführen

Carola Schulte
26. Mai 2025
26 min

Legacy-Modernisierung: So überführen Sie alte Systeme in moderne Web-Apps

Das 15 Jahre alte System läuft noch. Irgendwie. Der ursprüngliche Entwickler ist längst weg, die Dokumentation lückenhaft, und jeder hat Angst, etwas anzufassen. Klingt bekannt?

Sie sind nicht allein. 70% aller Unternehmen kämpfen mit Legacy-Systemen, die zu wichtig sind, um sie abzuschalten, aber zu alt, um sie weiterzuentwickeln.

Die gute Nachricht: Sie müssen nicht alles auf einmal ersetzen. In diesem Artikel zeige ich Ihnen bewährte Patterns und konkreten PHP-Code, um Legacy-Systeme schrittweise zu modernisieren - ohne den laufenden Betrieb zu gefährden.


Warum Big Bang scheitert

Der klassische Ansatz: “Wir bauen das System komplett neu und schalten dann um.” Das scheitert aus drei Gründen:

  1. Unbekannte Anforderungen: Das alte System macht Dinge, von denen niemand mehr weiß
  2. Zeitdruck: Während Sie neu bauen, ändert sich das Business
  3. Risiko: Am Tag X muss alles funktionieren - tut es aber nie

Merksatz: Jede erfolgreiche Migration ist eine schrittweise Migration. Big Bang funktioniert nur in PowerPoint.


Das Strangler Fig Pattern

Das wichtigste Pattern für Legacy-Modernisierung. Benannt nach Würgefeigen, die ihren Wirtsbaum langsam umwachsen und ersetzen:

     ┌─────────────────────────────────────────┐
     │              Proxy/Router               │
     └─────────────┬───────────────┬───────────┘
                   │               │
          ┌────────▼────────┐ ┌────▼────────────┐
          │  Neues System   │ │  Legacy-System  │
          │  (Feature A, B) │ │  (Feature C, D) │
          └─────────────────┘ └─────────────────┘

Passt Strangler Fig für Ihr Projekt?

  • Legacy ist stabil genug, um weiterzulaufen (keine akuten Crashes)
  • Neuentwicklung dauert länger als 6 Monate
  • Es gibt klare Feature-Schnitte (nicht alles ist mit allem verzahnt)
  • Das Datenmodell muss sich sowieso ändern
  • Rollback-Fähigkeit ist geschaeftskritisch

Wenn 3+ Punkte zutreffen: Strangler ist Ihr Freund.

Schritt 1: Proxy vorschalten

<?php

declare(strict_types=1);

class MigrationRouter
{
    private array $migratedRoutes = [];
    private array $featureFlags = [];

    public function __construct(
        private readonly NewApplication $newApp,
        private readonly LegacyProxy $legacyProxy,
        private readonly FeatureFlagService $flags,
        private readonly LoggerInterface $logger
    ) {
        $this->loadMigratedRoutes();
    }

    public function handle(Request $request): Response
    {
        $path = $request->getPathInfo();
        $method = $request->getMethod();

        // Prüfen: Ist diese Route schon migriert?
        if ($this->isRouteMigrated($path, $method)) {
            return $this->routeToNewSystem($request);
        }

        // Prüfen: Ist Feature-Flag aktiv (graduelle Migration)?
        if ($this->shouldUseNewSystem($path, $request)) {
            return $this->routeToNewSystem($request);
        }

        // Fallback: Legacy-System
        return $this->routeToLegacy($request);
    }

    private function isRouteMigrated(string $path, string $method): bool
    {
        $key = strtoupper($method) . ':' . $this->normalizePath($path);
        return isset($this->migratedRoutes[$key]);
    }

    private function shouldUseNewSystem(string $path, Request $request): bool
    {
        // Feature-Flag basierte Migration (z.B. für 10% der User)
        $userId = $request->getSession()->get('user_id');

        if (!$userId) {
            return false;
        }

        $flagName = 'migration_' . $this->pathToFlagName($path);

        // WICHTIG: percentage gehoert in die Flag-Config, NICHT in den Context!
        // Sonst kann ein Bug/Manipulation das Rollout verändern.
        return $this->flags->isEnabled($flagName, [
            'user_id' => $userId,
        ]);
    }

    private function routeToNewSystem(Request $request): Response
    {
        $startTime = microtime(true);

        try {
            $response = $this->newApp->handle($request);

            $this->logger->info('Request routed to new system', [
                'path' => $request->getPathInfo(),
                'duration_ms' => (microtime(true) - $startTime) * 1000,
                'status' => $response->getStatusCode(),
            ]);

            return $response;

        } catch (\Throwable $e) {
            $this->logger->error('New system failed, falling back to legacy', [
                'path' => $request->getPathInfo(),
                'error' => $e->getMessage(),
            ]);

            // Fallback bei Fehler
            return $this->routeToLegacy($request);
        }
    }

    private function routeToLegacy(Request $request): Response
    {
        return $this->legacyProxy->forward($request);
    }

    private function loadMigratedRoutes(): void
    {
        // Aus Config oder Datenbank laden
        $this->migratedRoutes = [
            'GET:/api/v2/users' => true,
            'GET:/api/v2/users/*' => true,
            'POST:/api/v2/users' => true,
            'GET:/dashboard' => true,
            // Weitere migrierte Routen...
        ];
    }

    private function normalizePath(string $path): string
    {
        // NICHT: preg_replace('/\/\d+/', '/*', $path)
        // Das macht /reports/2025/09 zu /*/* - falsch!

        // Besser: Explizite Route-Patterns definieren
        // In Produktion: aus zentraler Route-Registry laden, nicht hardcoded!
        $patterns = [
            '#^/api/v2/users/(\d+)$#' => '/api/v2/users/*',
            '#^/api/v2/orders/(\d+)$#' => '/api/v2/orders/*',
            '#^/customers/(\d+)/invoices$#' => '/customers/*/invoices',
        ];

        foreach ($patterns as $regex => $normalized) {
            if (preg_match($regex, $path)) {
                return $normalized;
            }
        }

        return $path; // Unverändert wenn kein Pattern matched
    }

    private function pathToFlagName(string $path): string
    {
        // ACHTUNG: /foo-bar und /foo/bar werden beide zu foo_bar!
        // In Produktion: stabile Route-ID aus Registry nutzen.
        return str_replace(['/', '-'], '_', trim($path, '/'));
    }
}

Schritt 2: Legacy-Proxy implementieren

<?php

class LegacyProxy
{
    public function __construct(
        private readonly string $legacyBaseUrl,
        private readonly int $timeout = 30
    ) {}

    public function forward(Request $request): Response
    {
        $ch = curl_init();

        $url = $this->legacyBaseUrl . $request->getRequestUri();

        curl_setopt_array($ch, [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => $this->timeout,
            CURLOPT_CUSTOMREQUEST => $request->getMethod(),
            CURLOPT_HTTPHEADER => $this->forwardHeaders($request),
            CURLOPT_HEADER => true,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        // Body forwarden (POST, PUT, etc.)
        if (in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'])) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $request->getContent());
        }

        // Cookies forwarden (wichtig für Session!)
        if ($cookies = $request->cookies->all()) {
            $cookieString = implode('; ', array_map(
                fn($k, $v) => "{$k}={$v}",
                array_keys($cookies),
                array_values($cookies)
            ));
            curl_setopt($ch, CURLOPT_COOKIE, $cookieString);
        }

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);

        if ($response === false) {
            throw new LegacyUnavailableException(curl_error($ch));
        }

        curl_close($ch);

        $headers = $this->parseHeaders(substr($response, 0, $headerSize));
        $body = substr($response, $headerSize);

        return new Response($body, $httpCode, $headers);
    }

    private function forwardHeaders(Request $request): array
    {
        $forward = [];

        // Wichtige Headers forwarden
        $allowedHeaders = [
            'Content-Type', 'Accept', 'Authorization',
            'X-Requested-With', 'X-Correlation-ID'
        ];

        foreach ($allowedHeaders as $header) {
            if ($value = $request->headers->get($header)) {
                $forward[] = "{$header}: {$value}";
            }
        }

        // Original-IP forwarden
        $forward[] = 'X-Forwarded-For: ' . $request->getClientIp();
        $forward[] = 'X-Forwarded-Proto: ' . $request->getScheme();

        return $forward;
    }

    private function parseHeaders(string $headerStr): array
    {
        $headers = [];
        $setCookies = []; // Mehrere Set-Cookie Header möglich!

        foreach (explode("\r\n", $headerStr) as $line) {
            if (str_contains($line, ':')) {
                [$key, $value] = explode(':', $line, 2);
                $key = trim($key);
                $value = trim($value);

                // Set-Cookie kommt oft mehrfach - als Array sammeln
                if (strcasecmp($key, 'Set-Cookie') === 0) {
                    $setCookies[] = $value;
                } else {
                    $headers[$key] = $value;
                }
            }
        }

        // Session-Cookies gezielt droppen (nicht alle!)
        // Sonst killt man Locale, CSRF, Consent, Feature-Toggles etc.
        $sessionCookieNames = ['PHPSESSID', 'SESSIONID', 'session_id'];

        $filteredCookies = array_filter($setCookies, function($cookie) use ($sessionCookieNames) {
            foreach ($sessionCookieNames as $name) {
                if (str_starts_with($cookie, $name . '=')) {
                    return false; // Session-Cookie rausfiltern
                }
            }
            return true; // Andere Cookies behalten
        });

        if (!empty($filteredCookies)) {
            $headers['Set-Cookie'] = $filteredCookies;
        }

        return $headers;
    }
}

Hinweis: Je nach Framework werden Response-Header mehrfach gesetzt. Set-Cookie muss als Multi-Header behandelt werden - prueft euer Response-Objekt!

Merksatz: Der Proxy ist Ihr Sicherheitsnetz. Er ermöglicht Rollback in Sekunden, nicht Stunden.


API-Wrapping: Legacy-Funktionen kapseln

Bevor Sie Code migrieren, kapseln Sie ihn. So koennen Sie die Implementierung später austauschen, ohne die Aufrufer zu ändern:

Anti-Corruption Layer

<?php

// Das Legacy-System hat eine chaotische API
class LegacyCustomerService
{
    public function getKunde($id)
    {
        // Gibt Array mit deutschen Keys zurueck:
        // ['KundenNr' => '123', 'Firma' => 'Acme', 'PLZ_Ort' => '12345 Berlin', ...]
    }

    public function speichereKunde($data)
    {
        // Erwartet Array mit deutschen Keys
        // Gibt 1 bei Erfolg, 0 bei Fehler zurueck (kein bool!)
    }
}

// Anti-Corruption Layer: Saubere Schnittstelle nach aussen
interface CustomerRepositoryInterface
{
    public function findById(int $id): ?Customer;
    public function save(Customer $customer): void;
    public function findByEmail(string $email): ?Customer;
}

class LegacyCustomerRepository implements CustomerRepositoryInterface
{
    public function __construct(
        private readonly LegacyCustomerService $legacy,
        private readonly LoggerInterface $logger
    ) {}

    public function findById(int $id): ?Customer
    {
        try {
            $data = $this->legacy->getKunde($id);

            if (empty($data)) {
                return null;
            }

            return $this->mapToCustomer($data);

        } catch (\Throwable $e) {
            $this->logger->error('Legacy customer lookup failed', [
                'id' => $id,
                'error' => $e->getMessage(),
            ]);
            throw new RepositoryException("Customer lookup failed", 0, $e);
        }
    }

    public function save(Customer $customer): void
    {
        $legacyData = $this->mapToLegacy($customer);

        $result = $this->legacy->speichereKunde($legacyData);

        // Legacy gibt 1/0 statt true/false zurueck
        if ($result !== 1) {
            throw new RepositoryException("Failed to save customer");
        }
    }

    public function findByEmail(string $email): ?Customer
    {
        // WARNUNG: Das ist nur Uebergangs-Code!
        // Ab ~1000 Datensaetzen wird das unertraeglich langsam.
        // Loesung: Index in Legacy-DB, eigener Query, oder Migration-Job.
        $allCustomers = $this->legacy->getAlleKunden();

        foreach ($allCustomers as $data) {
            if (($data['Email'] ?? '') === $email) {
                return $this->mapToCustomer($data);
            }
        }

        return null;
    }

    private function mapToCustomer(array $legacyData): Customer
    {
        // PLZ und Ort trennen (Legacy speichert "12345 Berlin")
        $plzOrt = $legacyData['PLZ_Ort'] ?? '';
        preg_match('/^(\d{5})\s+(.+)$/', $plzOrt, $matches);

        return new Customer(
            id: (int)($legacyData['KundenNr'] ?? 0),
            companyName: $legacyData['Firma'] ?? '',
            email: $legacyData['Email'] ?? '',
            address: new Address(
                street: $legacyData['Strasse'] ?? '',
                postalCode: $matches[1] ?? '',
                city: $matches[2] ?? '',
                country: $legacyData['Land'] ?? 'DE',
            ),
            createdAt: $this->parseLegacyDate($legacyData['Angelegt'] ?? ''),
        );
    }

    private function mapToLegacy(Customer $customer): array
    {
        return [
            'KundenNr' => $customer->id ?: null,
            'Firma' => $customer->companyName,
            'Email' => $customer->email,
            'Strasse' => $customer->address->street,
            'PLZ_Ort' => $customer->address->postalCode . ' ' . $customer->address->city,
            'Land' => $customer->address->country,
        ];
    }

    private function parseLegacyDate(string $date): \DateTimeImmutable
    {
        // Legacy nutzt DD.MM.YYYY Format
        $parsed = \DateTimeImmutable::createFromFormat('d.m.Y', $date);
        return $parsed ?: new \DateTimeImmutable();
    }
}

// Spaeter: Neue Implementierung, gleiche Schnittstelle
class ModernCustomerRepository implements CustomerRepositoryInterface
{
    public function __construct(
        private readonly PDO $db
    ) {}

    public function findById(int $id): ?Customer
    {
        $stmt = $this->db->prepare(
            'SELECT * FROM customers WHERE id = ?'
        );
        $stmt->execute([$id]);

        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        return $row ? $this->hydrate($row) : null;
    }

    // ... moderne Implementierung
}

Feature-Toggle zwischen Implementierungen

<?php

class CustomerRepositoryFactory
{
    public function __construct(
        private readonly LegacyCustomerRepository $legacy,
        private readonly ModernCustomerRepository $modern,
        private readonly FeatureFlagService $flags
    ) {}

    public function create(?int $userId = null): CustomerRepositoryInterface
    {
        if ($this->flags->isEnabled('use_modern_customer_repo', ['user_id' => $userId])) {
            return $this->modern;
        }

        return $this->legacy;
    }
}

// In Service-Klassen:
class CustomerService
{
    public function __construct(
        private readonly CustomerRepositoryFactory $repoFactory
    ) {}

    public function getCustomer(int $id, ?int $requestingUserId = null): ?Customer
    {
        $repo = $this->repoFactory->create($requestingUserId);
        return $repo->findById($id);
    }
}

Merksatz: Der Anti-Corruption Layer schuetzt neuen Code vor Legacy-Chaos. Investieren Sie hier Zeit - es zahlt sich aus.


Datenbank-Migration

Die Datenbank ist oft der schwierigste Teil. Drei bewährte Strategien:

Strategie 1: Schema-Evolution (Online-Migration)

Aendern Sie das Schema schrittweise, ohne Downtime:

<?php

// Migration: Spalte hinzufuegen
class AddEmailToCustomers
{
    public function up(PDO $db): void
    {
        // Schritt 1: Spalte hinzufuegen (NULL erlaubt)
        $db->exec('ALTER TABLE customers ADD COLUMN email VARCHAR(255) NULL');

        // Schritt 2: Daten migrieren (in Batches!)
        $this->migrateData($db);

        // Schritt 3: NOT NULL setzen (erst wenn alle Daten migriert)
        // $db->exec('ALTER TABLE customers ALTER COLUMN email SET NOT NULL');
    }

    private function migrateData(PDO $db): void
    {
        $batchSize = 1000;
        $lastId = 0;

        do {
            $stmt = $db->prepare(
                'SELECT id, contact_info FROM customers
                 WHERE id > ? AND email IS NULL
                 ORDER BY id LIMIT ?'
            );
            $stmt->execute([$lastId, $batchSize]);
            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

            if (empty($rows)) {
                break;
            }

            $updateStmt = $db->prepare(
                'UPDATE customers SET email = ? WHERE id = ?'
            );

            foreach ($rows as $row) {
                $email = $this->extractEmail($row['contact_info']);
                $updateStmt->execute([$email, $row['id']]);
                $lastId = $row['id'];
            }

            // Kurze Pause um DB nicht zu überlasten
            usleep(100000); // 100ms

        } while (count($rows) === $batchSize);
    }

    private function extractEmail(string $contactInfo): ?string
    {
        // Legacy: Email ist irgendwo im Freitext-Feld versteckt
        if (preg_match('/[\w\.-]+@[\w\.-]+\.\w+/', $contactInfo, $matches)) {
            return $matches[0];
        }
        return null;
    }
}

Strategie 2: Dual-Write (Parallelbetrieb)

Schreiben Sie in beide Systeme, lesen Sie aus dem neuen:

<?php

class DualWriteCustomerRepository implements CustomerRepositoryInterface
{
    public function __construct(
        private readonly CustomerRepositoryInterface $legacy,
        private readonly CustomerRepositoryInterface $modern,
        private readonly LoggerInterface $logger,
        private readonly bool $readFromModern = false
    ) {}

    public function findById(int $id): ?Customer
    {
        if ($this->readFromModern) {
            // Primaer aus Modern lesen
            $customer = $this->modern->findById($id);

            // Fallback auf Legacy wenn nicht gefunden
            if ($customer === null) {
                $customer = $this->legacy->findById($id);

                // In Modern nachholen (Lazy Migration)
                if ($customer !== null) {
                    $this->modern->save($customer);
                }
            }

            return $customer;
        }

        return $this->legacy->findById($id);
    }

    public function save(Customer $customer): void
    {
        // IMMER in beide Systeme schreiben
        $this->legacy->save($customer);

        try {
            $this->modern->save($customer);
        } catch (\Throwable $e) {
            // Modern-Fehler loggen aber nicht abbrechen
            $this->logger->error('Dual-write to modern failed', [
                'customer_id' => $customer->id,
                'error' => $e->getMessage(),
            ]);
        }
    }

    // Verifikation: Sind beide Systeme synchron?
    public function verify(int $id): VerificationResult
    {
        $legacy = $this->legacy->findById($id);
        $modern = $this->modern->findById($id);

        if ($legacy === null && $modern === null) {
            return new VerificationResult(true, 'Both null');
        }

        if ($legacy === null || $modern === null) {
            return new VerificationResult(false, 'One is null');
        }

        // Felder vergleichen
        $differences = [];

        if ($legacy->email !== $modern->email) {
            $differences['email'] = [
                'legacy' => $legacy->email,
                'modern' => $modern->email,
            ];
        }

        // Weitere Felder...

        return new VerificationResult(
            empty($differences),
            empty($differences) ? 'Match' : json_encode($differences)
        );
    }
}

Strategie 3: Change Data Capture (CDC)

Aenderungen aus der Legacy-DB in Echtzeit erfassen:

<?php

class ChangeDataCapture
{
    private int $lastProcessedId = 0;

    public function __construct(
        private readonly PDO $legacyDb,
        private readonly PDO $modernDb,
        private readonly LoggerInterface $logger
    ) {}

    // Als Daemon oder Cron laufen lassen
    public function sync(): void
    {
        $this->lastProcessedId = $this->getLastProcessedId();

        while (true) {
            $changes = $this->pollChanges();

            foreach ($changes as $change) {
                $this->processChange($change);
            }

            // Kurze Pause wenn keine Aenderungen
            if (empty($changes)) {
                sleep(1);
            }
        }
    }

    private function pollChanges(): array
    {
        // Variante A: Trigger-basierte Change-Tabelle
        $stmt = $this->legacyDb->prepare(
            'SELECT * FROM change_log
             WHERE id > ?
             ORDER BY id
             LIMIT 100'
        );
        $stmt->execute([$this->lastProcessedId]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    private function processChange(array $change): void
    {
        try {
            match ($change['operation']) {
                'INSERT', 'UPDATE' => $this->upsertToModern($change),
                'DELETE' => $this->deleteFromModern($change),
                default => $this->logger->warning('Unknown operation', $change),
            };

            $this->lastProcessedId = $change['id'];
            $this->saveLastProcessedId($change['id']);

        } catch (\Throwable $e) {
            $this->logger->error('CDC processing failed', [
                'change_id' => $change['id'],
                'error' => $e->getMessage(),
            ]);
            // Nicht abbrechen - naechste Aenderung verarbeiten
        }
    }

    // Whitelist erlaubter Tabellen - WICHTIG gegen SQL Injection!
    private const ALLOWED_TABLES = [
        'customers', 'orders', 'products', 'invoices'
    ];

    private function upsertToModern(array $change): void
    {
        $tableName = $change['table_name'];
        $recordId = $change['record_id'];

        // Tabellennamen validieren (SQL Injection Prevention)
        if (!in_array($tableName, self::ALLOWED_TABLES, true)) {
            throw new \InvalidArgumentException("Unknown table: {$tableName}");
        }

        // Aktuellen Stand aus Legacy lesen
        $stmt = $this->legacyDb->prepare(
            "SELECT * FROM {$tableName} WHERE id = ?"
        );
        $stmt->execute([$recordId]);
        $record = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$record) {
            return;
        }

        // In Modern upserten
        $columns = array_keys($record);
        $placeholders = array_fill(0, count($columns), '?');

        $sql = sprintf(
            'INSERT INTO %s (%s) VALUES (%s)
             ON CONFLICT (id) DO UPDATE SET %s',
            $tableName,
            implode(', ', $columns),
            implode(', ', $placeholders),
            implode(', ', array_map(fn($c) => "{$c} = EXCLUDED.{$c}", $columns))
        );

        $stmt = $this->modernDb->prepare($sql);
        $stmt->execute(array_values($record));
    }

    private function deleteFromModern(array $change): void
    {
        $tableName = $change['table_name'];

        // Gleiche Whitelist-Pruefung wie bei upsert!
        if (!in_array($tableName, self::ALLOWED_TABLES, true)) {
            throw new \InvalidArgumentException("Unknown table: {$tableName}");
        }

        $stmt = $this->modernDb->prepare(
            "DELETE FROM {$tableName} WHERE id = ?"
        );
        $stmt->execute([$change['record_id']]);
    }

    // Checkpoint persistieren
    private function getLastProcessedId(): int
    {
        $file = '/var/run/cdc_checkpoint.txt';
        return file_exists($file) ? (int)file_get_contents($file) : 0;
    }

    private function saveLastProcessedId(int $id): void
    {
        file_put_contents('/var/run/cdc_checkpoint.txt', $id);
    }
}

Datenbank-Migration Entscheidungshilfe:

SituationEmpfehlung
Kleine AenderungenSchema-Evolution
Komplettes RedesignDual-Write + Lazy Migration
Echtzeit-Sync nötigChange Data Capture
Hohe Konsistenz-AnforderungDual-Write mit Verifikation

Merksatz: Datenbank-Migrationen sind Marathons, keine Sprints. Planen Sie Wochen ein, nicht Tage.


Legacy-Code testen

Legacy-Code hat selten Tests. Bevor Sie refactoren, sichern Sie ab:

Characterization Tests

Tests, die das aktuelle Verhalten dokumentieren - nicht das erwartete:

<?php

class LegacyInvoiceCalculatorTest extends TestCase
{
    private LegacyInvoiceCalculator $calculator;

    protected function setUp(): void
    {
        $this->calculator = new LegacyInvoiceCalculator();
    }

    /**
     * Characterization Test: Dokumentiert aktuelles Verhalten
     * ACHTUNG: Dieses Verhalten koennte ein Bug sein!
     */
    public function testCalculateTotal_WithDiscountAndTax(): void
    {
        $result = $this->calculator->calculateTotal(
            items: [
                ['price' => 100, 'quantity' => 2],
                ['price' => 50, 'quantity' => 1],
            ],
            discount: 10, // Prozent
            taxRate: 19   // Prozent
        );

        // Aktuelles Verhalten: 267.75
        // Erwartetes Verhalten: 267.30 (Rabatt vor Steuer)
        // Wir dokumentieren das aktuelle Verhalten!
        $this->assertEquals(267.75, $result);
    }

    /**
     * Golden Master Test: Vergleich mit gespeichertem Output
     */
    public function testGenerateReport_GoldenMaster(): void
    {
        $report = $this->calculator->generateReport(
            month: 6,
            year: 2025,
            customerId: 123
        );

        $goldenMaster = file_get_contents(__DIR__ . '/fixtures/report_123_202506.json');

        $this->assertJsonStringEqualsJsonString($goldenMaster, json_encode($report));
    }
}

Approval Tests

Speichern Sie den aktuellen Output und vergleichen bei Aenderungen:

<?php

class ApprovalTestHelper
{
    public static function verify(mixed $actual, string $testName): void
    {
        $actualFile = __DIR__ . "/approvals/{$testName}.received.txt";
        $approvedFile = __DIR__ . "/approvals/{$testName}.approved.txt";

        $actualContent = self::serialize($actual);

        file_put_contents($actualFile, $actualContent);

        if (!file_exists($approvedFile)) {
            throw new \RuntimeException(
                "No approved file. Review and rename:\n" .
                "mv {$actualFile} {$approvedFile}"
            );
        }

        $approvedContent = file_get_contents($approvedFile);

        if ($actualContent !== $approvedContent) {
            // HINWEIS: shell_exec("diff") ist nur für lokale Entwicklung!
            // In CI: Entweder PHP-Library (sebastian/diff) nutzen,
            // oder CI-Runner absichern (kein Schreibzugriff auf kritische Pfade).
            throw new \RuntimeException(
                "Output changed! Diff:\n" .
                shell_exec("diff {$approvedFile} {$actualFile}")
            );
        }

        // Cleanup
        unlink($actualFile);
    }

    private static function serialize(mixed $value): string
    {
        if (is_array($value) || is_object($value)) {
            return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
        }
        return (string)$value;
    }
}

// Verwendung im Test
public function testLegacyExport(): void
{
    $exporter = new LegacyExporter();
    $result = $exporter->export(customerId: 123);

    ApprovalTestHelper::verify($result, 'legacy_export_customer_123');
}

Test-Coverage für kritische Pfade

<?php

class CriticalPathIdentifier
{
    public function __construct(
        private readonly PDO $db
    ) {}

    /**
     * Identifiziert die meistgenutzten Code-Pfade basierend auf
     * Access-Logs oder Feature-Usage-Tracking
     */
    public function findCriticalPaths(): array
    {
        $stmt = $this->db->query(
            "SELECT
                endpoint,
                COUNT(*) as calls,
                AVG(duration_ms) as avg_duration,
                COUNT(*) FILTER (WHERE status >= 500) as errors
             FROM access_logs
             WHERE created_at > NOW() - INTERVAL '30 days'
             GROUP BY endpoint
             ORDER BY calls DESC
             LIMIT 50"
        );

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Generiert Test-Skelette für kritische Pfade
     */
    public function generateTestSkeletons(array $criticalPaths): string
    {
        $output = "<?php\n\nclass CriticalPathTests extends TestCase\n{\n";

        foreach ($criticalPaths as $path) {
            $methodName = $this->pathToMethodName($path['endpoint']);
            $output .= <<<PHP
    /**
     * Endpoint: {$path['endpoint']}
     * Calls/Month: {$path['calls']}
     * Avg Duration: {$path['avg_duration']}ms
     * Error Rate: {$this->calculateErrorRate($path)}%
     */
    public function test_{$methodName}(): void
    {
        \$this->markTestIncomplete('TODO: Implement critical path test');
    }

PHP;
        }

        $output .= "}\n";

        return $output;
    }

    private function pathToMethodName(string $path): string
    {
        return preg_replace('/[^a-z0-9]+/i', '_', trim($path, '/'));
    }

    private function calculateErrorRate(array $path): float
    {
        return round($path['errors'] / $path['calls'] * 100, 2);
    }
}

Merksatz: Ohne Tests ist Refactoring Gluecksspiel. Characterization Tests sind Ihr Sicherheitsnetz.


UI-Migration: Schrittweise modernisieren

Micro-Frontends für graduelle Migration

<!-- Legacy-Seite mit eingebettetem Modern-Widget -->
<!DOCTYPE html>
<html>
<head>
    <title>Legacy Application</title>
    <!-- Legacy-CSS -->
    <link rel="stylesheet" href="/legacy/styles.css">
</head>
<body>
    <div id="legacy-header">
        <!-- Alter Header bleibt -->
    </div>

    <div id="legacy-content">
        <!-- Alter Content -->
    </div>

    <!-- Modern Widget einbetten -->
    <div id="modern-dashboard-widget"></div>

    <script src="/legacy/jquery.min.js"></script>
    <script src="/legacy/app.js"></script>

    <!-- Modern Widget laden (isoliert) -->
    <script type="module">
        import { mountDashboardWidget } from '/modern/widgets/dashboard.js';

        mountDashboardWidget(document.getElementById('modern-dashboard-widget'), {
            apiBase: '/api/v2',
            userId: window.LEGACY_USER_ID
        });
    </script>
</body>
</html>

Shadow DOM für CSS-Isolation

// modern/widgets/dashboard.js
export function mountDashboardWidget(container, config) {
    // Shadow DOM schuetzt vor Legacy-CSS-Konflikten
    const shadow = container.attachShadow({ mode: 'open' });

    shadow.innerHTML = `
        <style>
            /* Eigene Styles - isoliert von Legacy */
            :host {
                display: block;
                font-family: system-ui, sans-serif;
            }
            .dashboard {
                padding: 1rem;
                background: #f5f5f5;
                border-radius: 8px;
            }
            /* ... */
        </style>
        <div class="dashboard">
            <h2>Neues Dashboard</h2>
            <div id="content">Laden...</div>
        </div>
    `;

    loadDashboardData(shadow.querySelector('#content'), config);
}

async function loadDashboardData(container, config) {
    const response = await fetch(`${config.apiBase}/dashboard/${config.userId}`);
    const data = await response.json();

    container.innerHTML = renderDashboard(data);
}

Server-Side Includes (SSI) für PHP

<?php

// legacy/templates/page.php
?>
<!DOCTYPE html>
<html>
<head>
    <title><?= htmlspecialchars($pageTitle) ?></title>
</head>
<body>
    <?php include 'header.php'; ?>

    <main>
        <?php if ($this->shouldShowModernWidget('sidebar')): ?>
            <!-- Modern Widget via Server-Side Include -->
            <?= $this->renderModernWidget('sidebar', [
                'user_id' => $currentUser->id,
                'context' => 'dashboard'
            ]) ?>
        <?php else: ?>
            <!-- Legacy Sidebar -->
            <?php include 'legacy_sidebar.php'; ?>
        <?php endif; ?>

        <div class="content">
            <?= $content ?>
        </div>
    </main>

    <?php include 'footer.php'; ?>
</body>
</html>

<?php

// ModernWidgetRenderer.php
class ModernWidgetRenderer
{
    public function __construct(
        private readonly string $widgetServiceUrl,
        private readonly int $timeout = 2
    ) {}

    public function render(string $widgetName, array $props): string
    {
        $cacheKey = "widget:{$widgetName}:" . md5(json_encode($props));

        // Cache pruefen (Widgets ändern sich selten)
        if ($cached = $this->cache->get($cacheKey)) {
            return $cached;
        }

        try {
            $html = $this->fetchWidget($widgetName, $props);
            $this->cache->set($cacheKey, $html, ttl: 300);
            return $html;

        } catch (\Throwable $e) {
            // Bei Fehler: Fallback oder leer
            return "<!-- Widget {$widgetName} unavailable -->";
        }
    }

    private function fetchWidget(string $widgetName, array $props): string
    {
        $ch = curl_init($this->widgetServiceUrl . "/widgets/{$widgetName}");

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => $this->timeout,
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($props),
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode !== 200) {
            throw new \RuntimeException("Widget fetch failed: {$httpCode}");
        }

        return $response;
    }
}

Feature Flags für sichere Rollouts

<?php

interface FeatureFlagService
{
    public function isEnabled(string $flag, array $context = []): bool;
}

class DatabaseFeatureFlagService implements FeatureFlagService
{
    public function __construct(
        private readonly PDO $db,
        private readonly CacheInterface $cache
    ) {}

    public function isEnabled(string $flag, array $context = []): bool
    {
        $config = $this->getConfig($flag);

        if ($config === null) {
            return false; // Unknown flag = off
        }

        if (!$config['enabled']) {
            return false;
        }

        // Globales Kill-Switch
        if ($config['kill_switch']) {
            return false;
        }

        // Percentage Rollout
        if ($percentage = $config['percentage']) {
            $userId = $context['user_id'] ?? 0;
            // Deterministisch basierend auf User-ID
            $bucket = crc32($flag . ':' . $userId) % 100;
            if ($bucket >= $percentage) {
                return false;
            }
        }

        // User-Whitelist
        if ($whitelist = $config['user_whitelist']) {
            $userId = $context['user_id'] ?? null;
            if ($userId && !in_array($userId, $whitelist)) {
                return false;
            }
        }

        // Zeit-basierte Aktivierung
        if ($startAt = $config['start_at']) {
            if (new \DateTime() < new \DateTime($startAt)) {
                return false;
            }
        }

        return true;
    }

    private function getConfig(string $flag): ?array
    {
        $cacheKey = "feature_flag:{$flag}";

        if ($cached = $this->cache->get($cacheKey)) {
            return $cached;
        }

        $stmt = $this->db->prepare(
            'SELECT * FROM feature_flags WHERE name = ?'
        );
        $stmt->execute([$flag]);

        $config = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;

        if ($config) {
            $config['user_whitelist'] = json_decode($config['user_whitelist'] ?? '[]', true);
        }

        $this->cache->set($cacheKey, $config, ttl: 60);

        return $config;
    }
}

// Admin-Controller für Feature Flags
class FeatureFlagController
{
    public function updatePercentage(string $flag, int $percentage): void
    {
        if ($percentage < 0 || $percentage > 100) {
            throw new \InvalidArgumentException('Percentage must be 0-100');
        }

        $stmt = $this->db->prepare(
            'UPDATE feature_flags SET percentage = ?, updated_at = NOW() WHERE name = ?'
        );
        $stmt->execute([$percentage, $flag]);

        $this->cache->delete("feature_flag:{$flag}");

        $this->logger->info("Feature flag percentage updated", [
            'flag' => $flag,
            'percentage' => $percentage,
        ]);
    }

    public function emergencyKill(string $flag): void
    {
        $stmt = $this->db->prepare(
            'UPDATE feature_flags SET kill_switch = true, updated_at = NOW() WHERE name = ?'
        );
        $stmt->execute([$flag]);

        $this->cache->delete("feature_flag:{$flag}");

        $this->logger->warning("EMERGENCY: Feature flag killed", ['flag' => $flag]);
    }
}

Migrations-Checkliste

Vor dem Start:

  • Aktuelle System-Dokumentation erstellen (auch wenn lückenhaft)
  • Kritische Geschaeftsprozesse identifizieren
  • Stakeholder-Buy-In sichern (wird länger dauern als gedacht)
  • Monitoring/Alerting für Legacy UND neues System
  • Rollback-Plan für jede Phase

Während der Migration:

  • Strangler Fig Pattern: Proxy vorschalten
  • Anti-Corruption Layer für jede Legacy-Schnittstelle
  • Feature Flags für graduelle Rollouts
  • Dual-Write für Datenbank (wenn möglich)
  • Characterization Tests vor jedem Refactoring
  • Keine neuen Features im Legacy (Feature Freeze)

Nach der Migration:

  • Legacy-System abschalten (nicht vergessen!)
  • Datenbank-Sync beenden
  • Proxy entfernen
  • Feature Flags aufraeumen (Expiry-Date setzen, sonst verrottet das System!)
  • Lessons Learned dokumentieren

Typische Fehler vermeiden

FehlerBesser
Big Bang RewriteStrangler Fig Pattern
Alles auf einmal migrierenFeature für Feature
Keine Tests vor RefactoringCharacterization Tests zuerst
Legacy-Logik 1:1 kopierenAnforderungen neu verstehen
Zu frueh optimierenErst funktionieren, dann schnell
Dual-Write ohne VerifikationRegelmaessig Daten vergleichen
Feature Flags nie aufraeumenExpiry-Date setzen

Fazit

Legacy-Modernisierung ist kein Projekt, sondern ein Prozess. Die wichtigsten Erkenntnisse:

  1. Strangler Fig: Schrittweise ersetzen, nicht Big Bang
  2. Anti-Corruption Layer: Legacy-Chaos vom neuen Code isolieren
  3. Characterization Tests: Verhalten dokumentieren vor Refactoring
  4. Feature Flags: Graduelle Rollouts mit Notfall-Kill-Switch
  5. Dual-Write: Parallelbetrieb mit Verifikation
Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch