1. Einleitung
Ein anfänglicher Satz von Migrationen ist der Moment, in dem ein Projekt aufhört, „funktioniert bei mir“ zu sein, und beginnt, reproduzierbar zu arbeiten. Sie haben bereits Entitäten, Repositories, Abfragen, und irgendwo im Docker-Volume lebt eine Datenbank mit Tabellen, die Hibernate irgendwann erstellt hat. Aber wenn Sie morgen das Volume löschen, oder ein Kollege das Projekt zum ersten Mal startet, oder CI eine saubere Datenbank hochfährt, dann befinden Sie sich ohne Migrationen plötzlich in einer Welt von „na ja… versuch es zu starten und bete“.
Aus technischer Sicht macht der anfängliche Satz von Migrationen eine einfache Sache: Er kann ein konsistentes Schema von Grund auf hochziehen. Nicht „ungefähr so“, nicht „ähnlich dem, was ich in pgAdmin habe“, sondern genau das, das der aktuellen Codeversion entspricht. Daher umfasst der anfängliche Satz nicht nur Tabellen, sondern auch Schlüssel, Fremdschlüssel, UNIQUE, NOT NULL und Indizes, die logisch aus unseren Leseszenarien folgen. Und wenn Sie fragen wollen: „Warum kann man nicht nur die Tabellen lassen und den Rest später?“, dann ist die Antwort einfach: Weil „später“ normalerweise nie kommt, und die Daten sofort kaputtgehen.
Hier ist es wichtig, zwei verschiedene Geschichten nicht zu verwechseln: die Geschichte, wie das Projekt zuvor gewachsen ist, und die Migrationsgeschichte, die es erst jetzt erhält. Bis zu diesem Moment lebte shop-data-jpa auf einem lokalen Schema und ddl-auto, daher gab es noch keine ehrlichen V1, V2, V3. Jetzt fixieren wir das Projekt zum ersten Mal in Flyway, und dieser Satz beschreibt den aktuellen konsistenten Zustand der Datenbank und erzählt nicht Schritt für Schritt die gesamte bisherige Lebensgeschichte des Modells nach.
2. Zusammensetzung des Schemas mini-shop
Wenn Sie zum ersten Mal V1__init_schema.sql schreiben, spielt Ihr Anfängergehirn gerne das Spiel „tu so, als ob alles einfach wäre“. Es denkt: „Nun, es gibt eine Tabelle product, eine Tabelle category, los geht’s.“ Und dann stellt sich plötzlich heraus, dass eine Bestellung ohne Positionen irgendwie gespeichert wird, sku dupliziert wird und order_item auf eine nicht existierende order verweist – und all diese Seltsamkeiten sind kein Fehler von Spring, sondern das Ergebnis Ihres Schemas.
Um das Bild im Kopf zu behalten, ist es hilfreich, für einen Moment zur Domain shop-data-jpa zurückzukehren: Wir haben einen Katalog (Kategorien und Produkte), Bestände (Mengenverwaltung im Lager) und Bestellungen (Bestellkopf und Positionen). In Tabellenbegriffen ergibt sich etwa dieser Abhängigkeitsgraph: category → product → (one-to-one Tabellen product_details, stock_item), und der Bestellungsteil ist customer_order → order_item, wobei order_item zusätzlich auf product verweist. Genau diese Reihenfolge bestimmt, welche Tabellen wir früher und welche später erstellen müssen.
Anschaulich lässt sich das so darstellen:
erDiagram
CATEGORY ||--o{ PRODUCT : contains
PRODUCT ||--|| PRODUCT_DETAILS : has
PRODUCT ||--|| STOCK_ITEM : has
CUSTOMER_ORDER ||--o{ ORDER_ITEM : includes
PRODUCT ||--o{ ORDER_ITEM : ordered
Wenn Sie das Gefühl haben „ja, es ist irgendwie klar“, ist das ein großartiger Moment: Genau jetzt sollte man das in einer Migration festhalten, solange die Klarheit nicht in hunderten Commits verschwunden ist.
3. Aufteilung in Migrationen
Es besteht die Versuchung, eine riesige V1__init_all.sql zu erstellen, in die man alles stopft: Tabellen, Schlüssel, Indizes, Einfügen von Referenzdaten. Das würde funktionieren, aber eine solche Datei einen Monat später zu warten, ist eine Aufgabe für Leute, die Schmerz verdächtig lieben. Viel bequemer ist es, den anfänglichen Satz in mehrere Schritte aufzuteilen, von denen jeder für eine Kategorie von Änderungen verantwortlich ist.
Für ein Schulungsprojekt (und oft auch für ein kommerzielles) ist es praktisch, mit einem solchen Satz zu beginnen:
| Migration | Bedeutung | Inhalt |
|---|---|---|
| V1__init_schema.sql | Struktur | Tabellen, PK, FK, UNIQUE, NOT NULL |
| V2__init_indexes.sql | Leseleistung | Indizes für unsere Leseszenarien |
| V3__seed_reference_data.sql | Anfängliche Referenzdaten | minimale INSERT in Referenztabellen (z.B. Kategorien) |
Warum ist das gut? Weil Sie die Geschichte wie ein Buch lesen können: Zuerst erstellen wir das „Skelett“ des Schemas, dann fügen wir Beschleuniger (Indizes) hinzu, dann legen wir die „minimalen Bausteine“ ab, die das System sofort benötigt. Und das Beste: Wenn Sie später neue Indizes hinzufügen möchten, ist das eine neue Versionsdatei, kein Herumdoktern an V1.
4. V1__init_schema.sql: Tabellen und Beziehungen
V1__init_schema.sql ist Ihr neues „Skelett“ der Datenbank. Es sollte vollständig genug sein, damit die Anwendung leben kann, aber einfach genug, um es ohne Psychotherapeuten lesen zu können. Wir bauen noch keine „ideale Datenbank für die Ewigkeit“, wir fixieren das aktuelle konsistente Modell: das, was bereits in den Entitäten vorhanden ist und bereits von den Services verwendet wird.
Es ist sehr wichtig, die Reihenfolge der Tabellenerstellung zu beachten. Ein Fremdschlüssel ist eine hartnäckige Sache: Er erlaubt es nicht, auf eine noch nicht existierende Tabelle zu verweisen. Daher gehen wir normalerweise von den Parent-Tabellen zu den Child-Tabellen: zuerst category, dann product, dann die one-to-one Tabellen, dann customer_order, und erst danach order_item. Und noch etwas: Es ist besser, sich gleich auf einen Namensstil zu einigen. Zum Beispiel Tabellen und Spalten – snake_case, FK-Namen – fk_<from>_<to>, Unique – uq_<table>_<column>, Indizes – idx_<table>_<column>.
Unten ein kleines Gerüst für V1, das Sie auf den genauen Zustand Ihres Projekts erweitern können.
create table category (
id bigint generated by default as identity primary key, -- technischer PK
code varchar(64) not null, -- Domänencode der Kategorie (eindeutig)
name varchar(255) not null, -- menschenlesbarer Name
description varchar(1000), -- optionale Beschreibung
active boolean not null, -- Aktivitätskennzeichen (wichtig: NOT NULL nicht vergessen)
constraint uq_category_code unique (code) -- Duplizierung von code verbieten
);
Hier fixieren wir sofort, dass code eindeutig ist und name und active obligatorisch sind. In Java wird dies normalerweise durch @Column(nullable = false) und unique = true abgebildet, aber nun lebt die „Wahrheit“ in SQL.
Katalog: product und Beziehung zu category
Der Katalog ist der Ort, an dem ORM normalerweise „am einfachsten“ aussieht: Kategorie, Produkt, Many-to-one-Beziehung. Aber genau hier entstehen oft die ersten Schemafehler, weil man es „schnell“ machen möchte. Zum Beispiel hat man NOT NULL auf category_id vergessen, und plötzlich tauchen Produkte ohne Kategorie auf. Im Code würden Sie das nicht wollen, aber die Datenbank, wenn man es nicht verbietet, wird geduldig lächeln und speichern.
In product fixieren wir die Schlüsselfelder: sku (eindeutig), name, status (String für Enum), price (Geldtyp via numeric) und den Fremdschlüssel auf category. Beachten Sie: numeric(12,2) für den Preis ist der Kompromiss, der in den meisten Projekten gut funktioniert. Es ist nicht die einzige Option, aber für den Kurs ist er verständlich und praktisch.
create table product (
id bigint generated by default as identity primary key, -- technischer PK
sku varchar(64) not null, -- Artikelnummer/SKU: obligatorisch und eindeutig
name varchar(255) not null, -- Produktname
status varchar(32) not null, -- String für Enum (z.B. ACTIVE/INACTIVE)
price numeric(12, 2) not null, -- Geldbetrag: Genauigkeit wichtiger als float/double
category_id bigint not null, -- FK auf category: Produkte ohne Kategorie verboten
constraint uq_product_sku unique (sku), -- Verbot von SKU-Duplikaten (und der Index erscheint automatisch)
constraint fk_product_category
foreign key (category_id) references category(id) -- many-to-one Beziehung in DB-Terminologie
);
Wenn Sie sich an unsere @ManyToOne und @JoinColumn(name = "category_id") erinnern, dann ist das genau dieselbe Beziehung, nur in SQL-Form. JPA-Annotationen erzeugen keine Beziehung „im luftleeren Raum“, sie beschreiben, was im relationalen Modell existieren soll.
One-to-one: product_details und stock_item
Eine Eins-zu-Eins-Beziehung ist sehr praktisch für die Demonstration (und bei einem Schulungsprojekt gerechtfertigt), aber das Schema dafür muss sorgfältig sein. Es ist wichtig, zwei Regeln festzulegen: Erstens kann ein Eintrag product_details nicht ohne Produkt existieren, und zweitens darf ein Produkt nicht zwei verschiedene product_details haben. In SQL bedeutet das einen Fremdschlüssel auf product(id) und die Eindeutigkeit von product_id in der Details-Tabelle.
Aus denselben Gründen machen wir stock_item: es ist eine separate Tabelle, eins-zu-eins an das Produkt gebunden, und die Menge muss obligatorisch sein. Wir führen noch keine version ein (Optimistic Locking kommt später), daher enthält stock_item nur Zahlen.
create table product_details (
id bigint generated by default as identity primary key, -- technischer PK
product_id bigint not null, -- FK auf product
description varchar(2000), -- Beschreibung (optional)
manufacturer varchar(255), -- Hersteller (optional)
warranty_months integer, -- Garantie in Monaten (optional)
constraint uq_product_details_product unique (product_id), -- one-to-one: ein Details-Eintrag pro product
constraint fk_product_details_product
foreign key (product_id) references product(id) -- details können nicht ohne product existieren
);
create table stock_item (
id bigint generated by default as identity primary key, -- technischer PK
product_id bigint not null, -- FK auf product
available_quantity integer not null, -- verfügbare Menge im Lager
reserved_quantity integer not null, -- reservierte Menge
constraint uq_stock_item_product unique (product_id), -- one-to-one: ein Stock-Eintrag pro product
constraint fk_stock_item_product
foreign key (product_id) references product(id) -- stock_item kann nicht ohne product existieren
);
Hier kann man eine „Unvollkommenheit“ bemerken: Wir haben noch keine negativen Mengen verboten. Das ist kein Fehler – wir halten die Komplexität einfach im Rahmen des Tages. Am nächsten Tag über Constraints werden wir genau darüber sprechen, dass ein Teil der Invarianten in der Datenbank leben sollte.
Bestellungen: customer_order und Embedded DeliveryAddress
Im Bestellungsteil ist der häufigste „Anfänger“-Bug: „Ich habe customer_order erstellt, aber vergessen, dass DeliveryAddress keine separate Tabelle ist, sondern ein @Embeddable, und ich brauche Spalten direkt in customer_order.“ In Java sehen Sie deliveryAddress.city, aber in der Datenbank sind es trotzdem Spalten – nur mit einem Präfix.
Plus ist es wichtig, an das Wort order zu denken: In SQL ist es oft reserviert. Daher verwenden wir customer_order als Tabellennamen. Und wir fixieren sofort die Eindeutigkeit von order_number, die Obligatorik von Email und Status. Geldbeträge ebenfalls numeric(12,2).
create table customer_order (
id bigint generated by default as identity primary key, -- technischer PK
order_number varchar(64) not null, -- öffentliche Bestellnummer (eindeutig)
status varchar(32) not null, -- Bestellstatus (String für Enum)
customer_email varchar(320) not null, -- Kunden-Email
delivery_city varchar(128) not null, -- DeliveryAddress.city (embedded in diese Tabelle)
delivery_street varchar(255) not null, -- DeliveryAddress.street
delivery_house varchar(32) not null, -- DeliveryAddress.house
delivery_postal_code varchar(32) not null, -- DeliveryAddress.postalCode
total_amount numeric(12, 2) not null, -- Gesamtbetrag der Bestellung
constraint uq_customer_order_number unique (order_number) -- verbietet zwei Bestellungen mit derselben Nummer
);
Ja, varchar(320) für Email sieht aus wie „zu schlau“ – aber es ist einfach eine angenäherte obere Grenze nach RFC-ähnlicher Logik. In einem Schulungsprojekt kann man auch weniger nehmen, aber wichtig ist, dass Sie die Größe bewusst wählen, nicht „irgendwie so“ lassen.
Bestellpositionen: order_item als Child-Tabelle
order_item ist ein klassisches Child-Objekt: Es gehört zur Bestellung und verweist auf das Produkt. Hier sehen Sie in SQL dasselbe wie in JPA: ManyToOne zur Bestellung und zum Produkt. Und hier fixieren wir auch die Felder des Preis-Snapshots: unit_price und line_total. Selbst wenn sich der Produktpreis im Katalog ändert, muss die Bestellposition historisch korrekt bleiben.
create table order_item (
id bigint generated by default as identity primary key, -- technischer PK
order_id bigint not null, -- FK auf customer_order
product_id bigint not null, -- FK auf product
quantity integer not null, -- Menge in der Bestellposition
unit_price numeric(12, 2) not null, -- Preis pro Einheit zum Zeitpunkt der Bestellung (Snapshot)
line_total numeric(12, 2) not null, -- Zeilenbetrag (normalerweise quantity * unit_price)
constraint fk_order_item_order
foreign key (order_id) references customer_order(id), -- child -> parent (Position gehört zur Bestellung)
constraint fk_order_item_product
foreign key (product_id) references product(id) -- Position verweist auf Katalogprodukt
);
Hier kann man wieder fragen: „Warum gibt es kein Verbot von quantity <= 0?“ Weil wir bisher nur das Gerüst fixieren. Einschränkungen der Ebene „Menge größer Null“ sind genau das, was man in nachfolgenden Migrationen bequem hinzufügen kann, wenn wir bereits die grundlegende Disziplin von Flyway und ein Verständnis der Schemaentwicklung haben.
5. V2__init_indexes.sql: Indizes
Indizes sind der Teil des Schemas, an den Anfänger oft erst nach dem Satz „irgendwie ist es langsam“ denken. In einem Schulungsprojekt tritt „langsam“ normalerweise nicht auf (es gibt wenig Daten), und das ist tückisch: Sie gewöhnen sich daran, dass Indizes unwichtig sind. Aber dann landen Sie in einem Projekt, in dem die Tabelle product 5 Millionen Zeilen hat, und plötzlich beginnen Sie, idx_product_category_id fast wie einen Verwandten zu respektieren.
Die Hauptidee: Ein Index sollte dort gesetzt werden, wo Sie ein stabiles Leseszenario haben. Wir wissen bereits, dass wir nach Produkten nach Name, nach Kategorie, nach sku suchen werden, und wir werden Bestellpositionen nach order_id lesen. Ein Teil der Indizes wird „automatisch“ durch UNIQUE entstehen (PostgreSQL erstellt einen Index unter Eindeutigkeit), aber nicht alle. Deshalb machen wir eine separate Migration für die Indizierung.
create index idx_product_category_id on product(category_id); -- häufige Filterung/Join nach Kategorie
create index idx_product_name on product(name); -- Suche/Sortierung nach Name (falls es solche Abfragen gibt)
create index idx_order_item_order_id on order_item(order_id); -- schnelles Abrufen von Bestellpositionen nach order_id
create index idx_order_item_product_id on order_item(product_id); -- Join/Aggregationen nach Produkt (z.B. Statistiken)
Wenn Sie es etwas erweitern möchten, können Sie einen Index auf product(status) hinzufügen (falls Sie Filter nach Status haben) oder einen Index auf customer_order(customer_email) (falls Sie häufig Bestellungen nach Email suchen). Aber behalten Sie das Ziel im Kopf: Indizes sind keine Briefmarkensammlung, sondern eine Reflexion realer Abfragen.
6. V3__seed_reference_data.sql: Referenzdaten
Referenzdaten sind ein sehr heikles Thema. Einerseits ist es praktisch, wenn das Projekt startet und sofort z.B. ein paar Kategorien hat. Andererseits kann man leicht in „Migrationen als Testdatengenerator“ abrutschen, und dann beginnt V3__seed.sql, 200 Produkte, 50 Bestellungen und eine Katze namens Migrationschik einzufügen (die Katze ist nicht schuld, aber so ist es manchmal).
Eine gute Regel: Referenzdaten in Migrationen sind das, ohne das das System nicht normal startet oder das als Teil der „Grundkonfiguration“ der Domäne gilt. In unserem mini-shop könnten das ein paar Kategorien sein. Wir geben id nicht von Hand an – lassen wir identity seine Arbeit machen. Und denken Sie unbedingt an die Pflichtfelder (active bei der Kategorie zum Beispiel).
-- Minimaler Satz von Kategorien, damit die Anwendung auf einer sauberen Datenbank "lebendig wird"
insert into category(code, name, description, active)
values ('BOOKS', 'Books', 'All kinds of books', true), -- Bücher
('HOME', 'Home', 'Home goods', true), -- Haushaltswaren
('SPORT', 'Sport', 'Sport goods', true); -- Sport
Diese Datei wird einmal auf einer sauberen Datenbank ausgeführt. Wenn Sie Angst vor wiederholten Ausführungen haben, denken Sie daran: Flyway wird eine versionierte Migration nicht erneut anwenden, wenn sie bereits in der Geschichte festgehalten ist. Das heißt, wir müssen kein on conflict do nothing „für den Fall der Fälle“ machen – meistens ist das nur zusätzliches Rauschen.
7. Typische Fehler beim Umgang mit Migrationen
Fehler Nr. 1: Die Startmigration „aus dem Gedächtnis“ erstellen, nicht nach dem aktuellen konsistenten Modell des Projekts.
Es ist sehr einfach, pgAdmin zu öffnen, zu schauen, was derzeit in der Datenbank ist, und das in V1 zu übernehmen. Das Problem ist, dass Ihre lokale Datenbank zufällige Spuren von Experimenten angesammelt haben könnte: eine Spalte „zum Ausprobieren“, einen vergessenen Index, eine Tabelle tag, die Sie später aus dem Code entfernt haben. Der anfängliche Migrationssatz sollte nicht Ihre „historische“ Datenbank widerspiegeln, sondern die aktuelle Version des Projekts.
Fehler Nr. 2: Fremdschlüssel und Eindeutigkeiten vergessen, nur Tabellen hinterlassen.
Das Projekt kann sogar starten, und das ist das Heimtückischste. Aber Sie öffnen sofort die Tür zu unmöglichen Zuständen: Bestellung ohne Positionen, zwei Produkte mit derselben sku, Bestellposition ohne Bestellung. Wenn wir uns schon entschieden haben, dass das Schema ein Artefakt des Projekts ist, dann müssen die Invarianten von Anfang an dort leben, zumindest die grundlegenden NOT NULL, UNIQUE und FK.
Fehler Nr. 3: Die Reihenfolge der Tabellenerstellung verwechseln und einen FK-Fehler erhalten.
Wenn Sie product erstellen, aber category noch nicht existiert, wird der FK einfach nicht erstellt. In bester Tradition passiert das „am Ende“, wenn Sie bereits sicher sind, dass alles fertig ist. Die Lösung ist einfach: Beginnen Sie immer mit den Parent-Tabellen (category, product, customer_order) und erstellen Sie dann die Child-Tabellen (product_details, stock_item, order_item).
Fehler Nr. 4: Schema, Indizes und Seed-Daten in eine riesige Datei werfen.
Das scheint Zeit zu sparen, verwandelt sich aber nach ein paar Wochen in „Ich habe Angst, das anzufassen, falls es explodiert“. Die Aufteilung in V1 (Struktur), V2 (Indizes), V3 (Referenzdaten) ist keine Religion, sondern banale Lesbarkeit. Es ist einfacher, mit den Augen zu sehen, was und warum sich geändert hat, und damit das Projekt zu warten.
Fehler Nr. 5: Referenzdaten als „Demodaten mit tausend Zeilen“ einfügen.
Referenzdaten sind das Minimum, das das System benötigt. Sobald Sie beginnen, dutzende Produkte und hunderte Bestellungen in eine Migration zu stopfen, verwandeln Sie das Schema in etwas, das schwer zu warten und zu erklären ist. Für Testdaten gibt es Tests, Fixtures, separate Skripte – Migrationen sollten kurz und domänenbegründet bleiben.
GO TO FULL VERSION