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?