#Datenbank, Schema-Migration und Zero-Downtime

Datenbank-Ownership, Flyway/Liquibase-Denke, Stored Procedures, Trigger, Backfills, Outbox-Tabellen und Expand-and-Contract.


#Datenbank, Schema-Migration, Stored Procedures, Trigger, Datenqualität und Zero-Downtime-Migration

Dieser Abschnitt behandelt den Teil der Modernisierung, der in alten Enterprise-Java-Systemen oft unterschätzt wird: die Datenbank. In vielen Legacy-Systemen ist nicht der Java-Code der eigentliche Monolith, sondern die Datenbank. Tabellen, Stored Procedures, Trigger, Views, Batch-Jobs, Reports und externe Direktzugriffe bilden häufig ein zweites, weniger sichtbares System.

Das Ziel ist nicht, sofort das Datenbankschema neu zu designen. Das Ziel ist, Datenbankabhängigkeiten sichtbar zu machen, Schemaänderungen kontrollierbar zu machen und Modernisierung ohne unnötige Downtime zu ermöglichen.


#Warum die Datenbank ein eigenes Modernisierungs-Slice ist

Bei Legacy Enterprise Java sieht man oft diesen Irrtum:

text
Wir modernisieren die EJBs und bauen REST APIs.
Die Datenbank bleibt erst mal gleich.

Das ist manchmal richtig, aber nur, wenn du die Datenbank wirklich verstehst.

In der Praxis steckt viel Fachlogik hier:

text
- Stored Procedures
- Trigger
- Views
- Materialized Views
- Constraints
- Batch-Tabellen
- Statusfelder
- Historientabellen
- Audit-Tabellen
- Reporting-Schemas
- Schnittstellentabellen
- manuelle Korrekturskripte

Deshalb behandelst du die Datenbank wie einen eigenen Legacy-Teil.

#Erste Regel

text
Kein Schema-Refactoring ohne Nutzungsanalyse.

Vor jeder Änderung musst du wissen:

text
Welche Anwendung liest?
Welche Anwendung schreibt?
Welche Jobs laufen nachts?
Welche Reports greifen direkt zu?
Welche Trigger verändern Daten unsichtbar?
Welche Stored Procedures enthalten Fachlogik?

#Datenbank-Inventar erstellen

Lege diese Datei an:

text
docs/database-inventory.md

Vorlage:

markdown
# Database Inventory

## Datenbanken / Schemas

| DB/Schemaname | Zweck | Anwendung | Kritikalität |
|---|---|---|---|
| ORDER_DB | Bestellungen | Legacy EAR | hoch |
| REPORTING_DB | Reports | BI/Batch | mittel |

## Wichtige Tabellen

| Tabelle | Zweck | Read by | Written by | Risiko |
|---|---|---|---|---|
| ORDERS | Bestellung | UI, Batch, Reports | OrderServiceBean | hoch |
| INVOICE | Rechnung | UI, Reports | InvoiceMDB | hoch |
| AUDIT_LOG | Audit | Admin, Compliance | AuditServiceBean | mittel |
| OUTBOX_EVENT | Events | Worker | Use Cases | hoch |

## Stored Procedures

| Procedure | Zweck | Aufrufer | Risiko |
|---|---|---|---|
| CALCULATE_DISCOUNT | Rabattberechnung | OrderServiceBean | hoch |
| CLOSE_BILLING_PERIOD | Monatsabschluss | Batch | hoch |

## Trigger

| Trigger | Tabelle | Aktion | Risiko |
|---|---|---|---|
| TRG_ORDER_AUDIT | ORDERS | schreibt AUDIT_LOG | mittel |
| TRG_INVOICE_NUMBER | INVOICE | erzeugt Nummer | hoch |

## Views

| View | Zweck | Nutzer | Risiko |
|---|---|---|---|
| V_ORDER_STATUS | Reporting | BI | mittel |

Das ist dein Startpunkt.


#Suchliste für Datenbankabhängigkeiten im Code

Suche im Java-Code nach:

text
@Entity
@Table
@Column
@NamedQuery
createQuery
createNativeQuery
createStoredProcedureQuery
CallableStatement
PreparedStatement
Statement
ResultSet
@Procedure
@NamedStoredProcedureQuery
persistence.xml
jdbc/
DataSource
getConnection
commit
rollback
setAutoCommit
SELECT
INSERT
UPDATE
DELETE
MERGE
CALL
EXEC

