1. Link-Entity: Das richtige Modell
Wenn ein Entwickler zum ersten Mal hört: „Lass uns die Verbindungstabelle in eine separate Entität auslagern“, reagiert das Gehirn manchmal wie auf die Aufforderung, 300 Zeilen YAML-Konfiguration manuell umzuschreiben. Aber jetzt machen wir das System nicht komplizierter, sondern hören auf, das reale Modellobjekt zu verstecken. Wenn eine Beziehung eigene Felder, Zeitpunkte und Regeln hat, braucht sie eine eigene Klasse.
ProductCategoryAssignment ist kein technischer Müll. Es ist eine eigenständige Tatsache der Fachdomäne: Ein Produkt ist einer Kategorie zugeordnet. Also denken wir nicht mehr „das Produkt hat einfach eine Liste von Kategorien“, sondern „das Produkt hat eine Menge von Zuordnungen, und jede Zuordnung verweist auf eine bestimmte Kategorie“.
Wenn im Vertrag Bedingungen auftauchen, ist es sinnvoll, ein Dokument anzulegen, statt die Bedingungen mit Bleistift auf den Rand zweier verschiedener Verträge zu schreiben.
Schema Product ↔ Assignment ↔ Category
Bevor wir Annotationen schreiben, ist es nützlich, einen Moment innezuhalten und sich zu überlegen, welchen Objektgraphen wir im Speicher der Anwendung sehen möchten. Bei @ManyToMany dachten wir so: Das Produkt hat eine Liste von Kategorien. Im Link-Entity-Modell denken wir anders: Das Produkt hat eine Liste von Zuordnungen (assignments), und jede Zuordnung verweist auf eine bestimmte Kategorie. Das ist eine kleine Umstellung der Denkweise, aber sie zahlt sich sehr schnell aus.
Unten sehen Sie ein Schema, das den Unterschied zwischen „einer Collection“ und „expliziter Beziehungsentität“ zeigt:
flowchart LR
P[Product] -- "1..*" --> A[ProductCategoryAssignment]
A -- "*..1" --> C[Category]
P ---|"categoryAssignments"| A
C ---|"productAssignments"| A
Ein wichtiger Punkt: In diesem Modell ist das Assignment das Zentrum der Beziehungssteuerung, denn es enthält die beiden Fremdschlüssel (product_id, category_id). Also wird es zur besitzenden Seite für beide @ManyToOne-Referenzen. Product und Category werden @OneToMany(mappedBy = ...) zur Navigation haben, aber das eigentliche Schreiben der Fremdschlüssel erfolgt über das Assignment.
Um bei den Namen nicht durcheinanderzukommen, legen wir gleich die Begriffe im Code fest. Auf Product sollte die Collection etwa categoryAssignments heißen, nicht categories, denn darin liegen keine Kategorien, sondern Zuordnungsobjekte. Auf Category ist es sinnvoll, productAssignments zu haben, um bei Bedarf sehen zu können, „welche Produkte dieser Kategorie zugeordnet sind“ – wiederum über Assignments.
3. Entität ProductCategoryAssignment
Jetzt machen wir den wichtigsten Schritt des Tages: Wir verwandeln die versteckte Join-Tabelle in eine vollwertige JPA-Entity. Zuerst bauen wir das minimale Grundgerüst: ohne zusätzliche Verhaltenslogik, aber bereits mit einer ehrlichen Beziehungstabelle, zwei @ManyToOne-Referenzen und einem eigenen Objekt im Speicher.
Praktisch bedeutet das, dass wir eine Klasse ProductCategoryAssignment mit @Entity, eine Tabelle in der Datenbank und zwei @ManyToOne-Referenzen bekommen. Wir versuchen nicht, „sofort alles perfekt zu machen“ – eine minimal korrekte Struktur reicht, auf die wir später aufbauen können.
Minimales Grundgerüst: @Entity, @Table, id
Beginnen wir damit, dass jede JPA-Entity eine ID haben muss. Ja, eine reine Join-Tabelle kommt oft ohne Surrogate-ID aus und verwendet einen zusammengesetzten Schlüssel (product_id, category_id). Aber in einem Lehrprojekt (und auch in realen Projekten recht häufig) vereinfacht das Hinzufügen von id das Leben: Es ist einfacher, auf eine bestimmte Zuordnung zu verweisen, eine bestimmte Beziehungszeile zu löschen, und die Arbeit in Logs und Debugging wird leichter.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "product_category_assignment")
public class ProductCategoryAssignment {
@Id
@GeneratedValue
// Surrogate-ID: vereinfacht Referenzen, Debugging und gezieltes Löschen einer bestimmten Zuordnung
private Long id;
}
Beachte das jakarta.persistence.*: In Spring Boot 4 leben wir in der Jakarta-Welt, und javax.persistence ist bereits Vergangenheit (wie DVD-Discs: irgendwo tauchen sie noch auf, aber man sollte keine Sammlung beginnen).
Zwei @ManyToOne-Referenzen: product und category
Jetzt fügen wir das Wesentliche hinzu. Das Assignment muss sowohl auf das Produkt als auch auf die Kategorie verweisen. Diese Felder bilden die Fremdschlüssel in der Tabelle product_category_assignment. Hier ist es sehr nützlich, sich an das Modul über Fetching zu erinnern: @ManyToOne ist standardmäßig EAGER, und wenn wir den Standard belassen, wird die Kategoriezuordnung anfangen, sowohl das Produkt als auch die Kategorie ohne Anfrage nachzuladen. Nach den Tagen über N+1 und Fetch-Design klingt das bereits nach einer schlechten Idee, also setzen wir bewusst LAZY.
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
// Besitzende Seite der Beziehung zu Product: dieses Feld schreibt product_id in die Assignment-Tabelle
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false) // nullable=false: Schutz auf DB-Ebene
private Product product;
// Besitzende Seite der Beziehung zu Category: dieses Feld schreibt category_id in die Assignment-Tabelle
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id", nullable = false) // nullable=false: Schutz auf DB-Ebene
private Category category;
Hier gibt es zwei Ebenen der „Kontrolle des gesunden Menschenverstandes“. optional = false ist unser Signal an das ORM: „Ein Assignment ohne Produkt oder ohne Kategorie ist sinnlos“. Und nullable = false auf @JoinColumn ist eine Bitte an das DB-Schema: „Erlaube nicht, beschädigte Beziehungen zu speichern“. Im Deep-Dive-Kurs haben wir stets zwei Wahrheiten im Kopf: Das Modell muss im Speicher korrekt sein, und das Schema muss die Korrektheit auf Datenebene unterstützen.
Beziehungsfelder: sortOrder und assignedAt
Wir werden diese Lektion jetzt nicht in eine Lektion 4 über Invarianten verwandeln, aber es ist wichtig, bereits auf Klassenebene zu zeigen, dass die Beziehung nicht leer ist. Daher fügen wir die Felder sortOrder und assignedAt hinzu. Auch wenn wir sie jetzt noch nicht perfekt validieren, diszipliniert allein die Tatsache, dass diese Felder hier leben, das Modell.
import jakarta.persistence.Column;
import java.time.Instant;
// Reihenfolge der Kategorie innerhalb des Produkts (z.B. für die Katalogausgabe)
@Column(name = "sort_order", nullable = false)
private int sortOrder;
// Zeitpunkt, zu dem die Zuordnung erstellt wurde (nicht wann man "das Produkt zu aktualisieren gedacht hat")
@Column(name = "assigned_at", nullable = false)
private Instant assignedAt;
Wenn du dich fragst, warum Instant und nicht LocalDateTime, dann bist du auf dem richtigen Weg. Instant ist ein Zeitpunkt in UTC, und in den meisten Backend-Szenarien ist er einfacher und ehrlicher. Aber wir wollen jetzt nicht in einen separaten Kurs „Zeitzonen gegen Entwickler“ abtauchen: Es reicht, dass Instant gut zum „Zeitpunkt der Zuordnung“ passt.
JPA-Konstruktor und „normaler“ Konstruktor
JPA erzeugt Objekte gerne per Reflection, daher braucht eine Entity normalerweise einen parameterlosen Konstruktor. Wir machen ihn protected, damit kein zufälliger Code halbleere Assignment-Objekte „einfach so“ erzeugt. Die Felder product, category, sortOrder und assignedAt wurden oben bereits deklariert; hier ist es wichtig festzuhalten, wie ein Assignment als eigenständiges Objekt überhaupt entsteht.
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import java.time.Instant;
@Entity
public class ProductCategoryAssignment {
@Id
@GeneratedValue
private Long id;
// Konstruktor für JPA: ORM erzeugt Objekt per Reflection
protected ProductCategoryAssignment() {
}
// Minimaler Konstruktor: baut die Beziehung vollständig ohne Guard-Validierung
public ProductCategoryAssignment(Product product,
Category category,
int sortOrder,
Instant assignedAt) {
this.product = product;
this.category = category;
this.sortOrder = sortOrder;
this.assignedAt = assignedAt;
}
}
An diesem Punkt reicht es, dass das Assignment als separate Entität erstellt wird, nicht als Nebeneffekt einer Kategorieliste.
4. Aktualisieren von Product: categoryAssignments
Die Beziehungsentität allein ist nützlich, aber sie muss in die Navigation des Domänenmodells eingebaut werden. In der Praxis bedeutet das: Product hat nicht mehr List<Category> categories. Stattdessen erscheint List<ProductCategoryAssignment> categoryAssignments. Das ist der Moment, in dem viele zum ersten Mal ein leichtes Unbehagen verspüren: „Wie bekomme ich jetzt die Liste der Kategorien?“ Keine Sorge. Du wirst sie bekommen. Nur jetzt auf einem ehrlichen Weg: über die Assignments, die die Quelle der Wahrheit sind.
@OneToMany(mappedBy="product"): Navigation + Lifecycle
Aus Mapping-Sicht ist die Logik so: Das Assignment enthält das Feld product und ist die besitzende Seite. Also erzeugen wir auf Product eine Collection, die sagt: „Ich bin die inverse Seite, ich werde über ProductCategoryAssignment.product aktualisiert“. Das wird genau durch mappedBy = "product" ausgedrückt.
import jakarta.persistence.CascadeType;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@OneToMany(
mappedBy = "product", // Inverse Seite, FK wird in assignment.product gespeichert
cascade = CascadeType.ALL, // Produkt verwaltet den Lebenszyklus seiner Zuordnungen
orphanRemoval = true // Zuordnung aus Collection entfernt -> Beziehungszeile gelöscht
)
private List<ProductCategoryAssignment> categoryAssignments = new ArrayList<>();
Hier gibt es zwei wichtige Entscheidungen, die beide auf der vorherigen Ebene aufbauen. Wir setzen cascade = ALL, weil das Assignment in unserem Szenario der Katalogverwaltung wirklich eine „Kind“-Entität des Produkts ist: Das Produkt verwaltet seine Menge an Zuordnungen. Und wir setzen orphanRemoval = true, weil das Entfernen eines Assignments aus der Produkt-Collection das Löschen der Beziehungszeile aus der Tabelle bedeuten soll. Beachte: Das löscht das Assignment, aber nicht die Kategorie. Die Kategorie ist eine separate referenzielle Entität, sie gehört nicht zum Produkt.
Disziplin: vorerst ohne „bequeme“ Kategorieliste
Man möchte am liebsten in Product eine Methode wie getCategories() schreiben und categoryAssignments.stream().map(a -> a.getCategory()).toList() zurückgeben. Das ist im Grunde in Ordnung, aber heute ist es wichtig, Disziplin zu wahren: Im Modell sind jetzt die Assignments primär. Wenn wir sie jetzt sofort wieder hinter einer „bequemen“ Kategorieliste verstecken, riskieren wir, zur alten Gewohnheit zurückzukehren: Die Beziehung über die Kategorieliste zu ändern und die Kontrolle über sortOrder und assignedAt wieder zu verlieren.
Daher halten wir auf Feldebene nur categoryAssignments. Die „bequemen“ Zugriffsmethoden werden wir später und sorgfältig implementieren, wenn wir entsprechende Helper-Methoden zur Beziehungsverwaltung haben. Das ist wie eine Renovierung der Wohnung: zuerst die Elektrik erneuern, dann die Tapeten schön kleben. Wenn man es umgekehrt macht, sieht es nur bis zum ersten Kurzschluss schön aus.
5. Aktualisieren von Category: productAssignments
Jetzt der symmetrische Schritt: Auf der Seite von Category möchten wir ebenfalls die Assignments sehen, die auf diese Kategorie verweisen. Aber hier ändert sich die Bedeutung. Die Kategorie ist ein Nachschlagewerk, und Assignments „kommen und gehen“, aber die Kategorie sollte nicht versuchen, kaskadierend fremde Produkte oder fremde Beziehungen ohne Notwendigkeit zu verwalten. Daher reicht hier in der Regel eine einfache @OneToMany(mappedBy="category")-Collection für die Navigation.
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@OneToMany(mappedBy = "category") // Inverse Seite, FK wird in assignment.category gespeichert
private List<ProductCategoryAssignment> productAssignments = new ArrayList<>();
Beachte: Standardmäßig kein cascade = ALL und kein orphanRemoval hier. Wir wollen nicht die Situation, dass jemand ein Assignment aus category.productAssignments entfernt und Hibernate entscheidet: „Aha, orphanRemoval – also löschen“ und plötzlich Beziehungen von der Seite aus ändert, die in unserem Szenario nicht der Hauptcontroller sein soll.
Aus praktischer Sicht ist die Regel sehr einfach: Das Produkt verwaltet „welche Kategorien ich habe“, und die Kategorie wird hauptsächlich als Nachschlagewerk und Navigationspunkt benötigt. In einem realen Projekt kann es andere Szenarien geben, aber in unserem Commerce Persistence Lab legen wir genau dieses fest, weil es den Lifecycle-Gedanken gut zeigt, den wir trainieren.
6. Tabelle und Flyway-Migration
Wenn wir Annotationen malen, vergisst man leicht, dass Hibernate kein Zauberer ist, der aus dem Nichts ein Schema erzeugt (zumindest sollte er das in unserem Kurs nicht tun). Unser Schema wird durch Flyway-Migrationen gesteuert, also brauchen wir für die neue Entität eine Tabelle.
Unten ist eine minimale DDL-Version. Sie versucht nicht, alle zukünftigen Invarianten zu berücksichtigen, aber sie erzeugt das, was für das Leben der Entity nötig ist: id, zwei FK und zwei Beziehungsfelder.
create table product_category_assignment (
id bigserial primary key, -- Surrogate-ID für bequeme Arbeit mit der Zuordnung
product_id bigint not null references product(id), -- FK auf das Produkt
category_id bigint not null references category(id), -- FK auf die Kategorie
sort_order integer not null, -- Anzeigereihenfolge innerhalb des Produkts
assigned_at timestamp with time zone not null -- Zeitpunkt der Zuordnung (UTC-Moment)
);
Wenn du sofort eine Eindeutigkeit für das Paar (product_id, category_id) hinzufügen möchtest – der Wunsch ist berechtigt, wir werden das nur etwas später als separate Invariante festlegen. Jetzt ist es wichtig, dass die Mechanik der Link-Entity funktioniert und wir sehen können, wie sich Navigation und ORM-Verhalten ändern.
Service-Code vor Helper-Methoden
Nach der Auslagerung der Link-Entity wird der Service-Code vorübergehend etwas „gesprächiger“. Das ist normal: Wir haben den versteckten Teil des Modells nach außen geholt, und jetzt muss der Code ihn benennen. In diesem Schritt ist es nützlich, die Mechanik direkt zu sehen, ohne sie durch zusätzliche Methoden zu verstecken.
Hier ist ein naiver (vorübergehender) Stil, der die Mechanik zeigt:
import java.time.Instant;
// Erstellen eine "Zuordnung", nicht nur "Kategorie zur Liste hinzufügen"
ProductCategoryAssignment a =
new ProductCategoryAssignment(product, category, 10, Instant.now());
// Solange keine Helper-Methoden existieren, müssen beide Seiten der Beziehung manuell synchronisiert werden
product.getCategoryAssignments().add(a);
category.getProductAssignments().add(a);
Dieses Codefragment erzeugt genau das Gefühl „na ja, jetzt sind es mehr Zeilen“. Aber betrachte es von der anderen Seite: Jetzt haben wir einen Ort, an dem wir die Regel „dieselbe Kategorie darf nicht zweimal zugeordnet werden“ setzen können, an dem wir sortOrder kontrollieren, den Zeitpunkt der Zuordnung festlegen und eine punktgenaue Löschung durchführen können (wir löschen das Assignment, statt die „gesamte Beziehungstabelle neu aufzubauen“).
Und sofort wird der nächste Schmerz sichtbar: Die Struktur selbst ist bereits ehrlich, aber der Code ist noch sehr manuell. Eine Seite der Beziehung vergessen – und der Graph im Speicher beginnt zu lügen.
Überprüfung durch flush() und SQL-Log
Wir sind bereits an das wichtigste Ritual des Deep-Dive-Kurses gewöhnt: Wenn wir etwas am Mapping geändert haben, müssen wir sehen, welches SQL an die DB geht. Sonst besteht die Gefahr, dass wir schönen Code geschrieben haben, während Hibernate sein eigenes Leben lebt und stillschweigend unnötige DELETE/INSERT macht und dir aus den Logs zuzwinkert.
Für eine schnelle Überprüfung reicht ein einfacher Service-Ausschnitt mit flush():
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
import java.time.Instant;
@Transactional
public void assignCategory(Product product, Category category, EntityManager em) {
// Erstellen ein separates Beziehungsobjekt (Link-Entity) – das ist die "Quelle der Wahrheit" für die Beziehung
ProductCategoryAssignment a =
new ProductCategoryAssignment(product, category, 10, Instant.now());
// Solange keine Helper-Methoden existieren, werden Lifecycle und beide Graphenseiten manuell verwaltet
product.getCategoryAssignments().add(a);
category.getProductAssignments().add(a);
// Erzwingt das Senden der Änderungen an die DB, um SQL sofort zu sehen
em.flush(); // hier erwarten wir INSERT in product_category_assignment
}
Wenn das SQL-Log eingerichtet ist, solltest du etwas in der Art sehen:
-- Erwartetes SQL: Hinzufügen einer neuen Zeile in die Zuordnungstabelle
insert into product_category_assignment
(product_id, category_id, sort_order, assigned_at, id)
values (?, ?, ?, ?, ?);
Und genau dieses Gefühl, „ich verstehe, welche Zeile ich hinzufüge“, ist einer der Hauptgewinne des Link-Entity-Ansatzes. Wir hören auf zu raten, was mit der Join-Tabelle passiert. Wir beginnen, sie wie normale Modelldaten zu verwalten.
SQL liest sich bereits besser als bei @ManyToMany, aber der Code fordert den nächsten Schritt: die manuelle Synchronisation in das Modell selbst zu verlagern.
7. Typische Fehler bei der Auslagerung von ProductCategoryAssignment
Fehler Nr. 1: Gleichzeitig @ManyToMany categories und categoryAssignments behalten.
Das ist ein fast garantierter Weg, eine Desynchronisation des Modells zu bekommen: Ein Teil des Codes ändert die Beziehung über die alte Collection, ein Teil über das Assignment, und am Ende hat Hibernate „zwei Wahrheiten“. Irgendwann siehst du zusätzliche DELETE/INSERT in der Beziehungstabelle und beginnst, eine Verschwörung zu vermuten. Es gibt keine Verschwörung – das Modell ist einfach zweiköpfig geworden, und zwei Köpfe haben Schwierigkeiten, sich zu einigen.
Fehler Nr. 2: Kaskaden auf der Seite von Category „nur für den Fall“ einschalten.
Manchmal neigt die Hand dazu, cascade = ALL überall zu setzen, damit „es auf jeden Fall gespeichert wird“. Bei Nachschlagewerken ist das gefährlich: Kategorien werden oft geteilt (eine Kategorie wird von vielen Produkten verwendet), und ein kaskadierender Lebenszyklus bildet hier in der Regel nicht die Realität ab. Es ist besser, einen expliziten Steuerungspunkt für Assignments zu haben (in unserem Szenario auf der Produktseite) als zwei konkurrierende.
Fehler Nr. 3: Vergessen, dass @ManyToOne standardmäßig EAGER ist.
Wenn man fetch = LAZY nicht angibt, kann man plötzlich ein unnötiges Nachladen von Product und Category bei der Arbeit mit Assignments bekommen, was besonders in Listen unangenehm ist. Wir haben Fetching bereits in Modul 2 behandelt, daher ist jetzt der richtige Zeitpunkt, das Wissen anzuwenden: Die JPA-Standardeinstellungen stimmen nicht immer mit deinen Performance-Erwartungen überein.
Fehler Nr. 4: Das Assignment-Objekt „halbleer“ machen und hoffen, dass Hibernate es schon versteht.
Manchmal wird ein Assignment erstellt, in die Collection gelegt, aber vergessen, product oder category zu setzen. Im Speicher sieht das aus wie „naja, wird schon irgendwie gespeichert“, aber in der DB führt es entweder zu einem NOT NULL-Fehler oder (wenn die Einschränkungen weich sind) zu Müllzeilen. Ein Assignment muss eine vollwertige Beziehungszeile sein, also braucht es beide Referenzen.
Fehler Nr. 5: Die Collections so benennen, als ob dort nicht Assignments, sondern die verknüpften Entitäten liegen.
Wenn in Product das Feld categories heißt, aber der Typ List<ProductCategoryAssignment> ist, führt das ständig in die Irre. An solchen Stellen entstehen Bugs nicht durch Hibernate, sondern durch ganz normale menschliche Ermüdung: Man liest Code, erwartet etwas anderes, und findet etwas anderes. Ein guter Name für die Collection ist die halbe Kontrolle über das Modell, besonders in einem Lehrprojekt, in dem wir die Vorhersagbarkeit des Verhaltens trainieren.
GO TO FULL VERSION