Files
AFC-Demo/misc/INCREMENTAL_MIGRATION_PLAN.md
T

24 KiB

Inkrementelle Migrations-Strategie: Parallele Schema-Integration

Konzept: Strangler Fig Pattern

Diese Strategie nutzt das Strangler Fig Pattern - das Backend-Schema wird parallel integriert und schrittweise übernimmt die Logik, während das alte System weiterläuft. Kein Big Bang, keine Breaking Changes.

Phase 1: Beide Systeme parallel
┌─────────────────┐     ┌─────────────────┐
│ Public Schema   │     │ Backend Schema  │
│ (Aktiv)         │     │ (Read-Only)     │
└─────────────────┘     └─────────────────┘

Phase 2: Dual-Write Pattern
┌─────────────────┐     ┌─────────────────┐
│ Public Schema   │────▶│ Backend Schema  │
│ (Primary)       │     │ (Secondary)     │
└─────────────────┘     └─────────────────┘

Phase 3: Umstellung
┌─────────────────┐     ┌─────────────────┐
│ Public Schema   │◀────│ Backend Schema  │
│ (Read-Only)     │     │ (Primary)       │
└─────────────────┘     └─────────────────┘

Phase 4: Deprecation
                        ┌─────────────────┐
                        │ Backend Schema  │
                        │ (Einzige Quelle)│
                        └─────────────────┘

Phase 1: Dual-Connection Setup (1-2 Tage)

1.1 Database-Konfiguration erweitern

config/database.php:

<?php

return [
    'default' => env('DB_CONNECTION', 'pgsql'),

    'connections' => [
        // Bestehende Public-Schema Verbindung
        'pgsql' => [
            'driver' => 'pgsql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '5432'),
            'database' => env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8',
            'prefix' => '',
            'prefix_indexes' => true,
            'search_path' => 'public',
            'sslmode' => 'prefer',
        ],

        // Neue Backend-Schema Verbindung
        'backend' => [
            'driver' => 'pgsql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '5432'),
            'database' => env('DB_DATABASE', 'forge'), // Gleiche DB
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'charset' => 'utf8',
            'prefix' => '',
            'prefix_indexes' => true,
            'search_path' => 'backend', // Unterschiedliches Schema!
            'sslmode' => 'prefer',
        ],
    ],
];

.env:

# Keine Änderung nötig - beide Connections nutzen gleiche Credentials
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=your_database
DB_USERNAME=your_user
DB_PASSWORD=your_password

1.2 Backend Models erstellen

app/Models/Backend/Company.php:

<?php

namespace App\Models\Backend;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Company extends Model
{
    use HasFactory;

    protected $connection = 'backend';
    protected $table = 'companies';

    protected $fillable = [
        'name',
        'legal_name',
        'ticker',
        'sector',
        'country',
        'headquarters',
        'kyc_risk_level',
        'summary',
    ];

    public function transactions(): HasMany
    {
        return $this->hasMany(Transaction::class);
    }

    /**
     * Sync zu public.companies (während Migration)
     */
    public function syncToPublic(): \App\Models\Company
    {
        return \App\Models\Company::updateOrCreate(
            ['id' => $this->id],
            $this->only($this->fillable)
        );
    }
}

app/Models/Backend/Transaction.php:

<?php

namespace App\Models\Backend;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Transaction extends Model
{
    use HasFactory;

    protected $connection = 'backend';
    protected $table = 'transactions';

    public const UPDATED_AT = 'last_modified_at';

    protected $fillable = [
        'corporate_entity',
        'corporate_counterparty',
        'tx_date',
        'tx_amount',
        'tx_currency',
        'tx_purpose',
        'tx_country_outgoing',
        'tx_country_incoming',
        'source_file',
        'raw_payload',
        'status',
    ];

    protected $casts = [
        'tx_amount' => 'decimal:2',
        // tx_date ist als TEXT gespeichert - später migrieren zu timestamp
    ];

    public function company(): BelongsTo
    {
        return $this->belongsTo(Company::class);
    }

    /**
     * Konvertiere zu public.transactions Format
     */
    public function toPublicFormat(): array
    {
        return [
            'company_id' => $this->company_id,
            'reference' => $this->id, // oder generiere unique reference
            'amount' => $this->tx_amount,
            'currency' => $this->tx_currency,
            'counterparty' => $this->corporate_counterparty,
            'counterparty_country' => $this->tx_country_incoming,
            'executed_at' => $this->tx_date,
            'status' => $this->status,
            'requires_review' => true,
        ];
    }
}

