Zum Hauptinhalt springen

Beziehungen mit JPA abbilden

Das Infoblatt JPA und Hibernate erklärt, wie eine Klasse zu einer Tabelle wird. Hier geht es um den Schritt danach: Wie werden aus Verweisen zwischen Objekten Fremdschlüssel zwischen Tabellen?

Die Beispiele stammen bewusst nicht aus dem Webshop-Tutorial — dort siehst du sie in Aktion, hier zum Nachschlagen an anderen Fällen.

Die Grundfrage

In der Objektwelt zeigt ein Objekt auf ein anderes, indem es es festhält. In der Datenbank gibt es kein Festhalten — dort steht in einer Spalte die Nummer der anderen Zeile.

ObjektweltStudentid = 7mainCourseCourseid = 3Das Objekt hält das andere fest.Man kommt mit einem Punkt hin:student.getMainCourse()Datenbankstudentidmain_course_id73courseid3Die Spalte enthält eine Zahl.Man kommt nur mit einem JOIN hin —oder mit einer zweiten Abfrage.Die Annotation ist die Übersetzungsvorschrift zwischen beiden Bildern.

Der eine Begriff, ohne den nichts davon Sinn ergibt: die Besitzerseite

Bei einer zweiseitigen (bidirektionalen) Beziehung zeigen beide Klassen aufeinander. In der Datenbank gibt es aber nur eine Spalte mit dem Fremdschlüssel. JPA muss also wissen, welche der beiden Seiten diese Spalte beschreibt.

Diese Seite heißt Besitzerseite (owning side). Die andere ist die inverse Seite und wird mit mappedBy gekennzeichnet.

Besitzerseiteinverse Seite
Woran erkennbarhat @JoinColumn oder gar nichtshat mappedBy
Wo liegt der Fremdschlüsselhiernirgends — sie ist nur die Rückrichtung
Was beim Speichern zähltnur diese Seitewird beim Schreiben ignoriert
Bei 1:n / n:1 immerdie @ManyToOne-Seitedie @OneToMany-Seite
Die Fehlvorstellung, die am meisten Zeit kostet

„Ich habe die Liste beim Kurs gefüllt, also ist der Student jetzt in diesem Kurs."

Nein. Wenn du nur die inverse Seite füllst, schreibt JPA gar nichts. Nach dem nächsten Neustart ist die Zuordnung weg — ohne Fehlermeldung.

// Falsch: nur die inverse Seite gesetzt
course.getStudents().add(student);
courseRepository.save(course); // schreibt die Zuordnung NICHT

// Richtig: die Besitzerseite setzen
student.setMainCourse(course);
studentRepository.save(student); // schreibt die Spalte main_course_id

Der Grund ist der obige: main_course_id steht in der Tabelle student. Wer den Kurs speichert, fasst diese Spalte nicht an.

Merksatz: Der Fremdschlüssel liegt immer auf der Seite, die viele sein kann.

Weil beide Seiten trotzdem im Arbeitsspeicher zusammenpassen sollen, schreibt man bei bidirektionalen Beziehungen eine kleine Hilfsmethode:

// in Course
public void addStudent(Student student) {
students.add(student); // damit die Liste im Speicher stimmt
student.setMainCourse(this); // damit die Datenbank es erfährt
}

1:1 — genau eines auf jeder Seite

Beispiel: Ein Mitarbeiter hat genau einen Werksausweis, und ein Werksausweis gehört zu genau einem Mitarbeiter.

unidirektional — der Normalfall
@Entity
public class Employee {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@OneToOne
@JoinColumn(name = "badge_id")
private Badge badge;
}

Daraus entsteht in der Tabelle employee eine Spalte badge_id — mit UNIQUE-Bedingung. Genau die macht aus einer 1:n-Beziehung eine 1:1-Beziehung: Kein zweiter Mitarbeiter kann denselben Ausweis eintragen.

-- so sieht es in der Datenbank aus
employee ( id, name, badge_id )
badge_id → badge(id), UNIQUE (badge_id)
Nachgemessen

Lässt man @JoinColumn weg, macht Hibernate dasselbe und nennt die Spalte nach dem Feld — badge_id, samt UNIQUE-Bedingung. Die Annotation ist also nur nötig, wenn der Name anders lauten soll.

Die Varianten

