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.
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.
| Besitzerseite | inverse Seite | |
|---|---|---|
| Woran erkennbar | hat @JoinColumn oder gar nichts | hat mappedBy |
| Wo liegt der Fremdschlüssel | hier | nirgends — sie ist nur die Rückrichtung |
| Was beim Speichern zählt | nur diese Seite | wird beim Schreiben ignoriert |
| Bei 1:n / n:1 immer | die @ManyToOne-Seite | die @OneToMany-Seite |
„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.
@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)
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
| Variante | Wie | Wann |
|---|---|---|
| unidirektional | nur Employee kennt Badge | Der Normalfall. Nimm ihn, solange du die Rückrichtung nicht brauchst |
| bidirektional | Badge 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 Mitarbeiter | Spart eine Spalte und garantiert die 1:1 auf Datenbankebene. Fortgeschritten |
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.
@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;
}
@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
| Variante | Wie es aussieht | Was in der Datenbank entsteht |
|---|---|---|
| n:1 unidirektional | nur Lesson.course | Spalte course_id in lesson. Schlank und meist genug |
| bidirektional | zusätzlich Course.lessons mit mappedBy | dasselbe — die Liste kostet keine Spalte |
1:n unidirektional mit @JoinColumn | nur 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 @JoinColumn | nur Course.lessons | ⚠️ eine Zwischentabelle — fast nie gewollt |
@JoinColumnSchreibt 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.
@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<>();
}
@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.
Beide Fälle erzeugen eine Zwischentabelle mit zwei Spalten. Nachgemessen:
| Zwischentabelle | Zusatzbedingung | |
|---|---|---|
@OneToMany ohne @JoinColumn | course_lessons (course_id, lessons_id) | UNIQUE (lessons_id) |
@ManyToMany | student_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.
@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;
}
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.
| Annotation | Vorgabe | Merkhilfe |
|---|---|---|
@OneToOne | EAGER | |
@ManyToOne | EAGER | „to one" → sofort |
@OneToMany | LAZY | |
@ManyToMany | LAZY | „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ösungEs 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:
| Weg | Wann |
|---|---|
Zugriff innerhalb einer Transaktion (@Transactional im Service) | Der Normalfall |
Eine Abfrage mit join fetch | Wenn 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 Unterobjekte | Wenn die Liste lang werden kann |
Was beim Speichern und Löschen mitgeht
cascade legt fest, welche Vorgänge an die andere Seite weitergereicht werden.
| Angabe | Bedeutung |
|---|---|
cascade = CascadeType.ALL | Speichern, Ändern, Löschen — alles geht mit |
cascade = CascadeType.PERSIST | nur das erste Speichern |
orphanRemoval = true | Wird 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 Diagramm | Bedeutung | In Java |
|---|---|---|
| Komposition (gefüllte Raute) | Der Teil kann ohne das Ganze nicht existieren | cascade = ALL, oft orphanRemoval = true |
| Aggregation (leere Raute) | Der Teil gehört dazu, überlebt aber | kein Cascade beim Löschen |
| Assoziation (schlichte Linie) | Beide sind eigenständig | kein Cascade |
Ein Werksausweis ohne Mitarbeiter ist sinnlos — Komposition. Eine Lektion ohne Kurs auch. Ein Studierender ohne Kurs dagegen bleibt ein Studierender.
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
- 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.")
- Trägt die Verbindung eigene Angaben? Wenn ja: eigene Klasse statt
@ManyToMany. - Brauche ich beide Richtungen? Wenn nein: unidirektional, das ist weniger Code und weniger Fehlerquellen.
- Wo liegt der Fremdschlüssel? Diese Seite ist die Besitzerseite. Die andere bekommt
mappedBy. - Was passiert beim Löschen des Ganzen? Komposition oder nicht — daraus folgt das Cascade.
- Wird die andere Seite meistens gebraucht? Wenn nein:
LAZYlassen und in der Transaktion arbeiten. - Nachsehen. Anwendung starten,
show-sqlan, das erzeugtecreate tableim 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
- JPA und Hibernate — die Grundlagen: Entität, Id, Tabellenabbildung
- Abgeleitete Abfragen —
countByCourseIdund Verwandte - Lombok — warum
@Dataund Beziehungen sich nicht vertragen - DTOs und Schichten — warum eine Entität mit Beziehungen keine gute Antwort ist
- Webshop-Tutorial — dieselben Beziehungen an einem durchgehenden Beispiel