1.3 Test der Dual-Connection

<?php
// tests/Feature/DualConnectionTest.php

use App\Models\Backend\Company as BackendCompany;
use App\Models\Company as PublicCompany;

test('can access both schemas', function () {
    // Public Schema
    $publicCount = PublicCompany::count();
    expect($publicCount)->toBeGreaterThan(0);

    // Backend Schema
    $backendCount = BackendCompany::count();
    expect($backendCount)->toBeGreaterThanOrEqual(0);
});

test('connections are isolated', function () {
    $public = PublicCompany::first();
    $backend = BackendCompany::first();

    // Verschiedene Connections
    expect($public->getConnectionName())->toBe('pgsql');
    expect($backend->getConnectionName())->toBe('backend');
});

Phase 2: Abstraction Layer (2-3 Tage)

2.1 Repository Pattern mit Feature Flags

app/Repositories/CompanyRepository.php:

<?php

namespace App\Repositories;

use App\Models\Backend\Company as BackendCompany;
use App\Models\Company as PublicCompany;
use Illuminate\Database\Eloquent\Collection;

class CompanyRepository
{
    public function __construct(
        private bool $useBackendSchema = false
    ) {
        // Feature Flag aus Config
        $this->useBackendSchema = config('features.use_backend_schema', false);
    }

    public function all(): Collection
    {
        return $this->useBackendSchema
            ? BackendCompany::all()
            : PublicCompany::all();
    }

    public function find(int $id): PublicCompany|BackendCompany|null
    {
        return $this->useBackendSchema
            ? BackendCompany::find($id)
            : PublicCompany::find($id);
    }

    public function create(array $data): PublicCompany|BackendCompany
    {
        if ($this->useBackendSchema) {
            $company = BackendCompany::create($data);

            // Dual-Write: Sync zu Public während Übergangsphase
            if (config('features.dual_write', true)) {
                $company->syncToPublic();
            }

            return $company;
        }

        return PublicCompany::create($data);
    }

    public function update(int $id, array $data): bool
    {
        $company = $this->find($id);

        if (!$company) {
            return false;
        }

        $result = $company->update($data);

        // Dual-Write
        if ($this->useBackendSchema && config('features.dual_write', true)) {
            $company->syncToPublic();
        }

        return $result;
    }

    public function delete(int $id): bool
    {
        $company = $this->find($id);

        if (!$company) {
            return false;
        }

        // Dual-Delete
        if ($this->useBackendSchema && config('features.dual_write', true)) {
            PublicCompany::destroy($id);
        }

        return $company->delete();
    }

    /**
     * Helper: Get model class
     */
    public function getModelClass(): string
    {
        return $this->useBackendSchema
            ? BackendCompany::class
            : PublicCompany::class;
    }
}

app/Repositories/TransactionRepository.php:

<?php

namespace App\Repositories;

use App\Models\Backend\Transaction as BackendTransaction;
use App\Models\Transaction as PublicTransaction;
use Illuminate\Database\Eloquent\Collection;

class TransactionRepository
{
    public function __construct(
        private bool $useBackendSchema = false
    ) {
        $this->useBackendSchema = config('features.use_backend_schema', false);
    }

    public function forCompany(int $companyId): Collection
    {
        return $this->useBackendSchema
            ? BackendTransaction::where('company_id', $companyId)->get()
            : PublicTransaction::where('company_id', $companyId)->get();
    }

    public function requiresReview(): Collection
    {
        if ($this->useBackendSchema) {
            // Backend hat kein requires_review Feld - nutze status
            return BackendTransaction::where('status', 'pending')->get();
        }

        return PublicTransaction::where('requires_review', true)->get();
    }

    public function highRisk(int $threshold = 70): Collection
    {
        if ($this->useBackendSchema) {
            // Backend hat keinen risk_score - Alternative Logik
            return BackendTransaction::where('status', 'flagged')->get();
        }

        return PublicTransaction::where('risk_score', '>=', $threshold)->get();
    }
}

2.2 Feature Flag Konfiguration

config/features.php:

<?php