VarianteWieWann
unidirektionalnur Employee kennt BadgeDer Normalfall. Nimm ihn, solange du die Rückrichtung nicht brauchst
bidirektionalBadge bekommt zusätzlich @OneToOne(mappedBy = "badge") private Employee employee;Wenn du vom Ausweis zum Mitarbeiter navigieren musst
gemeinsamer Schlüssel@MapsId — der Ausweis bekommt dieselbe Id wie der MitarbeiterSpart eine Spalte und garantiert die 1:1 auf Datenbankebene. Fortgeschritten
1:1 ist seltener, als man denkt

Bevor du eine 1:1-Beziehung baust, prüfe: Warum sind das zwei Tabellen und nicht eine? Gute Gründe gibt es — sehr große Felder auslagern, unterschiedliche Zugriffsrechte, ein Teil ist optional. Wenn keiner davon zutrifft, gehören die Felder wahrscheinlich in eine Klasse.

1:n und n:1 — dieselbe Beziehung von zwei Seiten

Beispiel: Ein Kurs hat viele Lektionen, jede Lektion gehört zu genau einem Kurs.

1:n und n:1 sind keine zwei Beziehungen, sondern zwei Blickrichtungen auf dieselbe. Der Fremdschlüssel liegt bei der n-Seite — bei der Lektion.

die Besitzerseite: hier liegt der Fremdschlüssel
@Entity
public class Lesson {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String title;

@ManyToOne
@JoinColumn(name = "course_id", nullable = false)
private Course course;
}
die inverse Seite: nur die Rückrichtung
@Entity
public class Course {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String title;

@OneToMany(mappedBy = "course")
private List<Lesson> lessons = new ArrayList<>();
}

Das mappedBy = "course" zeigt auf den Feldnamen in Lesson — nicht auf eine Spalte und nicht auf die Klasse. Ein Tippfehler dort ist ein Startfehler, kein Laufzeitfehler; die Anwendung fährt gar nicht erst hoch.

Die Varianten

VarianteWie es aussiehtWas in der Datenbank entsteht
n:1 unidirektionalnur Lesson.courseSpalte course_id in lesson. Schlank und meist genug
bidirektionalzusätzlich Course.lessons mit mappedBydasselbe — die Liste kostet keine Spalte
1:n unidirektional mit @JoinColumnnur Course.lessons, dazu @JoinColumn(name = "course_id")Spalte course_id in lesson, aber Hibernate füllt sie mit einem zusätzlichen UPDATE
1:n unidirektional ohne @JoinColumnnur Course.lessons⚠️ eine Zwischentabelle — fast nie gewollt
Die stille Falle: 1:n ohne @JoinColumn

Schreibt man nur die Liste und lässt @JoinColumn weg, legt Hibernate eine Zwischentabelle an — dieselbe Bauform wie bei n:m.

Nachgemessen an einem Wegwerf-Beispiel mit @OneToMany List<Lesson> lessons; ohne weitere Angabe:

course_lessons ( course_id, lessons_id )
UNIQUE (lessons_id)

Es funktioniert — aber es sind drei Tabellen statt zwei, jede Zuordnung kostet einen zusätzlichen Schreibvorgang, und im Datenmodell steht etwas anderes, als du gemeint hast.

Die Abhilfe ist immer dieselbe: Mach die @ManyToOne-Seite zur Besitzerseite. Das ist der Weg, den auch das Webshop-Tutorial geht.

n:m — viele auf beiden Seiten

Beispiel: Ein Studierender belegt mehrere Kurse, ein Kurs hat mehrere Studierende.

Für n:m gibt es in der Datenbank keine Spalte, die reicht. Es braucht eine Zwischentabelle, die nur aus zwei Fremdschlüsseln besteht.

Besitzerseite
@Entity
public class Student {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private Set<Course> courses = new HashSet<>();
}
inverse Seite
@Entity
public class Course {

@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
}

Bei n:m ist die Wahl der Besitzerseite frei — nimm die, von der aus du üblicherweise arbeitest.

studentid, namestudent_coursestudent_idcourse_idcourseid, title1 : nn : 1Eine n:m-Beziehung ist in der Datenbank immer zwei 1:n-Beziehungen.Das ist keine Krücke, sondern die einzige Möglichkeit — genau wie bei der Maklerdatenbank.
Der Unterschied zur 1:n-Falle ist genau ein UNIQUE