Suche zusätzlich in SQL-Dateien nach:

text
CREATE TABLE
ALTER TABLE
CREATE INDEX
CREATE TRIGGER
CREATE PROCEDURE
CREATE FUNCTION
CREATE VIEW
CREATE MATERIALIZED VIEW
GRANT
SYNONYM
SEQUENCE

Ergebnis:

markdown
# Database Usage Map

| Code-Stelle | Tabelle/Procedure | Zugriff | Technologie | Risiko |
|---|---|---|---|---|
| OrderDao#save | ORDERS | INSERT | JPA | mittel |
| BillingJob#run | INVOICE | UPDATE | JDBC | hoch |
| DiscountDao#calculate | CALCULATE_DISCOUNT | CALL | Stored Procedure | hoch |
| OrderReportDao#find | V_ORDER_STATUS | SELECT | Native SQL | niedrig |

#Tabellen-Ownership klären

Eine Tabelle sollte möglichst genau einen fachlichen Owner haben.

Schlechtes Bild:

text
ORDERS wird geschrieben von:
- OrderServiceBean
- InvoiceMDB
- BillingBatch
- Admin JSP
- externem ETL Job

Das bedeutet:

text
Niemand besitzt die Tabelle wirklich.
Jede Änderung ist riskant.
Microservice-Splitting ist gefährlich.

Besseres Zielbild:

text
ORDERS wird geschrieben nur vom Order-Kontext.
Andere Kontexte lesen über API, Events, Views oder Read Models.

Dokumentiere:

markdown
# Table Ownership

| Tabelle | Fachlicher Owner | Schreibende Komponenten | Ziel |
|---|---|---|---|
| ORDERS | Order | OrderServiceBean, Admin JSP | nur Order Application Service |
| INVOICE | Billing | InvoiceMDB | Billing/Invoice Service |
| CUSTOMER | Customer | CustomerService, CRM Batch | Customer Context |

#Schemaänderungen versionieren

Keine manuelle Datenbankänderung ohne Versionierung.

In modernen Projekten nutzt du typischerweise:

text
- Flyway
- Liquibase

Flyway arbeitet stark migrationsskript-orientiert: Änderungen werden als versionierte Migrationen abgelegt und in definierter Reihenfolge ausgeführt. Liquibase arbeitet mit Changelogs und Changesets, die in SQL, XML, YAML oder JSON beschrieben werden können.

Beispiel Flyway:

text
src/main/resources/db/migration/
  V001__create_order_table.sql
  V002__add_order_status.sql
  V003__create_outbox_event.sql

Beispiel Liquibase:

text
src/main/resources/db/changelog/
  db.changelog-master.yaml
  changes/001-create-order-table.yaml
  changes/002-add-order-status.yaml

#Entscheidungshilfe

markdown
| Frage | Flyway | Liquibase |
|---|---|---|
| Team denkt in SQL-Skripten | sehr gut | gut |
| Viele DB-Typen abstrahieren | mittel | stark |
| Simpler Einstieg | sehr gut | mittel |
| Governance/Rollback/Changelogs | gut | stark |
| Legacy-DB mit viel SQL | sehr gut | gut |

Für viele Legacy-Java-Projekte ist Flyway als erster Schritt einfacher. Liquibase ist stark, wenn Governance, mehrere Datenbanktypen oder formale Changesets wichtig sind.


#Baseline für bestehende Legacy-Datenbank

Bei bestehenden Datenbanken startest du nicht mit V001__create_everything.sql, wenn die Datenbank längst existiert.

Du brauchst eine Baseline.

Beispiel:

text
Aktueller Produktionsstand = Baseline 1000
Neue Migrationen beginnen ab V1001

Flyway-Beispiel:

text
V1001__create_outbox_event.sql
V1002__add_payment_status_to_orders.sql
V1003__create_processed_message.sql

Dokumentation:

markdown
# Database Baseline

## Baseline Version
- Version: 1000
- Datum: 2026-07-02
- Umgebung: Production Snapshot

## Annahmen
- Alle bestehenden Tabellen gelten als Legacy-Bestand.
- Neue Änderungen starten mit Version 1001.
- Manuelle Änderungen nach Baseline sind verboten.

Wichtig:

text
Ab Baseline muss jede Schemaänderung durch Migration laufen.

#Zero-Downtime-Grundregel — Expand and Contract

Für produktionsnahe Systeme ist die wichtigste Technik:

text
Expand → Migrate → Contract

Nicht:

text
Spalte löschen
Code deployen
hoffen

Sondern:

text
1. Expand
   Schema kompatibel erweitern.

2. Migrate
   Daten befüllen und Code umstellen.

3. Contract
   Alte Spalten/Tabellen erst entfernen, wenn niemand sie mehr nutzt.

Beispiel: status von kurzem DB-Code auf sprechenden Status umstellen.

Alt:

text
ORDERS.STATUS = 'N', 'P', 'F'

Neu:

text
ORDERS.PAYMENT_STATUS = 'PAYMENT_PENDING', 'PAID', 'PAYMENT_FAILED'

#Schritt 1: Expand

sql
ALTER TABLE ORDERS ADD PAYMENT_STATUS VARCHAR(50);

#Schritt 2: Backfill

sql
UPDATE ORDERS
SET PAYMENT_STATUS = CASE STATUS
    WHEN 'N' THEN 'PAYMENT_PENDING'
    WHEN 'P' THEN 'PAID'
    WHEN 'F' THEN 'PAYMENT_FAILED'
    ELSE 'PAYMENT_UNKNOWN'
END
WHERE PAYMENT_STATUS IS NULL;

#Schritt 3: Dual Read / Dual Write

Neue Anwendung schreibt beide Felder:

text
STATUS und PAYMENT_STATUS

#Schritt 4: Read Switch

Neue Anwendung liest nur noch PAYMENT_STATUS.

#Schritt 5: Contract

Erst viel später:

sql
ALTER TABLE ORDERS DROP COLUMN STATUS;

#Additive Migrationen bevorzugen

Sichere Änderungen sind meistens additive Änderungen:

text
- neue nullable Spalte hinzufügen
- neue Tabelle hinzufügen
- neuer Index concurrent/online, wenn DB es unterstützt
- neue View hinzufügen
- neue Stored Procedure-Version hinzufügen

Riskante Änderungen:

text
- Spalte umbenennen
- Spalte löschen
- Datentyp ändern
- NOT NULL auf große Tabelle setzen
- große Tabelle rewriten
- Trigger-Verhalten ändern
- Stored Procedure Signatur ändern

Regel:

text
Eine Änderung ist gut, wenn alte und neue Anwendung gleichzeitig laufen können.

Beispiel sicher:

sql
ALTER TABLE outbox_event ADD correlation_id VARCHAR(100);

Beispiel riskant:

sql
ALTER TABLE outbox_event RENAME COLUMN payload TO event_payload;

Besser:

sql
ALTER TABLE outbox_event ADD event_payload CLOB;
-- Dual write
-- Backfill
-- Read switch
-- später payload entfernen

#Backfill-Jobs sicher gestalten

Bei großen Tabellen machst du keinen riesigen Update in einer Transaktion.

Schlecht:

sql
UPDATE ORDERS SET PAYMENT_STATUS = 'PAID' WHERE STATUS = 'P';

Bei Millionen Zeilen kann das lange Locks, Undo/Redo-Last und Replikationsprobleme verursachen.

Besser:

text
- kleine Chunks
- Fortschritt speichern
- wiederholbar machen
- nach jeder Einheit committen
- Monitoring einbauen

Pseudo-Code:

java
public class OrderStatusBackfillJob {

    public void run() {
        while (true) {
            List<Long> ids = repository.findNextBatchWithoutPaymentStatus(500);

            if (ids.isEmpty()) {
                return;
            }

            txService.backfillBatch(ids);
        }
    }
}

Transaktion pro Batch:

java
@Transactional
public void backfillBatch(List<Long> ids) {
    for (Long id : ids) {
        OrderEntity order = em.find(OrderEntity.class, id);
        order.setPaymentStatus(map(order.getStatus()));
    }
}

Dokumentiere:

markdown
# Backfill Plan

| Punkt | Entscheidung |
|---|---|
| Tabelle | ORDERS |
| Zielspalte | PAYMENT_STATUS |
| Batch-Größe | 500 |
| Retry | ja |
| Fortschritt | letzte ID / WHERE payment_status is null |
| Laufzeitfenster | außerhalb Peak |
| Monitoring | offene Datensätze, Fehler, Dauer |