return [
    /*
    |--------------------------------------------------------------------------
    | Backend Schema Migration Flags
    |--------------------------------------------------------------------------
    */

    // Hauptschalter: Nutze Backend-Schema statt Public
    'use_backend_schema' => env('FEATURE_USE_BACKEND_SCHEMA', false),

    // Dual-Write: Schreibe in beide Schemas während Migration
    'dual_write' => env('FEATURE_DUAL_WRITE', true),

    // Read-Verification: Vergleiche Reads aus beiden Schemas (Logging)
    'verify_reads' => env('FEATURE_VERIFY_READS', false),

    // Schrittweise Migration pro Bereich
    'backend_schema_areas' => [
        'companies' => env('FEATURE_BACKEND_COMPANIES', false),
        'transactions' => env('FEATURE_BACKEND_TRANSACTIONS', false),
        'reports' => env('FEATURE_BACKEND_REPORTS', false),
    ],
];

.env (für schrittweise Aktivierung):

# Phase 1: Beide Schemas verfügbar, aber Public aktiv
FEATURE_USE_BACKEND_SCHEMA=false
FEATURE_DUAL_WRITE=false

# Phase 2: Dual-Write aktivieren
# FEATURE_USE_BACKEND_SCHEMA=false
# FEATURE_DUAL_WRITE=true

# Phase 3: Backend als Primary, Public als Fallback
# FEATURE_USE_BACKEND_SCHEMA=true
# FEATURE_DUAL_WRITE=true

# Phase 4: Nur Backend
# FEATURE_USE_BACKEND_SCHEMA=true
# FEATURE_DUAL_WRITE=false

2.3 Service Provider für Dependency Injection

app/Providers/RepositoryServiceProvider.php:

<?php

namespace App\Providers;

use App\Repositories\CompanyRepository;
use App\Repositories\TransactionRepository;
use Illuminate\Support\ServiceProvider;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(CompanyRepository::class, function ($app) {
            return new CompanyRepository(
                useBackendSchema: config('features.use_backend_schema', false)
            );
        });

        $this->app->singleton(TransactionRepository::class, function ($app) {
            return new TransactionRepository(
                useBackendSchema: config('features.use_backend_schema', false)
            );
        });
    }
}

bootstrap/providers.php:

<?php

return [
    App\Providers\AppServiceProvider::class,
    App\Providers\RepositoryServiceProvider::class, // Neu hinzufügen
];

Phase 3: Controller Migration (3-5 Tage)

3.1 Controller auf Repository umstellen

Vorher (app/Http/Controllers/CompanyController.php):

<?php

namespace App\Http\Controllers;

use App\Models\Company;
use Illuminate\Http\Request;

class CompanyController extends Controller
{
    public function index()
    {
        $companies = Company::with('transactions')->get();

        return view('companies.index', compact('companies'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string',
            'country' => 'required|string|size:2',
            // ...
        ]);

        $company = Company::create($validated);

        return redirect()->route('companies.show', $company);
    }
}

Nachher:

<?php

namespace App\Http\Controllers;

use App\Repositories\CompanyRepository;
use Illuminate\Http\Request;

class CompanyController extends Controller
{
    public function __construct(
        private CompanyRepository $companies
    ) {}

    public function index()
    {
        $companies = $this->companies->all();

        return view('companies.index', compact('companies'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string',
            'country' => 'required|string|size:2',
            // ...
        ]);

        $company = $this->companies->create($validated);

        return redirect()->route('companies.show', $company);
    }
}

3.2 Volt/Livewire Components anpassen

Vorher (resources/views/pages/companies/index.blade.php):

<?php

use App\Models\Company;

$companies = Company::query()
    ->with('transactions')
    ->orderBy('name')
    ->get();

?>

<div>
    @foreach($companies as $company)
        <flux:card>{{ $company->name }}</flux:card>
    @endforeach
</div>

Nachher:

<?php

use App\Repositories\CompanyRepository;

$companyRepo = app(CompanyRepository::class);
$companies = $companyRepo->all();

?>

<div>
    @foreach($companies as $company)
        <flux:card>{{ $company->name }}</flux:card>
    @endforeach
</div>

Phase 4: Monitoring & Verification (Parallel zu Phase 3)

4.1 Dual-Read Verification Middleware

app/Http/Middleware/VerifyDualSchemaReads.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class VerifyDualSchemaReads
{
    public function handle(Request $request, Closure $next)
    {
        if (!config('features.verify_reads')) {
            return $next($request);
        }

        // Capture queries von beiden Schemas
        $publicQueries = [];
        $backendQueries = [];

        \DB::connection('pgsql')->listen(function ($query) use (&$publicQueries) {
            $publicQueries[] = $query->sql;
        });

        \DB::connection('backend')->listen(function ($query) use (&$backendQueries) {
            $backendQueries[] = $query->sql;
        });

        $response = $next($request);

        // Log für Analyse
        if (!empty($publicQueries) || !empty($backendQueries)) {
            Log::channel('migration')->info('Dual Schema Access', [
                'route' => $request->path(),
                'public_queries' => count($publicQueries),
                'backend_queries' => count($backendQueries),
            ]);
        }

        return $response;
    }
}