Beide Fälle erzeugen eine Zwischentabelle mit zwei Spalten. Nachgemessen:

ZwischentabelleZusatzbedingung
@OneToMany ohne @JoinColumncourse_lessons (course_id, lessons_id)UNIQUE (lessons_id)
@ManyToManystudent_course (student_id, course_id)keine

Das UNIQUE ist die ganze 1:n-Beziehung: Es verbietet, dass dieselbe Lektion zu zwei Kursen gehört. Fehlt es, ist es eine n:m-Beziehung.

Wer die Datenbank lesen kann, sieht die Kardinalität also direkt — an den Bedingungen, nicht an den Spalten.

Sobald die Verbindung selbst etwas weiß: keine n:m-Beziehung mehr

Das ist die praktisch wichtigste Regel dieses Blatts.

Solange die Zuordnung nur „gehört zusammen" bedeutet, reicht @ManyToMany. Sobald sie eigene Angaben trägt — wann eingeschrieben, welche Note, wie viele Stück, welcher Preis —, hat die Zwischentabelle Spalten, die keinem der beiden Partner gehören. Dann ist sie keine Zwischentabelle mehr, sondern eine eigene Entität.

„Wann hat sich wer eingeschrieben, und mit welcher Note?"StudentEnrollmentenrolledOngradeCourse10..*0..*1Aus n:m werden zwei 1:n — und die Verbindung bekommt einen Namen.Sie heißt jetzt „Einschreibung" und ist ein Ding, über das man reden kann.
die Verbindung als eigene Klasse
@Entity
public class Enrollment {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne
@JoinColumn(name = "student_id", nullable = false)
private Student student;

@ManyToOne
@JoinColumn(name = "course_id", nullable = false)
private Course course;

private LocalDate enrolledOn;

private Integer grade;
}
Faustregel

Frag dich bei jeder n:m-Beziehung: Wird jemand jemals wissen wollen, wann oder wie diese Verbindung entstanden ist?

Bei „Artikel hat Schlagwörter" lautet die Antwort meist nein — @ManyToMany genügt. Bei „Student belegt Kurs", „Bestellung enthält Artikel", „Mitarbeiter arbeitet in Projekt" lautet sie fast immer ja.

Der Umbau von @ManyToMany zu einer eigenen Klasse ist später mühsam, weil Daten schon drinstehen. Die Frage vorher zu stellen kostet nichts.

Was von allein geladen wird — und was nicht

Jede Beziehungsannotation hat eine Vorgabe dafür, ob die andere Seite sofort mitgeladen wird. Diese Vorgaben sind nicht einheitlich, und das ist eine häufige Fehlerquelle.

AnnotationVorgabeMerkhilfe
@OneToOneEAGER
@ManyToOneEAGER„to one" → sofort
@OneToManyLAZY
@ManyToManyLAZY„to many" → auf Verdacht nicht

Merksatz: Eines ist schnell geholt, viele nicht.

LAZY bedeutet: Statt der echten Daten steht zunächst ein Platzhalter dort. Erst wenn jemand tatsächlich darauf zugreift, geht eine zweite Abfrage an die Datenbank — sofern die Verbindung dann noch offen ist. Ist sie es nicht, gibt es die Ausnahme, die du im Webshop-Tutorial erlebst:

Cannot lazily initialize collection of role '…' (no session)
EAGER ist selten die Lösung

Es ist die naheliegende Reparatur und meist die falsche: EAGER lädt immer mit, auch wenn es niemand braucht — und es lässt sich am Abrufort nicht mehr abschalten.

Die üblichen Wege stattdessen:

WegWann
Zugriff innerhalb einer Transaktion (@Transactional im Service)Der Normalfall
Eine Abfrage mit join fetchWenn man weiß, dass man beides braucht
Gar nicht laden, sondern zählen (countBy…)Wenn nur die Anzahl gebraucht wird
Ein eigener Endpunkt für die UnterobjekteWenn die Liste lang werden kann

Was beim Speichern und Löschen mitgeht

cascade legt fest, welche Vorgänge an die andere Seite weitergereicht werden.

