@OneToMany sieht harmlos aus, bis eines von drei Dingen passiert: Die Fremdschlüsselspalte bleibt nach dem Speichern leer, das Löschen eines Elternobjekts nimmt fremde Datensätze mit, oder eine Liste von hundert Objekten löst hunderteins Abfragen aus. Alle drei haben dieselbe Ursache. Die Annotation sagt weniger, als man ihr zutraut.
Der Stand heute
Der Namensraum heißt seit Jakarta EE 9 nicht mehr javax.persistence, sondern jakarta.persistence. Wer über ein älteres Tutorial hier landet, braucht an den Imports also ein Suchen und Ersetzen; inhaltlich hat sich dabei nichts geändert.
Hibernate ORM 7 setzt Jakarta Persistence 3.2 um und verlangt mindestens Java 17. Für alten Code wichtiger: Version 7 prüft das Domänenmodell strenger als ihre Vorgänger und lehnt Kombinationen ab, die früher stillschweigend durchgingen. Annotationen am Getter sind weiterhin erlaubt, aber nur konsequent. Wer @Id am Feld und @OneToMany am Getter stehen hat, bekommt jetzt einen Fehler statt eines schwer auffindbaren Verhaltens. Heute schreibt man Annotationen ans Feld.
Die beiden Seiten einer Beziehung
Das häufigste Missverständnis steckt in mappedBy. Die Annotation sagt nicht „hier ist die Beziehung”, sondern „die andere Seite verwaltet sie”. Der Fremdschlüssel steht in der Tabelle der Kind-Entität, und nur wer dort etwas einträgt, ändert die Datenbank.
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Entity
@Table(name = "my_object", schema = "public")
public class MyObject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "myObject",
cascade = { CascadeType.PERSIST, CascadeType.MERGE },
orphanRemoval = true)
private List<MySubObject> subObjects = new ArrayList<>();
/**
* Beide Seiten in einer Methode setzen. Wer nur der Liste etwas hinzufügt,
* schreibt nichts in die Fremdschlüsselspalte, und das Kind bleibt verwaist.
*/
public void addSubObject(MySubObject subObject) {
subObjects.add(subObject);
subObject.setMyObject(this);
}
public void removeSubObject(MySubObject subObject) {
subObjects.remove(subObject);
subObject.setMyObject(null);
}
/** Nur lesbar nach außen, damit niemand an addSubObject vorbei arbeitet. */
public List<MySubObject> getSubObjects() {
return Collections.unmodifiableList(subObjects);
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}Code language: Java (java)
Die Gegenseite:
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "my_sub_object", schema = "public")
public class MySubObject {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// @ManyToOne lädt per Vorgabe EAGER. Das ist fast nie gewollt und
// die häufigste Ursache dafür, dass eine Abfrage halbe Objektgraphen mitzieht.
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "myobject_id")
private MyObject myObject;
public MyObject getMyObject() {
return myObject;
}
void setMyObject(MyObject myObject) {
this.myObject = myObject;
}
}Code language: Java (java)
Zwei Vorgaben sind hier bewusst gesetzt. @ManyToOne lädt ohne Zutun eager, @OneToMany dagegen lazy. Diese Asymmetrie überrascht regelmäßig, und sie ist der Grund, warum eine harmlose Abfrage plötzlich vier Tabellen verbindet.
Cascade und orphanRemoval
CascadeType.ALL steht in vielen Beispielen, und es schließt REMOVE ein. Das Löschen des Elternobjekts löscht damit auch alle Kinder. Wenn die Kinder ausschließlich zu diesem einen Elternteil gehören, ist das richtig. Teilen sich mehrere Eltern dieselben Kinder, löscht man fremde Daten.
orphanRemoval = true macht etwas anderes und meist Sinnvolleres: Es löscht ein Kind, sobald es aus der Collection entfernt wird, auch ohne dass das Elternobjekt verschwindet. Für echte Besitzverhältnisse, etwa Rechnungspositionen zu einer Rechnung, ist das genau das gewünschte Verhalten.
Abfragen nach der Größe der Collection
In JPQL gibt es zwei Formulierungen, und sie meinen nicht dasselbe.
// Mindestens ein Kind. Lesbarer und oft schneller als size(...) > 0.
List<MyObject> mitKindern = entityManager.createQuery("""
select o
from MyObject o
where o.subObjects is not empty
""", MyObject.class)
.getResultList();
// Mehr als n Kinder.
List<MyObject> vieleKinder = entityManager.createQuery("""
select o
from MyObject o
where size(o.subObjects) > :min
""", MyObject.class)
.setParameter("min", 5)
.getResultList();Code language: Java (java)
size() erzeugt eine korrelierte Unterabfrage, die pro Zeile der äußeren Abfrage ausgeführt wird. Bei ein paar hundert Datensätzen merkt das niemand. Bei ein paar hunderttausend schon, und dann ist ein Join mit Gruppierung die bessere Wahl:
List<MyObject> vieleKinder = entityManager.createQuery("""
select o
from MyObject o
join o.subObjects s
group by o
having count(s) > :min
""", MyObject.class)
.setParameter("min", 5)
.getResultList();Code language: Java (java)
Wer wissen will, welche der beiden Varianten in seinem Fall gewinnt, schaltet hibernate.show_sql ein und lässt die erzeugte Abfrage durch EXPLAIN ANALYZE laufen. Raten hilft hier nicht.
Das N+1-Problem
Die Abfragen oben laden nur die Elternobjekte. Sobald der Code über getSubObjects() geht, setzt Hibernate für jedes Elternobjekt eine eigene Abfrage ab. Aus einer Abfrage werden einhunderteins.
Der direkte Weg ist ein Fetch Join:
List<MyObject> geladen = entityManager.createQuery("""
select o
from MyObject o
left join fetch o.subObjects
where o.subObjects is not empty
""", MyObject.class)
.getResultList();Code language: Java (java)
Das früher übliche distinct braucht man dafür nicht mehr: Seit Hibernate 6 entfernt Hibernate die Duplikate bei Entity-Abfragen selbst.
Ein Fetch Join hat allerdings zwei Haken. Er verträgt sich schlecht mit Paginierung, weil setMaxResults dann auf die verbundenen Zeilen wirkt und nicht auf die Elternobjekte. Und er lässt sich nicht auf zwei Listen gleichzeitig anwenden; der Versuch endet in einer MultipleBagFetchException. Wer mehrere Collections braucht, nimmt entweder Set statt List oder lädt sie nacheinander.
Für viele Fälle reicht auch die unaufwendigere Lösung:
@OneToMany(mappedBy = "myObject")
@BatchSize(size = 25)
private List<MySubObject> subObjects = new ArrayList<>();Code language: Java (java)
Damit lädt Hibernate die Kind-Collections in Gruppen zu 25 nach, sobald die erste davon gebraucht wird. Aus 101 Abfragen werden fünf, ohne dass eine einzige Abfrage umgeschrieben werden muss.
Dasselbe mit Spring Data JPA
public interface MyObjectRepository extends JpaRepository<MyObject, Long> {
@Query("select o from MyObject o where size(o.subObjects) > :min")
List<MyObject> findWithMoreThan(@Param("min") int min);
@EntityGraph(attributePaths = "subObjects")
List<MyObject> findByNameStartingWith(String prefix);
}Code language: Java (java)
@EntityGraph ist die angenehmere Variante des Fetch Joins: Die Abfrage bleibt eine abgeleitete Methode, und das Nachladeverhalten steht als Annotation daneben.
Woran man beim Umstellen alter Projekte denken sollte
Neben den Imports lohnt ein Blick auf drei Stellen. Erstens CascadeType.ALL auf @OneToMany, überall dort, wo die Kinder nicht ausschließlich zu diesem Elternteil gehören. Zweitens jedes @ManyToOne ohne explizites fetch, weil die Vorgabe eager lautet. Drittens rohe Collections ohne Typparameter, die Hibernate 7 nicht mehr so großzügig hinnimmt wie frühere Versionen.
Wer von Hibernate 5 kommt, sollte außerdem einplanen, dass Version 7 das Wiederanhängen abgelöster Entitäten an einen Persistence Context nicht mehr erlaubt. Das betrifft ältere Web-Anwendungen, die Entitäten über Request-Grenzen hinweg gehalten haben, und es fällt beim Kompilieren nicht auf.
Dieser Artikel erschien ursprünglich 2008 und behandelte nur die Abfrage nach der Collection-Größe. Überarbeitet im September 2026.