4.2 Health Check Command

app/Console/Commands/VerifySchemaConsistency.php:

<?php

namespace App\Console\Commands;

use App\Models\Backend\Company as BackendCompany;
use App\Models\Company as PublicCompany;
use Illuminate\Console\Command;

class VerifySchemaConsistency extends Command
{
    protected $signature = 'schema:verify-consistency';
    protected $description = 'Verify data consistency between public and backend schemas';

    public function handle(): int
    {
        $this->info('Checking schema consistency...');

        // Company Count
        $publicCount = PublicCompany::count();
        $backendCount = BackendCompany::count();

        $this->table(
            ['Schema', 'Companies', 'Status'],
            [
                ['Public', $publicCount, '✓'],
                ['Backend', $backendCount, $backendCount === $publicCount ? '✓' : '⚠'],
            ]
        );

        if ($backendCount !== $publicCount) {
            $this->warn("Company count mismatch: Public={$publicCount}, Backend={$backendCount}");
        }

        // Sample Data Verification
        $sampleSize = min(10, $publicCount);
        $publicSample = PublicCompany::take($sampleSize)->get();
        $mismatches = 0;

        foreach ($publicSample as $publicCompany) {
            $backendCompany = BackendCompany::find($publicCompany->id);

            if (!$backendCompany) {
                $this->warn("Company {$publicCompany->id} missing in backend");
                $mismatches++;
                continue;
            }

            if ($publicCompany->name !== $backendCompany->name) {
                $this->warn("Company {$publicCompany->id} name mismatch");
                $mismatches++;
            }
        }

        if ($mismatches === 0) {
            $this->info('✓ All consistency checks passed!');
            return self::SUCCESS;
        }

        $this->error("✗ Found {$mismatches} inconsistencies");
        return self::FAILURE;
    }
}

Phase 5: Graduelle Umstellung (2-4 Wochen)

Woche 1: Read-Only Access

FEATURE_USE_BACKEND_SCHEMA=false
FEATURE_DUAL_WRITE=false
FEATURE_VERIFY_READS=true
  • Backend-Schema ist verfügbar
  • Monitoring läuft
  • Keine Produktions-Daten betroffen

Woche 2: Dual-Write aktivieren

FEATURE_USE_BACKEND_SCHEMA=false  # Lesen: Public
FEATURE_DUAL_WRITE=true           # Schreiben: Beide
FEATURE_VERIFY_READS=true
  • Neue Daten gehen in beide Schemas
  • Public bleibt Primary
  • ⚠️ Monitor auf Sync-Errors

Woche 3: Backend als Primary (Canary)

# Nur für 10% Traffic oder spezifische Routes
FEATURE_BACKEND_COMPANIES=true    # Companies von Backend lesen
FEATURE_BACKEND_TRANSACTIONS=false # Transactions noch von Public
FEATURE_DUAL_WRITE=true
  • Schrittweise Umstellung pro Feature
  • A/B Testing möglich
  • Rollback jederzeit möglich

Woche 4: Full Switchover

FEATURE_USE_BACKEND_SCHEMA=true   # Lesen: Backend
FEATURE_DUAL_WRITE=true           # Schreiben: Beide (Sicherheit)
  • Backend ist Primary
  • Public als Safety Net

Nach 2 Wochen stabiler Betrieb:

FEATURE_USE_BACKEND_SCHEMA=true
FEATURE_DUAL_WRITE=false          # Public wird deprecated

Phase 6: Testing-Strategie

6.1 Feature Tests mit Feature Flags

<?php
// tests/Feature/CompanyControllerTest.php

use App\Models\Backend\Company as BackendCompany;
use App\Models\Company as PublicCompany;

test('can create company with public schema', function () {
    config(['features.use_backend_schema' => false]);

    $response = $this->post('/companies', [
        'name' => 'Test Corp',
        'country' => 'DE',
    ]);

    $response->assertRedirect();
    expect(PublicCompany::where('name', 'Test Corp')->exists())->toBeTrue();
});