AngabeBedeutung
cascade = CascadeType.ALLSpeichern, Ändern, Löschen — alles geht mit
cascade = CascadeType.PERSISTnur das erste Speichern
orphanRemoval = trueWird das Kind aus der Liste entfernt, wird es gelöscht
(nichts angegeben)Jede Seite wird einzeln gespeichert und gelöscht

Die Entscheidung ist fachlich, nicht technisch — und im Klassendiagramm steht sie schon:

Im DiagrammBedeutungIn Java
Komposition (gefüllte Raute)Der Teil kann ohne das Ganze nicht existierencascade = ALL, oft orphanRemoval = true
Aggregation (leere Raute)Der Teil gehört dazu, überlebt aberkein Cascade beim Löschen
Assoziation (schlichte Linie)Beide sind eigenständigkein Cascade

Ein Werksausweis ohne Mitarbeiter ist sinnlos — Komposition. Eine Lektion ohne Kurs auch. Ein Studierender ohne Kurs dagegen bleibt ein Studierender.

Wohin ein falsches Cascade führt

cascade = ALL bei @ManyToMany bedeutet: Löschst du einen Kurs, löschst du alle Studierenden dieses Kurses — und mit ihnen alles, was an ihnen hängt.

Bei „viele zu viele" gehört so gut wie nie ein Lösch-Cascade hin.

Drei Fallen, die jeder einmal baut

1. @Data auf einer Klasse mit Beziehungen

Lombok erzeugt toString(), equals() und hashCode() über alle Felder. Zeigen zwei Klassen aufeinander, rufen diese Methoden sich gegenseitig auf und kommen nie zurück — das Programm bleibt stehen oder stürzt mit StackOverflowError ab. Bei toString() genügt eine einzige Logausgabe, um es auszulösen.

// So nicht:
@Data
@Entity
public class Course {@OneToMany}

// Sondern:
@Getter
@Setter
@NoArgsConstructor
@Entity
public class Course {}

Mehr dazu im Infoblatt Lombok.

2. Entitäten mit Beziehungen nach außen geben

Gibt der Controller eine Entität heraus, wandert die Umwandlung nach JSON durch die ganze Beziehung — und bei einer zweiseitigen Beziehung im Kreis. Was dabei herauskommt, siehst du gemessen im Webshop-Tutorial.

Die Abhilfe ist ein eigenes Antwortobjekt, ein DTO. Es enthält nur die Felder, die in der Antwort stehen sollen, und nichts, was zurückzeigt.

3. Das N+1-Problem

Du holst 50 Kurse und gibst zu jedem die Zahl der Lektionen aus. Bei LAZY geht dafür eine Abfrage für die Kurse und dann je eine pro Kurs — 51 statt 2.

So findest du es: spring.jpa.show-sql=true einschalten und die Abfragen im Log zählen. Steht dieselbe Abfrage 50-mal untereinander, hast du es gefunden.

So behebst du es:

// statt zu laden und zu zählen: zählen lassen
long count = lessonRepository.countByCourseId(courseId);

// oder in einer Abfrage mitholen
@Query("select c from Course c join fetch c.lessons")
List<Course> findAllWithLessons();

Eine Prüfliste für jede neue Beziehung

  1. Welche Kardinalität? 1:1, 1:n oder n:m — und lässt sich das an einem Beispielsatz prüfen? („Ein Kurs hat viele Lektionen, eine Lektion gehört zu einem Kurs.")
  2. Trägt die Verbindung eigene Angaben? Wenn ja: eigene Klasse statt @ManyToMany.
  3. Brauche ich beide Richtungen? Wenn nein: unidirektional, das ist weniger Code und weniger Fehlerquellen.
  4. Wo liegt der Fremdschlüssel? Diese Seite ist die Besitzerseite. Die andere bekommt mappedBy.
  5. Was passiert beim Löschen des Ganzen? Komposition oder nicht — daraus folgt das Cascade.
  6. Wird die andere Seite meistens gebraucht? Wenn nein: LAZY lassen und in der Transaktion arbeiten.
  7. Nachsehen. Anwendung starten, show-sql an, das erzeugte create table im Log lesen. Steht dort, was du gemeint hast?

Punkt 7 ist der wichtigste. Alle Beispiele in diesem Blatt sind genau so entstanden: hingeschrieben, gestartet, im Log nachgelesen.

Weiterlesen