#Stored Procedures analysieren

Stored Procedures sind nicht automatisch schlecht. Aber sie sind gefährlich, wenn niemand weiß, was sie tun.

Inventar:

markdown
# Stored Procedure Inventory

| Procedure | Fachliche Funktion | Aufrufer | Tabellen | Risiko |
|---|---|---|---|---|
| CALCULATE_DISCOUNT | Rabatt | OrderServiceBean | CUSTOMER, DISCOUNT_RULE | hoch |
| CREATE_INVOICE_NO | Rechnungsnummer | InvoiceServiceBean | INVOICE_SEQ | hoch |
| CLOSE_PERIOD | Monatsabschluss | Batch | INVOICE, LEDGER | sehr hoch |

Fragen:

text
- Enthält die Procedure Fachlogik?
- Enthält sie Transaktionskontrolle?
- Schreibt sie mehrere Tabellen?
- Wird sie von mehreren Anwendungen genutzt?
- Gibt es Tests?
- Gibt es Versionskontrolle?

#Modernisierungsoptionen

markdown
| Fall | Strategie |
|---|---|
| Procedure stabil und kritisch | WRAP |
| Procedure enthält Fachlogik, aber nur ein Aufrufer | EXTRACT langfristig |
| Procedure von vielen Systemen genutzt | KEEP + dokumentieren |
| Procedure ungetestet und komplex | Characterization Tests |

Erster Schritt ist fast immer ein Port:

java
public interface DiscountCalculator {
    Money calculateDiscount(CustomerId customerId, Money amount);
}

Adapter:

java
public class StoredProcedureDiscountCalculator implements DiscountCalculator {

    private final EntityManager em;

    public StoredProcedureDiscountCalculator(EntityManager em) {
        this.em = em;
    }

    @Override
    public Money calculateDiscount(CustomerId customerId, Money amount) {
        StoredProcedureQuery query = em.createStoredProcedureQuery("CALCULATE_DISCOUNT");
        query.registerStoredProcedureParameter("p_customer_id", String.class, ParameterMode.IN);
        query.registerStoredProcedureParameter("p_amount", BigDecimal.class, ParameterMode.IN);
        query.registerStoredProcedureParameter("p_discount", BigDecimal.class, ParameterMode.OUT);

        query.setParameter("p_customer_id", customerId.value());
        query.setParameter("p_amount", amount.value());
        query.execute();

        return Money.of((BigDecimal) query.getOutputParameterValue("p_discount"));
    }
}

So bleibt die Procedure erhalten, aber deine Fachlogik hängt nicht direkt an ihr.


#Trigger sichtbar machen

Trigger sind besonders gefährlich, weil sie Side Effects verstecken.

Beispiel:

text
Java-Code macht INSERT in ORDERS.
Trigger schreibt automatisch AUDIT_LOG.
Ein anderer Trigger aktualisiert CUSTOMER_STATS.

Der Java-Code zeigt nicht die ganze Wahrheit.

Dokumentiere:

markdown
# Trigger Map

| Trigger | Tabelle | Wann | Side Effect | Risiko |
|---|---|---|---|---|
| TRG_ORDER_AUDIT | ORDERS | AFTER INSERT/UPDATE | AUDIT_LOG | mittel |
| TRG_CUSTOMER_STATS | ORDERS | AFTER INSERT | CUSTOMER_STATS | hoch |
| TRG_INVOICE_NO | INVOICE | BEFORE INSERT | Rechnungsnummer | hoch |

Modernisierungsregeln:

text
- Trigger nicht entfernen, solange Code darauf angewiesen ist.
- Trigger-Side-Effects in Tests sichtbar machen.
- Bei neuer Architektur entscheiden: Trigger behalten oder Logik in Application Service ziehen.
- Keine doppelte Logik in Trigger und Java ohne klare Übergangsphase.

Testidee:

java
@Test
void insertingOrder_createsAuditLogViaTrigger() {
    orderRepository.save(order);
    assertTrue(auditLogRepository.existsForOrder(order.id()));
}

Das ist ein Characterization Test für Datenbankverhalten.


#Datenqualität prüfen

Vor Modernisierung musst du wissen, ob die Daten sauber sind.

Typische Legacy-Probleme:

text
- Statuswerte außerhalb der Dokumentation
- NULL in Pflichtfeldern
- ungültige Fremdschlüssel
- doppelte fachliche Schlüssel
- Währungsbeträge als String
- Datumsfelder mit Fake-Daten
- manuelle Korrekturen ohne Audit
- verwaiste Datensätze

Datenqualitäts-Checks:

sql
-- unbekannte Statuswerte
SELECT status, COUNT(*)
FROM orders
GROUP BY status;
sql
-- Orders ohne Customer
SELECT o.id
FROM orders o
LEFT JOIN customer c ON c.id = o.customer_id
WHERE c.id IS NULL;
sql
-- doppelte Rechnungen pro Order
SELECT order_id, COUNT(*)
FROM invoice
GROUP BY order_id
HAVING COUNT(*) > 1;

Dokumentation:

markdown
# Data Quality Findings

| Check | Ergebnis | Risiko | Aktion |
|---|---|---|---|
| unbekannte Order-Status | 17 Werte | hoch | Mapping klären |
| doppelte Invoices | 12 Orders | hoch | fachliche Korrektur |
| Orders ohne Customer | 0 | niedrig | keine |

#Constraints bewusst einführen

Constraints sind gut, aber bei Legacy-Daten gefährlich, wenn Daten nicht sauber sind.

Nicht sofort:

sql
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;

Besser:

text
1. Datenqualität prüfen
2. neue Writes validieren
3. Backfill/Korrektur
4. Constraint mit geringer Downtime einführen
5. Monitoring

Beispiel:

sql
SELECT COUNT(*)
FROM orders
WHERE customer_id IS NULL;

Wenn Ergebnis 0, dann weiter prüfen:

sql
SELECT COUNT(*)
FROM orders o
LEFT JOIN customer c ON c.id = o.customer_id
WHERE o.customer_id IS NOT NULL
AND c.id IS NULL;

Erst dann Constraint planen.

Für große Tabellen ist wichtig:

text
Constraint-Validierung kann Tabellen sperren oder Last erzeugen.
Datenbank-spezifische Online-Mechanismen prüfen.

#Indizes sicher hinzufügen

Indizes können Performance retten, aber auch Deployments blockieren.

Dokumentiere jeden neuen Index:

markdown
# Index Change

| Punkt | Wert |
|---|---|
| Tabelle | OUTBOX_EVENT |
| Spalten | status, event_type, created_at |
| Grund | Worker sucht PENDING Events |
| Query | findPendingOrderPaidEvents |
| Risiko | Schreib-Overhead |

SQL:

sql
CREATE INDEX idx_outbox_event_pending
ON outbox_event (status, event_type, created_at);

Für große Tabellen:

text
- Online Index Build prüfen
- Concurrent Index Build prüfen, falls DB unterstützt
- Lock-Verhalten der Ziel-DB kennen
- in produktionsnaher Kopie messen

Wichtiger Punkt:

text
Ein Index ist auch eine fachliche Produktionsänderung.
Er gehört in Review, Migration und Monitoring.

#Outbox-Tabellen operativ designen

Outbox ist nicht nur eine Tabelle. Outbox ist ein Betriebsprozess.

Empfohlene Spalten:

sql
CREATE TABLE outbox_event (
    id VARCHAR(36) PRIMARY KEY,
    aggregate_id VARCHAR(100) NOT NULL,
    aggregate_type VARCHAR(100) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload CLOB NOT NULL,
    status VARCHAR(30) NOT NULL,
    correlation_id VARCHAR(100),
    created_at TIMESTAMP NOT NULL,
    locked_at TIMESTAMP NULL,
    locked_by VARCHAR(100) NULL,
    published_at TIMESTAMP NULL,
    retry_count INTEGER NOT NULL,
    last_error CLOB NULL
);

Wichtige Indizes:

sql
CREATE INDEX idx_outbox_pending
ON outbox_event (status, event_type, created_at);

CREATE INDEX idx_outbox_aggregate
ON outbox_event (aggregate_type, aggregate_id);

Betriebsfragen:

text
- Wie lange behalten wir PUBLISHED Events?
- Wer darf DEAD Events reprocessen?
- Wie sehen wir hängende PROCESSING Events?
- Wie verhindern wir parallele Verarbeitung desselben Events?
- Wie propagieren wir correlation_id?

Retention:

text
PUBLISHED Events nach 30/90/180 Tagen archivieren oder löschen.
DEAD Events nie automatisch löschen, solange nicht fachlich geklärt.

#Processed Message Tabelle designen

Für Idempotenz brauchst du eine Tabelle wie:

sql
CREATE TABLE processed_message (
    message_id VARCHAR(150) PRIMARY KEY,
    message_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100),
    processed_at TIMESTAMP NOT NULL,
    consumer_name VARCHAR(100) NOT NULL
);

Wenn mehrere Consumer denselben Message-ID-Raum nutzen:

sql
CREATE TABLE processed_message (
    consumer_name VARCHAR(100) NOT NULL,
    message_id VARCHAR(150) NOT NULL,
    message_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100),
    processed_at TIMESTAMP NOT NULL,
    PRIMARY KEY (consumer_name, message_id)
);

Warum consumer_name?

text
Dieselbe Message kann für verschiedene Consumer unterschiedliche Bedeutung haben.

Idempotenz-Regel:

text
Erst fachliche Operation und Markierung in derselben Transaktion abschließen.

Beispiel:

java
@Transactional
public void handle(OrderPaidMessage message) {
    if (processedMessageRepository.alreadyProcessed("invoice-consumer", message.id())) {
        return;
    }

    if (!invoiceRepository.existsForOrderId(message.orderId())) {
        createInvoiceUseCase.execute(new CreateInvoiceCommand(message.orderId()));
    }

    processedMessageRepository.markProcessed("invoice-consumer", message.id());
}

#Read Models und Reporting entkoppeln

Legacy-Systeme haben oft Reports, die direkt auf operativen Tabellen lesen.

Problem:

text
Du willst ORDERS ändern,
aber BI, Excel-Exports und Batch-Reports lesen direkt daraus.

Strategien:

markdown
| Strategie | Wann sinnvoll |
|---|---|
| kompatible Views | wenn Reports SQL-basiert bleiben |
| Read Model Tabellen | wenn neue Struktur operativ gebraucht wird |
| Events in Reporting DB | wenn Entkopplung langfristig nötig ist |
| API für Reports | wenn Zugriff kontrolliert werden soll |

Kompatible View:

sql
CREATE VIEW v_orders_legacy AS
SELECT
    id,
    customer_id,
    CASE payment_status
        WHEN 'PAYMENT_PENDING' THEN 'N'
        WHEN 'PAID' THEN 'P'
        WHEN 'PAYMENT_FAILED' THEN 'F'
        ELSE 'U'
    END AS status,
    amount
FROM orders;

Damit können alte Reports weiterlaufen, während intern neue Statuswerte genutzt werden.


#Datenbank-Migration für Complex Slice 001

Für unseren Order/Payment-Slice brauchst du mindestens diese Migrationen.

#V1001: Payment Status erweitern

sql
ALTER TABLE orders ADD payment_status VARCHAR(50);
ALTER TABLE orders ADD payment_provider_reference VARCHAR(100);
ALTER TABLE orders ADD payment_error_reason VARCHAR(1000);

#V1002: Backfill Payment Status

sql
UPDATE orders
SET payment_status = CASE status
    WHEN 'NEW' THEN 'PAYMENT_PENDING'
    WHEN 'PAID' THEN 'PAID'
    WHEN 'PAYMENT_FAILED' THEN 'PAYMENT_FAILED'
    ELSE 'PAYMENT_UNKNOWN'
END
WHERE payment_status IS NULL;

#V1003: Outbox Event

sql
CREATE TABLE outbox_event (
    id VARCHAR(36) PRIMARY KEY,
    aggregate_id VARCHAR(100) NOT NULL,
    aggregate_type VARCHAR(100) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload CLOB NOT NULL,
    status VARCHAR(30) NOT NULL,
    correlation_id VARCHAR(100),
    created_at TIMESTAMP NOT NULL,
    locked_at TIMESTAMP NULL,
    locked_by VARCHAR(100) NULL,
    published_at TIMESTAMP NULL,
    retry_count INTEGER NOT NULL,
    last_error CLOB NULL
);

#V1004: Outbox Indizes

sql
CREATE INDEX idx_outbox_pending
ON outbox_event (status, event_type, created_at);

CREATE INDEX idx_outbox_aggregate
ON outbox_event (aggregate_type, aggregate_id);

#V1005: Processed Message