test('can create company with backend schema', function () {
    config(['features.use_backend_schema' => true]);

    $response = $this->post('/companies', [
        'name' => 'Test Corp Backend',
        'country' => 'DE',
    ]);

    $response->assertRedirect();
    expect(BackendCompany::where('corporate_entity', 'Test Corp Backend')->exists())->toBeTrue();
});

test('dual write creates in both schemas', function () {
    config([
        'features.use_backend_schema' => true,
        'features.dual_write' => true,
    ]);

    $response = $this->post('/companies', [
        'name' => 'Dual Write Test',
        'country' => 'DE',
    ]);

    $response->assertRedirect();

    expect(BackendCompany::where('corporate_entity', 'Dual Write Test')->exists())->toBeTrue();
    expect(PublicCompany::where('name', 'Dual Write Test')->exists())->toBeTrue();
});

6.2 Performance Tests

<?php
// tests/Performance/SchemaPerformanceTest.php

test('backend schema is not slower than public', function () {
    // Public Schema
    $start = microtime(true);
    config(['features.use_backend_schema' => false]);
    app(CompanyRepository::class)->all();
    $publicTime = microtime(true) - $start;

    // Backend Schema
    $start = microtime(true);
    config(['features.use_backend_schema' => true]);
    app(CompanyRepository::class)->all();
    $backendTime = microtime(true) - $start;

    // Backend sollte nicht mehr als 20% langsamer sein
    expect($backendTime)->toBeLessThan($publicTime * 1.2);
});

Phase 7: Cleanup & Optimization (Nach 4-6 Wochen)

7.1 Repository vereinfachen

Wenn Backend-Schema stabil läuft:

<?php

namespace App\Repositories;

use App\Models\Backend\Company;

class CompanyRepository
{
    // Feature Flags entfernen
    public function all()
    {
        return Company::all();
    }

    // Dual-Write Code entfernen
    public function create(array $data)
    {
        return Company::create($data);
    }
}

7.2 Public Schema deprecaten

-- Tabellen in _deprecated Schema verschieben
CREATE SCHEMA IF NOT EXISTS _deprecated;

ALTER TABLE public.companies SET SCHEMA _deprecated;
ALTER TABLE public.transactions SET SCHEMA _deprecated;

-- Optional: Views für Legacy-Support
CREATE VIEW public.companies AS
SELECT
    id,
    corporate_entity as name,
    NULL as legal_name,
    country,
    'medium' as kyc_risk_level,
    NULL as summary,
    created_at::timestamp,
    last_modified_at::timestamp as updated_at
FROM backend.companies;

Vorteile dieser Strategie

Zero Downtime

  • Keine Breaking Changes
  • Rollback jederzeit möglich
  • Schrittweise Umstellung

Sicherheit

  • Dual-Write als Safety Net
  • Continuous Verification
  • Feature Flags für granulare Kontrolle

Flexibilität

  • A/B Testing möglich
  • Schrittweise Migration pro Feature
  • Team kann parallel arbeiten

Lernkurve

  • Team lernt neues Schema schrittweise
  • Bugs können isoliert gefunden werden
  • Keine Hektik

Zeitplan (Realistisch)

Woche Phase Aufwand Risiko
1 Setup & Backend Models 2-3 Tage Niedrig
2 Repository Pattern 2-3 Tage Niedrig
3-4 Controller Migration 5-7 Tage Mittel
5 Read-Only Testing 2-3 Tage Niedrig
6 Dual-Write Phase 1 Woche Mittel
7-8 Gradual Switchover 2 Wochen Mittel
9-10 Monitoring & Stabilisierung 2 Wochen Niedrig
11-12 Cleanup 1 Woche Niedrig

Total: 10-12 Wochen (inkl. Buffer)


Nächste konkrete Schritte

Schritt 1: Database Config (heute, 30 Min)

# config/database.php erweitern
# .env bleibt unverändert
php artisan config:clear
php artisan tinker
>>> DB::connection('backend')->select('SELECT 1')

Schritt 2: Backend Models (heute, 1-2 Std)

# Models erstellen
mkdir -p app/Models/Backend
# Company.php & Transaction.php erstellen

Schritt 3: Erster Test (heute, 30 Min)

php artisan test --filter=DualConnectionTest

Schritt 4: Feature Flags (morgen, 1-2 Std)

# config/features.php erstellen
# Repository Pattern implementieren

Möchten Sie, dass ich mit Schritt 1-2 beginne und die konkrete Implementierung starte?