#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:
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:
- 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
Kein Schema-Refactoring ohne Nutzungsanalyse.
Vor jeder Änderung musst du wissen:
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:
docs/database-inventory.md
Vorlage:
# 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:
@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:
CREATE TABLE
ALTER TABLE
CREATE INDEX
CREATE TRIGGER
CREATE PROCEDURE
CREATE FUNCTION
CREATE VIEW
CREATE MATERIALIZED VIEW
GRANT
SYNONYM
SEQUENCE
Ergebnis:
# 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:
ORDERS wird geschrieben von:
- OrderServiceBean
- InvoiceMDB
- BillingBatch
- Admin JSP
- externem ETL Job
Das bedeutet:
Niemand besitzt die Tabelle wirklich.
Jede Änderung ist riskant.
Microservice-Splitting ist gefährlich.
Besseres Zielbild:
ORDERS wird geschrieben nur vom Order-Kontext.
Andere Kontexte lesen über API, Events, Views oder Read Models.
Dokumentiere:
# 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:
- 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:
src/main/resources/db/migration/
V001__create_order_table.sql
V002__add_order_status.sql
V003__create_outbox_event.sql
Beispiel Liquibase:
src/main/resources/db/changelog/
db.changelog-master.yaml
changes/001-create-order-table.yaml
changes/002-add-order-status.yaml
#Entscheidungshilfe
| 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:
Aktueller Produktionsstand = Baseline 1000
Neue Migrationen beginnen ab V1001
Flyway-Beispiel:
V1001__create_outbox_event.sql
V1002__add_payment_status_to_orders.sql
V1003__create_processed_message.sql
Dokumentation:
# 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:
Ab Baseline muss jede Schemaänderung durch Migration laufen.
#Zero-Downtime-Grundregel — Expand and Contract
Für produktionsnahe Systeme ist die wichtigste Technik:
Expand → Migrate → Contract
Nicht:
Spalte löschen
Code deployen
hoffen
Sondern:
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:
ORDERS.STATUS = 'N', 'P', 'F'
Neu:
ORDERS.PAYMENT_STATUS = 'PAYMENT_PENDING', 'PAID', 'PAYMENT_FAILED'
#Schritt 1: Expand
ALTER TABLE ORDERS ADD PAYMENT_STATUS VARCHAR(50);
#Schritt 2: Backfill
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:
STATUS und PAYMENT_STATUS
#Schritt 4: Read Switch
Neue Anwendung liest nur noch PAYMENT_STATUS.
#Schritt 5: Contract
Erst viel später:
ALTER TABLE ORDERS DROP COLUMN STATUS;
#Additive Migrationen bevorzugen
Sichere Änderungen sind meistens additive Änderungen:
- 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:
- 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:
Eine Änderung ist gut, wenn alte und neue Anwendung gleichzeitig laufen können.
Beispiel sicher:
ALTER TABLE outbox_event ADD correlation_id VARCHAR(100);
Beispiel riskant:
ALTER TABLE outbox_event RENAME COLUMN payload TO event_payload;
Besser:
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:
UPDATE ORDERS SET PAYMENT_STATUS = 'PAID' WHERE STATUS = 'P';
Bei Millionen Zeilen kann das lange Locks, Undo/Redo-Last und Replikationsprobleme verursachen.
Besser:
- kleine Chunks
- Fortschritt speichern
- wiederholbar machen
- nach jeder Einheit committen
- Monitoring einbauen
Pseudo-Code:
public class OrderStatusBackfillJob {
public void run() {
while (true) {
List<Long> ids = repository.findNextBatchWithoutPaymentStatus(500);
if (ids.isEmpty()) {
return;
}
txService.backfillBatch(ids);
}
}
}
Transaktion pro Batch:
@Transactional
public void backfillBatch(List<Long> ids) {
for (Long id : ids) {
OrderEntity order = em.find(OrderEntity.class, id);
order.setPaymentStatus(map(order.getStatus()));
}
}
Dokumentiere:
# 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:
# 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:
- 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
| 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:
public interface DiscountCalculator {
Money calculateDiscount(CustomerId customerId, Money amount);
}
Adapter:
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:
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:
# 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:
- 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:
@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:
- 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:
-- unbekannte Statuswerte
SELECT status, COUNT(*)
FROM orders
GROUP BY status;
-- Orders ohne Customer
SELECT o.id
FROM orders o
LEFT JOIN customer c ON c.id = o.customer_id
WHERE c.id IS NULL;
-- doppelte Rechnungen pro Order
SELECT order_id, COUNT(*)
FROM invoice
GROUP BY order_id
HAVING COUNT(*) > 1;
Dokumentation:
# 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:
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;
Besser:
1. Datenqualität prüfen
2. neue Writes validieren
3. Backfill/Korrektur
4. Constraint mit geringer Downtime einführen
5. Monitoring
Beispiel:
SELECT COUNT(*)
FROM orders
WHERE customer_id IS NULL;
Wenn Ergebnis 0, dann weiter prüfen:
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:
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:
# Index Change
| Punkt | Wert |
|---|---|
| Tabelle | OUTBOX_EVENT |
| Spalten | status, event_type, created_at |
| Grund | Worker sucht PENDING Events |
| Query | findPendingOrderPaidEvents |
| Risiko | Schreib-Overhead |
SQL:
CREATE INDEX idx_outbox_event_pending
ON outbox_event (status, event_type, created_at);
Für große Tabellen:
- 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:
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:
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:
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:
- 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:
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:
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:
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?
Dieselbe Message kann für verschiedene Consumer unterschiedliche Bedeutung haben.
Idempotenz-Regel:
Erst fachliche Operation und Markierung in derselben Transaktion abschließen.
Beispiel:
@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:
Du willst ORDERS ändern,
aber BI, Excel-Exports und Batch-Reports lesen direkt daraus.
Strategien:
| 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:
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
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
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
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
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
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:
CREATE UNIQUE INDEX ux_invoice_order_id
ON invoice (order_id);
Vorher prüfen:
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:
Phase 1: Expand
- payment_status nullable hinzufügen
- outbox_event hinzufügen
- processed_message hinzufügen
- Indizes hinzufügen
Phase 2: Backfill
- payment_status aus altem status befüllen
- Datenqualität prüfen
- doppelte invoices prüfen
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
Phase 4: Shadow Read
- neue Komponenten lesen payment_status
- alte Komponenten lesen weiter status
- Abweichungen loggen
Phase 5: Cutover
- Payment Flow auf neuen Status umstellen
- Invoice nur auf OrderPaid triggern
- direkte OrderCreated-Verarbeitung abschalten
Phase 6: Contract
- alte status-Spalte erst entfernen, wenn keine Anwendung sie liest
- alte Trigger/Procedures nur nach Nutzungsnachweis entfernen
Cutover-Checkliste:
| 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:
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:
# 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
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