sql
CREATE TABLE processed_message (
    consumer_name VARCHAR(100) NOT NULL,
    message_id VARCHAR(150) NOT NULL,
    message_type VARCHAR(100) NOT NULL,
    aggregate_id VARCHAR(100),
    processed_at TIMESTAMP NOT NULL,
    PRIMARY KEY (consumer_name, message_id)
);

#V1006: Invoice Unique Guard

Falls fachlich nur eine Rechnung pro Order erlaubt ist:

sql
CREATE UNIQUE INDEX ux_invoice_order_id
ON invoice (order_id);

Vorher prüfen:

sql
SELECT order_id, COUNT(*)
FROM invoice
GROUP BY order_id
HAVING COUNT(*) > 1;

Wenn es Duplikate gibt, darfst du den Unique Index nicht blind einführen.


#Zero-Downtime-Cutover für DB-Änderungen

Für Complex Slice 001:

text
Phase 1: Expand
- payment_status nullable hinzufügen
- outbox_event hinzufügen
- processed_message hinzufügen
- Indizes hinzufügen
text
Phase 2: Backfill
- payment_status aus altem status befüllen
- Datenqualität prüfen
- doppelte invoices prüfen
text
Phase 3: Dual Write
- Legacy/modernisierter Code schreibt alten status und neuen payment_status
- Events zusätzlich in Outbox schreiben
- alte direkte JMS-Verarbeitung noch nicht abschalten
text
Phase 4: Shadow Read
- neue Komponenten lesen payment_status
- alte Komponenten lesen weiter status
- Abweichungen loggen
text
Phase 5: Cutover
- Payment Flow auf neuen Status umstellen
- Invoice nur auf OrderPaid triggern
- direkte OrderCreated-Verarbeitung abschalten
text
Phase 6: Contract
- alte status-Spalte erst entfernen, wenn keine Anwendung sie liest
- alte Trigger/Procedures nur nach Nutzungsnachweis entfernen

Cutover-Checkliste:

markdown
| Check | Erfüllt? |
|---|---|
| Backfill vollständig |  |
| keine unbekannten Statuswerte |  |
| Outbox Pending wird verarbeitet |  |
| keine DEAD Events |  |
| Invoice Idempotenz aktiv |  |
| Rollback-Plan getestet |  |
| alte Reports geprüft |  |
| Monitoring-Dashboard aktiv |  |

#Datenbank-Plan für dein echtes Projekt

Wenn du diesen Abschnitt auf dein echtes Projekt anwendest, machst du diese Reihenfolge:

text
1. Datenbank-Inventar erstellen.
2. Tabellen-Ownership dokumentieren.
3. Stored Procedures und Trigger katalogisieren.
4. Datenqualitätschecks schreiben.
5. Schema-Migrationstool auswählen.
6. Baseline festlegen.
7. Erste additive Migration planen.
8. Backfill-Strategie definieren.
9. Expand/Contract-Plan schreiben.
10. DB-Änderungen in CI testen.

Minimaler erster Arbeitsauftrag:

markdown
# Database Slice 001

## Ziel
Eine sichere additive Datenbankänderung für einen modernisierten Use Case einführen.

## Kandidat
- Tabelle:
- Neue Spalte/Tabelle:
- Betroffener Use Case:

## Risiken
- Tabelle groß?
- Trigger vorhanden?
- Reports betroffen?
- Stored Procedures betroffen?
- Externe Direktzugriffe?

## Plan
1. Migration schreiben.
2. In Test-DB ausführen.
3. Datenqualitätscheck schreiben.
4. Backfill testen.
5. Rollback/Forward-Fix definieren.
6. Monitoring ergänzen.

#Wichtigster Merksatz

text
Bei Legacy-Modernisierung ist die Datenbank kein passiver Speicher.
Sie ist oft ein aktiver Teil der Anwendung.
Modernisiere sie deshalb mit derselben Disziplin wie Java-Code.

#Referenzpunkte für diesen Abschnitt

  • Flyway: versionierte Datenbankmigrationen und wiederholbare Deployments
  • Liquibase: Changelogs und Changesets für Datenbankänderungen
  • Oracle Edition-Based Redefinition: Online Application Upgrades / Zero-Downtime-Migrationen
  • PostgreSQL Dokumentation: ALTER TABLE und Auswirkungen additiver Schemaänderungen
⌂ Cockpit