Woche 5, Tag 4 — Entity Relationships
Ziel
Heute willst du Entity Relationships in JPA und Spring Data JPA verstehen.
Die Kernfragen:
- Was ist eine Entity Relationship?
- Was ist
@ManyToOne? - Was ist
@OneToMany? - Was ist die Owning Side?
- Was bedeutet
mappedBy? - Was ist Lazy Loading?
- Was ist Eager Loading?
- Was ist Cascade?
- Was ist Orphan Removal?
- Was ist das N+1-Query-Problem?
- Wie sollten REST APIs mit Entity Relationships umgehen?
- Welche typischen Prüfungsfallen gibt es?
1. Kurz-Wiederholung aus Woche 5, Tag 3
In Tag 3 hast du gelernt:
- Eine Transaction ist eine Alles-oder-nichts-Einheit.
@Transactionaldefiniert eine Transaction-Grenze.- Transactions gehören meist in die Service-Schicht.
- Der Persistence Context trackt managed Entities.
- Dirty Checking aktualisiert managed Entities automatisch.
- Runtime Exceptions rollen standardmäßig zurück.
- Checked Exceptions rollen standardmäßig nicht zurück.
readOnly = trueist für Leseoperationen.- Lazy Loading braucht meist einen offenen Persistence Context.
Merksatz:
Der Persistence Context trackt managed Entities innerhalb einer Transaction.
Heute lernst du, was passiert, wenn Entities mit anderen Entities verbunden sind.
2. Was ist eine Entity Relationship?
Eine Entity Relationship bedeutet:
Eine Entity ist mit einer anderen Entity verbunden.
Beispiel für eine Business-Beziehung:
Ein Client hat viele Tasks.
Ein Task gehört zu einem Client.
Java-Modell:
ClientEntity
TaskEntity
Datenbank-Modell:
clients-Tabelle
tasks-Tabelle
Beziehung:
tasks.client_id referenziert clients.id
Merksatz:
Entity Relationships mappen Java-Objektverbindungen auf Foreign Keys in der Datenbank.
3. Die wichtigsten Relationship-Typen
JPA hat diese Relationship-Annotations:
@OneToOne
@OneToMany
@ManyToOne
@ManyToMany
Typische Bedeutungen:
| Annotation | Bedeutung |
|---|---|
@ManyToOne | viele Child-Rows gehören zu einem Parent |
@OneToMany | ein Parent hat viele Children |
@OneToOne | eine Entity ist mit einer Entity verbunden |
@ManyToMany | viele Entities sind mit vielen Entities verbunden |
Am häufigsten in Business-Apps:
@ManyToOne
@OneToMany
Beispiel:
Viele Tasks gehören zu einem Client.
Ein Client hat viele Tasks.
4. Zuerst der Foreign Key in der Datenbank
Bevor du Java-Annotations nutzt, versteh die Datenbank.
Tabellen:
CREATE TABLE clients (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
client_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
CONSTRAINT fk_tasks_client
FOREIGN KEY (client_id)
REFERENCES clients(id)
);
Wichtig:
Der Foreign Key liegt in der tasks-Tabelle.
Warum?
Jeder Task gehört zu einem Client.
Deshalb speichert jede Task-Row client_id.
Merksatz:
Bei einer Many-to-One-Beziehung liegt der Foreign Key meist auf der Many-Seite.
5. @ManyToOne
@ManyToOne mappt viele Entities auf eine Entity.
Beispiel:
Viele Tasks -> ein Client
Java:
@Entity
@Table(name = "tasks")
public class TaskEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;
protected TaskEntity() {
}
public TaskEntity(String title, String status, ClientEntity client) {
this.title = title;
this.status = status;
this.client = client;
}
}
Bedeutung:
TaskEntity hat eine Many-to-One-Beziehung zu ClientEntity.
Die Foreign-Key-Spalte ist client_id.
Merksatz:
@ManyToOnesteht meist auf der Child-Seite.
6. @JoinColumn
@JoinColumn definiert die Foreign-Key-Spalte.
Beispiel:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;
Bedeutung:
tasks.client_id zeigt auf clients.id
name = "client_id" bedeutet:
Name der Foreign-Key-Spalte in der tasks-Tabelle
nullable = false bedeutet:
ein Task muss einen Client haben
Merksatz:
@JoinColumnsagt JPA, welche Foreign-Key-Spalte die Beziehung speichert.
7. @OneToMany
@OneToMany mappt eine Entity auf viele Child-Entities.
Beispiel:
Ein Client -> viele Tasks
Java:
@Entity
@Table(name = "clients")
public class ClientEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks = new ArrayList<>();
protected ClientEntity() {
}
public ClientEntity(String name) {
this.name = name;
}
}
Bedeutung:
ClientEntity hat viele TaskEntity-Objekte.
TaskEntity.client besitzt die Beziehung.
Merksatz:
@OneToMany(mappedBy = "...")zeigt meist auf das Feld auf der Many-Seite.
8. Vollständiges Beziehungsbeispiel
Client:
@Entity
@Table(name = "clients")
public class ClientEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks = new ArrayList<>();
protected ClientEntity() {
}
public ClientEntity(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public List<TaskEntity> getTasks() {
return tasks;
}
}
Task:
@Entity
@Table(name = "tasks")
public class TaskEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;
protected TaskEntity() {
}
public TaskEntity(String title, String status, ClientEntity client) {
this.title = title;
this.status = status;
this.client = client;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public String getStatus() {
return status;
}
public ClientEntity getClient() {
return client;
}
}
9. Owning Side
In JPA ist die Owning Side die Seite, die die Datenbankbeziehung steuert.
Einfache Definition:
Die Owning Side ist die Seite, die das Foreign-Key-Mapping besitzt.
In diesem Beispiel:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id")
private ClientEntity client;
TaskEntity ist die Owning Side, weil:
die tasks-Tabelle den Foreign Key client_id hat
Merksatz:
Die Owning Side ist meist die Seite mit
@JoinColumn.
10. Inverse Side
Die Inverse Side ist die andere Seite einer bidirektionalen Beziehung.
Beispiel:
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks = new ArrayList<>();
Das bedeutet:
ClientEntity.tasks ist nicht die Owning Side.
TaskEntity.client besitzt die Beziehung.
mappedBy = "client" zeigt auf dieses Feld:
private ClientEntity client;
in TaskEntity.
Merksatz:
mappedBybedeutet: die andere Seite besitzt die Beziehung.
11. mappedBy
Beispiel:
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks;
Der Wert "client" bedeutet:
Schau auf das Feld namens client in TaskEntity.
Dieses Feld besitzt die Beziehung.
Häufiger Fehler:
@OneToMany(mappedBy = "client_id")
Falsch.
Warum?
mappedBy nutzt den Java-Feldnamen, nicht den Datenbank-Spaltennamen.
Richtig:
@OneToMany(mappedBy = "client")
Merksatz:
mappedBynutzt den Java-Feldnamen auf der Owning Side.
12. Bidirektionale Beziehung
Eine bidirektionale Beziehung bedeutet, dass beide Entities aufeinander verweisen.
Client hat Tasks:
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks;
Task hat Client:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id")
private ClientEntity client;
Damit kann Java-Code navigieren:
client.getTasks()
task.getClient()
Bidirektionale Beziehungen sind aber komplexer.
Merksatz:
Bidirektional bedeutet: beide Seiten haben Referenzen.
13. Unidirektionale Beziehung
Eine unidirektionale Beziehung bedeutet, dass nur eine Seite von der anderen weiß.
Beispiel:
@Entity
public class TaskEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;
}
Aber ClientEntity hat keine tasks-Liste.
Das ist einfacher.
Für viele Anwendungen reicht das.
Merksatz:
Nutze bidirektionale Beziehungen nur, wenn du wirklich von beiden Seiten navigieren musst.
14. Beide Seiten konsistent halten
Bei bidirektionalen Beziehungen müssen Java-Objektreferenzen konsistent gehalten werden.
Beispiel für eine Helper-Methode:
@Entity
@Table(name = "clients")
public class ClientEntity {
@OneToMany(mappedBy = "client", cascade = CascadeType.ALL, orphanRemoval = true)
private List<TaskEntity> tasks = new ArrayList<>();
public void addTask(TaskEntity task) {
tasks.add(task);
task.setClient(this);
}
public void removeTask(TaskEntity task) {
tasks.remove(task);
task.setClient(null);
}
}
Task:
@Entity
@Table(name = "tasks")
public class TaskEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id")
private ClientEntity client;
void setClient(ClientEntity client) {
this.client = client;
}
}
Warum Helper-Methoden?
Sie halten beide Java-Seiten synchron.
Merksatz:
Bei bidirektionalen Beziehungen aktualisierst du beide Seiten in Java.
15. Lazy Loading
Lazy Loading bedeutet:
Verknüpfte Daten werden erst geladen, wenn darauf zugegriffen wird.
Beispiel:
@ManyToOne(fetch = FetchType.LAZY)
private ClientEntity client;
Beim Laden eines Tasks:
TaskEntity task = taskRepository.findById(id).orElseThrow();
kann JPA laden:
Task-Row
aber nicht sofort:
Client-Row
Erst wenn der Code aufruft:
task.getClient().getName();
lädt JPA möglicherweise den Client.
Merksatz:
Lazy Loading verzögert das Laden verknüpfter Daten, bis sie gebraucht werden.
16. Eager Loading
Eager Loading bedeutet:
Verknüpfte Daten werden sofort zusammen mit der Entity geladen.
Beispiel:
@ManyToOne(fetch = FetchType.EAGER)
private ClientEntity client;
Beim Laden eines Tasks lädt JPA auch den Client.
Das klingt praktisch, kann aber Performance-Probleme verursachen.
Merksatz:
Eager Loading lädt verknüpfte Daten sofort.
17. Lazy vs. Eager
| Thema | Lazy | Eager |
|---|---|---|
| Wann geladen | beim Zugriff | sofort |
| Performance | oft standardmäßig besser | kann zu viel laden |
| Risiko | Lazy-Loading-Exception | N+1 und große Queries |
| Kontrolle | explizites Fetchen bei Bedarf | schwerer zu kontrollieren |
| Typische Empfehlung | für Associations bevorzugen | vorsichtig nutzen |
Wichtige praktische Regel:
Bevorzuge standardmäßig LAZY.
Lade explizit, wenn nötig.
18. Standard-Fetch-Typen
JPA-Defaults sind wichtige Prüfungsfallen.
Typische Defaults:
@ManyToOne -> standardmäßig EAGER
@OneToOne -> standardmäßig EAGER
@OneToMany -> standardmäßig LAZY
@ManyToMany -> standardmäßig LAZY
Weil @ManyToOne standardmäßig eager ist, schreiben viele Entwickler explizit:
@ManyToOne(fetch = FetchType.LAZY)
Merksatz:
Many-to-One ist standardmäßig eager — setze es in vielen echten Projekten explizit auf lazy.
19. LazyInitializationException
Lazy Loading braucht einen offenen Persistence Context.
Beispielproblem:
public TaskDto getTask(Long id) {
TaskEntity task = taskRepository.findById(id).orElseThrow();
return new TaskDto(
task.getId(),
task.getTitle(),
task.getClient().getName()
);
}
Wenn das außerhalb einer aktiven Transaction oder eines Persistence Context passiert, kann der Zugriff auf:
task.getClient().getName()
mit einem Lazy-Loading-Fehler fehlschlagen.
Häufiger Name:
LazyInitializationException
Merksatz:
Lazy-Beziehungen müssen aufgerufen werden, solange der Persistence Context offen ist.
20. Gute Praxis: innerhalb der Transaction auf DTO mappen
Guter Service:
@Transactional(readOnly = true)
public TaskDto getTask(Long id) {
TaskEntity task = taskRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Task", id));
return new TaskDto(
task.getId(),
task.getTitle(),
task.getStatus(),
task.getClient().getName()
);
}
Warum besser?
Mapping passiert, solange Transaction/Persistence Context offen ist
DTO wird an den Controller zurückgegeben
Controller berührt keine lazy Entity-Beziehungen
Merksatz:
Lade und mappe benötigte Daten in der Service-Schicht.
21. Gib Entities nicht direkt aus REST APIs zurück
Schlecht:
@GetMapping("/api/clients/{id}")
public ClientEntity getClient(@PathVariable Long id) {
return clientRepository.findById(id).orElseThrow();
}
Probleme:
Lazy Loading während der JSON-Serialisierung
N+1-Queries während der Serialisierung
endlose Rekursion
sensible Felder werden exponiert
API ist an das Datenbankmodell gekoppelt
unerwartet große JSON-Antwort
Besser:
@GetMapping("/api/clients/{id}")
public ClientDto getClient(@PathVariable Long id) {
return clientService.findById(id);
}
Merksatz:
Gib DTOs zurück, nicht Entities.
22. Endlose JSON-Rekursion
Bidirektionale Beziehung:
ClientEntity -> tasks
TaskEntity -> client
Wenn Jackson die Entity direkt serialisiert:
client -> tasks -> task -> client -> tasks -> task -> ...
kann das zu endloser Rekursion führen.
DTOs lösen das sauber.
Beispiel-DTO:
public record ClientDto(
Long id,
String name,
List<TaskSummaryDto> tasks
) {
}
public record TaskSummaryDto(
Long id,
String title,
String status
) {
}
Keine zirkuläre Referenz.
23. Cascade
Cascade bedeutet:
Eine Operation vom Parent-Entity auf verknüpfte Child-Entities anwenden.
Beispiel:
@OneToMany(mappedBy = "client", cascade = CascadeType.ALL)
private List<TaskEntity> tasks = new ArrayList<>();
Wenn du einen Client speicherst, kann JPA auch seine Tasks speichern.
Häufige Cascade-Typen:
PERSIST
MERGE
REMOVE
REFRESH
DETACH
ALL
Merksatz:
Cascade propagiert Entity-Operationen auf verknüpfte Entities.
24. CascadeType.PERSIST
Beispiel:
@OneToMany(mappedBy = "client", cascade = CascadeType.PERSIST)
private List<TaskEntity> tasks = new ArrayList<>();
Bedeutung:
beim Persistieren des Clients auch neue Tasks persistieren
Nützlich, wenn der Child-Lifecycle zum Parent gehört.
25. CascadeType.REMOVE
Beispiel:
@OneToMany(mappedBy = "client", cascade = CascadeType.REMOVE)
private List<TaskEntity> tasks = new ArrayList<>();
Bedeutung:
beim Löschen des Clients auch Tasks löschen
Gefahr:
kann versehentlich viele Rows löschen
Vorsichtig nutzen.
Merksatz:
Cascade Remove ist mächtig und gefährlich.
26. CascadeType.ALL
@OneToMany(mappedBy = "client", cascade = CascadeType.ALL)
private List<TaskEntity> tasks = new ArrayList<>();
Bedeutet alle Cascade-Operationen:
persist
merge
remove
refresh
detach
Das ist in Beispielen häufig, muss in echten Projekten aber vorsichtig genutzt werden.
Merksatz:
Nutze
CascadeType.ALLnicht automatisch überall.
27. Prüfungsfalle: Cascade auf @ManyToOne
Gefährlich:
@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "client_id")
private ClientEntity client;
Warum gefährlich?
Viele Tasks teilen sich einen Client.
Das Löschen eines Tasks könnte zum Löschen des Clients cascaden.
Das könnte andere Tasks betreffen.
Vermeide meist Cascade vom Child zum Parent.
Besser:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;
Merksatz:
Sei sehr vorsichtig mit Cascade auf
@ManyToOne.
28. Orphan Removal
Orphan Removal bedeutet:
Wenn eine Child-Entity aus der Parent-Collection entfernt wird, lösche sie aus der Datenbank.
Beispiel:
@OneToMany(
mappedBy = "client",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<TaskEntity> tasks = new ArrayList<>();
Wenn du machst:
client.removeTask(task);
kann der Task aus der Datenbank gelöscht werden.
Merksatz:
orphanRemoval = truelöscht Children, die aus der Parent-Collection entfernt werden.
29. Cascade Remove vs. Orphan Removal
| Thema | Cascade Remove | Orphan Removal |
|---|---|---|
| Auslöser | Parent wird entfernt | Child wird aus der Parent-Collection entfernt |
| Beispiel | Client löschen -> Tasks löschen | Task aus client.tasks entfernen -> Task löschen |
| Use Case | Child kann ohne Parent nicht existieren | Child-Lifecycle gehört zum Parent |
| Risiko | löscht viele Children | versehentliches Löschen aus der Collection |
Merksatz:
Cascade Remove reagiert auf Parent-Löschung. Orphan Removal reagiert auf Child-Entfernung aus der Collection.
30. Wann ist Orphan Removal sinnvoll?
Gutes Beispiel:
Order -> OrderItem
Invoice -> InvoiceLine
Client -> ClientContact vielleicht
Wenn ein OrderItem ohne Order nicht existieren kann, ist Orphan Removal sinnvoll.
Vielleicht nicht gut:
Client -> Task
Warum?
Tasks sollten vielleicht archiviert, neu zugewiesen oder für Audits behalten werden.
Frag immer:
Kann das Child ohne den Parent existieren?
Soll das Entfernen aus der Collection es dauerhaft löschen?
Merksatz:
Nutze Orphan Removal nur, wenn der Child-Lifecycle wirklich zum Parent gehört.
31. N+1-Query-Problem
Das N+1-Problem bedeutet:
Eine Query lädt Parent-Records, dann laden weitere Queries die verknüpften Records einzeln.
Beispiel:
@Transactional(readOnly = true)
public List<TaskDto> listTasks() {
List<TaskEntity> tasks = taskRepository.findByStatus("OPEN");
return tasks.stream()
.map(task -> new TaskDto(
task.getId(),
task.getTitle(),
task.getClient().getName()
))
.toList();
}
Mögliches SQL:
select * from tasks where status = 'OPEN'; -- 1 query
select * from clients where id = 1; -- query 1
select * from clients where id = 2; -- query 2
select * from clients where id = 3; -- query 3
...
Wenn es 100 Tasks gibt:
1 Query für Tasks
100 Queries für Clients
= 101 Queries
Das ist N+1.
Merksatz:
N+1 bedeutet eine Haupt-Query plus eine Query pro Row für verknüpfte Daten.
32. Warum N+1 schlecht ist
N+1 verursacht:
viele Datenbank-Roundtrips
langsame API-Antworten
hohe Datenbanklast
Performance-Probleme in Production
schwer sichtbare Bugs
Kleine Dev-Datenbank:
wirkt okay
Production-Datenbank:
sehr langsam
Merksatz:
N+1 ist ein Performance-Bug, der oft erst mit echten Daten sichtbar wird.
33. Wie du N+1 erkennst
Aktiviere SQL-Logs in der Entwicklung:
spring:
jpa:
show-sql: true
Besseres Logging:
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
Achte auf:
eine Query für die Liste
viele ähnliche Queries für die verknüpfte Entity
Merksatz:
SQL-Logs zeigen N+1-Probleme.
34. N+1 mit Fetch Join beheben
Repository:
@Query("""
select t
from TaskEntity t
join fetch t.client
where t.status = :status
""")
List<TaskEntity> findByStatusWithClient(@Param("status") String status);
Bedeutung:
Lade Tasks und Clients in einer Query.
Service:
@Transactional(readOnly = true)
public List<TaskDto> listOpenTasks() {
return taskRepository.findByStatusWithClient("OPEN")
.stream()
.map(task -> new TaskDto(
task.getId(),
task.getTitle(),
task.getClient().getName()
))
.toList();
}
Merksatz:
Fetch Join lädt benötigte Beziehungen zusammen.
35. join vs. join fetch
JPQL-Join:
select t from TaskEntity t join t.client c
Das joined zum Filtern.
JPQL-Fetch-Join:
select t from TaskEntity t join fetch t.client
Das joined und lädt die Beziehung.
Merksatz:
joinhilft bei der Query.join fetchlädt auch die Association.
36. N+1 mit EntityGraph beheben
Repository:
@EntityGraph(attributePaths = "client")
List<TaskEntity> findByStatus(String status);
Bedeutung:
Beim Finden von Tasks nach Status auch den Client fetchen.
Das ist ein anderer Weg, einen Fetch Plan zu definieren.
Merksatz:
@EntityGraphsagt Spring Data JPA, welche Associations gefetcht werden sollen.
37. N+1 mit DTO Projection beheben
Statt volle Entities zu laden:
@Query("""
select new de.klarsync.tasks.TaskListDto(
t.id,
t.title,
t.status,
c.name
)
from TaskEntity t
join t.client c
where t.status = :status
""")
List<TaskListDto> findTaskListDtos(@Param("status") String status);
DTO:
public record TaskListDto(
Long id,
String title,
String status,
String clientName
) {
}
Das liefert genau das, was die API braucht.
Merksatz:
DTO Projection kann vermeiden, unnötige Entity Graphs zu laden.
38. Prüfungsfalle: Fetch Join mit Pagination
Sei vorsichtig mit Fetch Joins auf Collections und Pagination.
Beispiel für eine riskante Query:
@Query("""
select c
from ClientEntity c
join fetch c.tasks
""")
Page<ClientEntity> findAllWithTasks(Pageable pageable);
Problem:
Fetch Joins auf Collection-Beziehungen mit Pagination können falsche oder ineffiziente Ergebnisse liefern
Sicherere Optionen:
DTO Projection nutzen
nur Many-to-One-Beziehungen fetchen
Zwei-Schritt-Query nutzen
EntityGraph vorsichtig nutzen
Batch Fetching
eigenes Query-Design
Merksatz:
Sei vorsichtig mit Collection-Fetch-Joins und Pagination.
39. @ManyToOne ist meist sicher zum Fetch Joinen
Many-to-One fetchen ist meist einfacher:
@Query("""
select t
from TaskEntity t
join fetch t.client
where t.status = :status
""")
Page<TaskEntity> findByStatusWithClient(
@Param("status") String status,
Pageable pageable
);
Weil jeder Task einen Client hat.
Teste aber immer das generierte SQL und die Performance.
40. Relationship-Design für REST APIs
Schlechte API-Form:
{
"id": 1,
"name": "Client A",
"tasks": [
{
"id": 10,
"title": "Task 1",
"client": {
"id": 1,
"name": "Client A",
"tasks": [...]
}
}
]
}
Bessere DTO-Form:
{
"id": 1,
"name": "Client A",
"tasks": [
{
"id": 10,
"title": "Task 1",
"status": "OPEN"
}
]
}
Merksatz:
Die API-Antwortform sollte mit DTOs designed werden, nicht automatisch aus Entity Relationships generiert.
41. Gute DTO-Beispiele
Client-Detail:
public record ClientDetailDto(
Long id,
String name,
List<TaskSummaryDto> tasks
) {
}
Task-Zusammenfassung:
public record TaskSummaryDto(
Long id,
String title,
String status
) {
}
Task-Detail:
public record TaskDetailDto(
Long id,
String title,
String status,
Long clientId,
String clientName
) {
}
Beachte:
DTOs enthalten nur das, was der Endpoint braucht.
42. Beispiel: Task für Client erstellen
Controller:
@PostMapping("/api/clients/{clientId}/tasks")
public ResponseEntity<TaskDetailDto> createTask(
@PathVariable Long clientId,
@Valid @RequestBody CreateTaskRequest request
) {
TaskDetailDto created = taskService.createTask(clientId, request);
URI location = URI.create("/api/tasks/" + created.id());
return ResponseEntity.created(location).body(created);
}
Service:
@Transactional
public TaskDetailDto createTask(Long clientId, CreateTaskRequest request) {
ClientEntity client = clientRepository.findById(clientId)
.orElseThrow(() -> new ResourceNotFoundException("Client", clientId));
TaskEntity task = new TaskEntity(
request.title(),
"OPEN",
client
);
TaskEntity saved = taskRepository.save(task);
return new TaskDetailDto(
saved.getId(),
saved.getTitle(),
saved.getStatus(),
client.getId(),
client.getName()
);
}
Wichtig:
Parent-Entity finden.
Parent auf Child-Entity setzen.
Child-Entity speichern.
DTO zurückgeben.
43. Beispiel: Tasks mit Client-Namen auflisten
Repository:
@Query("""
select t
from TaskEntity t
join fetch t.client
where t.status = :status
order by t.createdAt desc
""")
List<TaskEntity> findByStatusWithClient(@Param("status") String status);
Service:
@Transactional(readOnly = true)
public List<TaskDetailDto> listTasks(String status) {
return taskRepository.findByStatusWithClient(status)
.stream()
.map(task -> new TaskDetailDto(
task.getId(),
task.getTitle(),
task.getStatus(),
task.getClient().getId(),
task.getClient().getName()
))
.toList();
}
Das vermeidet N+1 für Client-Namen.
44. Beispiel: Client mit Task-Zusammenfassungen
Repository:
@Query("""
select c
from ClientEntity c
left join fetch c.tasks
where c.id = :id
""")
Optional<ClientEntity> findByIdWithTasks(@Param("id") Long id);
Service:
@Transactional(readOnly = true)
public ClientDetailDto getClientDetail(Long id) {
ClientEntity client = clientRepository.findByIdWithTasks(id)
.orElseThrow(() -> new ResourceNotFoundException("Client", id));
List<TaskSummaryDto> tasks = client.getTasks()
.stream()
.map(task -> new TaskSummaryDto(
task.getId(),
task.getTitle(),
task.getStatus()
))
.toList();
return new ClientDetailDto(
client.getId(),
client.getName(),
tasks
);
}
Das kann für einen Client-Detail-Endpoint in Ordnung sein.
Bei paginierten Clients sei mit Collection-Fetch-Joins vorsichtiger.
45. Warnung zu @ManyToMany
@ManyToMany existiert, nutze es aber vorsichtig.
Beispiel:
User <-> Role
Student <-> Course
In echten Business-Apps ist oft eine Join-Entity besser.
Statt direktem Many-to-Many:
@ManyToMany
private Set<RoleEntity> roles;
Oft besser:
UserEntity
UserRoleEntity
RoleEntity
Warum?
Join-Tabelle braucht vielleicht extra Felder
createdAt
assignedBy
status
metadata
bessere Kontrolle
Merksatz:
Für komplexe Many-to-Many-Beziehungen nutze eine Join-Entity.
46. Warnung zu Entity equals und hashCode
Entity Relationships nutzen oft Collections.
Sei vorsichtig mit equals() und hashCode().
Schlechtes Design kann verursachen:
Duplikat-Probleme in Collections
Lazy-Loading-Probleme
unerwartetes Verhalten in Set
Probleme, bevor die ID generiert ist
Einfache Anfängerregel:
Sei vorsichtig mit Entities in HashSet.
Nimm lazy Beziehungen nicht in equals/hashCode auf.
Nutze vorerst List in Beispielen und vermeide komplizierte Gleichheitslogik, bis du sie brauchst.
47. Typische Prüfungsfallen
Prüfungsfalle 1
@ManyToOne ist meist die Owning Side.
Prüfungsfalle 2
Die Owning Side hat meist @JoinColumn.
Prüfungsfalle 3
mappedBy nutzt den Java-Feldnamen, nicht den Datenbank-Spaltennamen.
Prüfungsfalle 4
@ManyToOne ist standardmäßig eager — viele Projekte setzen es explizit auf lazy.
Prüfungsfalle 5
Lazy Loading braucht einen offenen Persistence Context.
Prüfungsfalle 6
Gib Entities nicht direkt aus REST Controllern zurück.
Prüfungsfalle 7
Bidirektionale Beziehungen können endlose JSON-Rekursion verursachen.
Prüfungsfalle 8
Cascade bedeutet nicht nur Datenbank-Foreign-Key-Cascade. Es ist JPA-Entity-Operations-Cascade.
Prüfungsfalle 9
Nutze CascadeType.ALL nicht automatisch überall.
Prüfungsfalle 10
Sei vorsichtig mit Cascade auf @ManyToOne.
Prüfungsfalle 11
Orphan Removal löscht Child-Rows, wenn sie aus der Parent-Collection entfernt werden.
Prüfungsfalle 12
N+1 bedeutet eine Haupt-Query plus viele Relationship-Queries.
Prüfungsfalle 13
Fetch Join kann N+1 beheben, aber Collection-Fetch-Joins mit Pagination sind gefährlich.
Prüfungsfalle 14
DTO Projections können besser sein als große Entity Graphs zu laden.
Prüfungsfalle 15
Nutze DTOs, um die API-Antwortform zu designen.
48. Echte Prüfungsfrage: @ManyToOne
Frage:
Was bedeutet @ManyToOne?
Antwort:
@ManyToOne bedeutet, dass viele Instanzen einer Entity mit einer Instanz einer anderen Entity verknüpft sind. Zum Beispiel gehören viele Tasks zu einem Client.
49. Echte Prüfungsfrage: @OneToMany
Frage:
Was bedeutet @OneToMany?
Antwort:
@OneToMany bedeutet, dass eine Entity eine Collection vieler verknüpfter Entities hat. Zum Beispiel hat ein Client viele Tasks.
50. Echte Prüfungsfrage: Owning Side
Frage:
Was ist die Owning Side einer JPA-Beziehung?
Antwort:
Die Owning Side ist die Seite, die die Datenbankbeziehung steuert — meist die Seite mit dem Foreign Key und @JoinColumn.
51. Echte Prüfungsfrage: mappedBy
Frage:
Was bedeutet mappedBy?
Antwort:
mappedBy markiert die Inverse Side einer bidirektionalen Beziehung und zeigt auf den Java-Feldnamen auf der Owning Side.
52. Echte Prüfungsfrage: Lazy Loading
Frage:
Was ist Lazy Loading?
Antwort:
Lazy Loading bedeutet, dass verknüpfte Entities erst geladen werden, wenn darauf zugegriffen wird — nicht sofort, wenn die Parent-Entity geladen wird.
53. Echte Prüfungsfrage: Eager Loading
Frage:
Was ist Eager Loading?
Antwort:
Eager Loading bedeutet, dass verknüpfte Entities sofort zusammen mit der besitzenden Entity geladen werden.
54. Echte Prüfungsfrage: Standard-Fetch
Frage:
Was ist der Standard-Fetch-Typ für @ManyToOne?
Antwort:
@ManyToOne ist standardmäßig eager — viele echte Projekte konfigurieren es explizit als lazy.
55. Echte Prüfungsfrage: Cascade
Frage:
Was ist Cascade?
Antwort:
Cascade bedeutet, dass eine Entity-Operation wie persist, merge oder remove von einer Entity auf ihre verknüpften Entities propagiert wird.
56. Echte Prüfungsfrage: Orphan Removal
Frage:
Was macht orphanRemoval = true?
Antwort:
Es löscht eine Child-Entity, wenn sie aus der Parent-Collection entfernt wird — vorausgesetzt, die Beziehung wird korrekt verwaltet.
57. Echte Prüfungsfrage: N+1
Frage:
Was ist das N+1-Query-Problem?
Antwort:
Das N+1-Problem tritt auf, wenn eine Query eine Liste von Entities lädt und dann für jede Entity eine zusätzliche Query ausgeführt wird, um eine verknüpfte Association zu laden.
58. Echte Prüfungsfrage: Fetch Join
Frage:
Wie kann ein Fetch Join bei N+1 helfen?
Antwort:
Ein Fetch Join lädt die Haupt-Entity und die benötigte Association in derselben Query und reduziert viele zusätzliche Relationship-Queries.
59. Interview-Antwort
Frage:
Erkläre
@ManyToOneund@OneToMany.
Gute Antwort:
@ManyToOne bedeutet, dass viele Child-Entities auf eine Parent-Entity zeigen, zum Beispiel viele Tasks, die zu einem Client gehören. Diese Seite hat meist den Foreign Key und @JoinColumn und ist daher oft die Owning Side. @OneToMany ist die Parent-seitige Collection, zum Beispiel ein Client mit vielen Tasks. In einer bidirektionalen Beziehung zeigt @OneToMany(mappedBy = "client") auf das Feld client in der Child-Entity.
60. Interview-Antwort
Frage:
Was ist die Owning Side in JPA?
Gute Antwort:
Die Owning Side ist die Seite, die die Beziehung in der Datenbank steuert. In einer typischen Many-to-One-Beziehung besitzt die Many-Seite die Beziehung, weil sie die Foreign-Key-Spalte hat. Zum Beispiel hat TaskEntity client_id, also ist TaskEntity.client die Owning Side. Die andere Seite nutzt mappedBy.
61. Interview-Antwort
Frage:
Was ist Lazy Loading und warum kann es gefährlich sein?
Gute Antwort:
Lazy Loading bedeutet, dass JPA verknüpfte Entities erst lädt, wenn darauf zugegriffen wird. Das ist nützlich, weil es unnötiges Laden vermeidet. Aber es kann gefährlich sein, wenn der Persistence Context schon geschlossen ist — das kann Lazy-Loading-Exceptions verursachen. Es kann auch N+1-Query-Probleme verursachen, wenn Code durch viele Entities iteriert und eine lazy Association einzeln aufruft.
62. Interview-Antwort
Frage:
Was ist das N+1-Query-Problem?
Gute Antwort:
N+1 tritt auf, wenn eine Query eine Liste von Entities lädt und dann für jede Entity eine zusätzliche Query ausgeführt wird, um verknüpfte Daten zu laden. Zum Beispiel lädt eine Query 100 Tasks, und dann laden 100 weitere Queries den Client jedes Tasks. Das erzeugt 101 Queries und kann die Performance stark beeinträchtigen. Es lässt sich mit Fetch Joins, Entity Graphs, DTO Projections oder besserem Query-Design beheben.
63. Interview-Antwort
Frage:
Was ist Cascade und wann musst du vorsichtig sein?
Gute Antwort:
Cascade bedeutet, dass Operationen auf einer Entity auf verknüpfte Entities propagiert werden. Zum Beispiel kann das Speichern eines Parents auch seine Children speichern. Das ist nützlich, wenn der Child-Lifecycle zum Parent gehört, wie Order Items in einer Order. Aber es ist gefährlich, es blind zu nutzen — besonders CascadeType.ALL oder Cascade Remove. Ich vermeide Cascade vom Child zum Parent, zum Beispiel @ManyToOne(cascade = ALL), weil das Löschen eines Childs einen geteilten Parent löschen könnte.
64. Interview-Antwort
Frage:
Warum sollten REST APIs JPA Entities nicht direkt zurückgeben?
Gute Antwort:
Entities direkt zurückzugeben kann interne Datenbankstruktur exponieren, sensible Felder leaken, Lazy Loading während der JSON-Serialisierung auslösen, endlose Rekursion in bidirektionalen Beziehungen verursachen und N+1-Performance-Probleme erzeugen. Ich mappe Entities lieber in der Service-Schicht auf DTOs und gebe DTOs aus Controllern zurück.
65. Kleine Code-Übung
Entity Relationship:
@Entity
public class TaskEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "client_id", nullable = false)
private ClientEntity client;
}
@Entity
public class ClientEntity {
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks = new ArrayList<>();
}
Fragen:
- Welche Seite besitzt die Beziehung?
- Was bedeutet
client_id? - Worauf zeigt
mappedBy = "client"? - Ist
TaskEntity.clientlazy oder eager? - Sollte diese Entity direkt aus dem REST Controller zurückgegeben werden?
Antworten:
TaskEntity.clientbesitzt die Beziehung.- Es ist die Foreign-Key-Spalte in der tasks-Tabelle.
- Es zeigt auf das Feld
clientinTaskEntity. - Lazy, weil
fetch = FetchType.LAZY. - Meist nein. Gib DTOs zurück.
66. Kleine Bug-Übung 1
Problem:
@OneToMany(mappedBy = "client_id")
private List<TaskEntity> tasks;
Frage:
Was ist falsch?
Antwort:
mappedBy muss den Java-Feldnamen auf der Owning Side nutzen, nicht den Datenbank-Spaltennamen.
Richtig:
@OneToMany(mappedBy = "client")
private List<TaskEntity> tasks;
67. Kleine Bug-Übung 2
Problem:
@ManyToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "client_id")
private ClientEntity client;
Frage:
Warum ist das gefährlich?
Antwort:
Viele Tasks können sich einen Client teilen. Cascade aller Operationen vom Task zum Client kann den geteilten Client versehentlich aktualisieren oder löschen, wenn du mit einem Task arbeitest. Vermeide Cascade vom Child zum Parent, außer du hast einen sehr spezifischen Grund.
68. Kleine Bug-Übung 3
Problem:
@GetMapping("/api/clients/{id}")
public ClientEntity getClient(@PathVariable Long id) {
return clientRepository.findById(id).orElseThrow();
}
Frage:
Was kann schiefgehen?
Antwort:
Die Entity direkt zurückzugeben kann Lazy-Loading-Fehler, endlose JSON-Rekursion, N+1-Queries während der Serialisierung, Exposition sensibler Daten und enge Kopplung zwischen API und Datenbankmodell verursachen. Gib stattdessen ein DTO zurück.
Übungsfragen
Frage 1
Was ist eine Entity Relationship?
Antwort:
Eine Entity Relationship mappt eine Verbindung zwischen Java Entities auf eine Datenbankbeziehung — meist über einen Foreign Key.
Frage 2
Was bedeutet @ManyToOne?
Antwort:
@ManyToOne bedeutet, dass viele Instanzen einer Entity mit einer Instanz einer anderen Entity verknüpft sind, zum Beispiel viele Tasks, die zu einem Client gehören.
Frage 3
Was bedeutet @OneToMany?
Antwort:
@OneToMany bedeutet, dass eine Entity eine Collection vieler verknüpfter Entities hat, zum Beispiel ein Client mit vielen Tasks.
Frage 4
Wo liegt der Foreign Key meist in einer Many-to-One-Beziehung?
Antwort:
Der Foreign Key liegt meist auf der Many-Seite, zum Beispiel tasks.client_id.
Frage 5
Was macht @JoinColumn?
Antwort:
@JoinColumn definiert die Foreign-Key-Spalte, die für die Beziehung genutzt wird.
Frage 6
Was ist die Owning Side?
Antwort:
Die Owning Side ist die Seite, die die Datenbankbeziehung steuert — meist die Seite mit @JoinColumn.
Frage 7
Was bedeutet mappedBy?
Antwort:
mappedBy markiert die Inverse Side einer bidirektionalen Beziehung und zeigt auf das Feld auf der Owning Side.
Frage 8
Nutzt mappedBy den Java-Feldnamen oder den Datenbank-Spaltennamen?
Antwort:
mappedBy nutzt den Java-Feldnamen, nicht den Datenbank-Spaltennamen.
Frage 9
Was ist Lazy Loading?
Antwort:
Lazy Loading bedeutet, dass verknüpfte Daten erst geladen werden, wenn darauf zugegriffen wird.
Frage 10
Was ist Eager Loading?
Antwort:
Eager Loading bedeutet, dass verknüpfte Daten sofort zusammen mit der Entity geladen werden.
Frage 11
Was ist der Standard-Fetch-Typ für @ManyToOne?
Antwort:
@ManyToOne ist standardmäßig eager.
Frage 12
Was kann LazyInitializationException verursachen?
Antwort:
Das passiert, wenn Code auf eine lazy Beziehung zugreift, nachdem der Persistence Context geschlossen ist.
Frage 13
Was ist Cascade?
Antwort:
Cascade bedeutet, dass Entity-Operationen von einer Entity auf verknüpfte Entities propagiert werden.
Frage 14
Warum ist CascadeType.ALL gefährlich, wenn du es überall nutzt?
Antwort:
Weil es persist, merge, remove, refresh und detach unerwartet propagieren kann und verknüpfte Daten versehentlich löschen oder ändern kann.
Frage 15
Was ist Orphan Removal?
Antwort:
Orphan Removal löscht eine Child-Entity, wenn sie aus der Parent-Collection entfernt wird.
Frage 16
Was ist der Unterschied zwischen Cascade Remove und Orphan Removal?
Antwort:
Cascade Remove löscht Children, wenn der Parent gelöscht wird. Orphan Removal löscht ein Child, wenn es aus der Parent-Collection entfernt wird.
Frage 17
Was ist das N+1-Query-Problem?
Antwort:
N+1 bedeutet, dass eine Query eine Liste lädt und dann für jedes Element eine zusätzliche Query ausgeführt wird, um verknüpfte Daten zu laden.
Frage 18
Wie kann Fetch Join bei N+1 helfen?
Antwort:
Ein Fetch Join lädt die Haupt-Entity und die benötigte Association in derselben Query und reduziert zusätzliche Queries.
Frage 19
Warum können bidirektionale Beziehungen JSON-Rekursion verursachen?
Antwort:
Weil Parent auf Child und Child auf Parent verweist — die JSON-Serialisierung kann dann endlos laufen.
Frage 20
Warum sollten REST APIs meist DTOs statt Entities zurückgeben?
Antwort:
DTOs vermeiden die Exposition interner Datenbankstruktur, verhindern Lazy-Loading- und Rekursionsprobleme, kontrollieren die Antwortform und schützen sensible Felder.
Finale Merksätze
- Entity Relationships mappen Java-Objektverbindungen auf Foreign Keys in der Datenbank.
@ManyToOnegehört meist auf die Child-Seite.@OneToManyrepräsentiert meist eine Parent-Collection.- Die Many-Seite besitzt meist den Foreign Key.
- Die Owning Side hat meist
@JoinColumn. mappedByzeigt auf das Java-Feld auf der Owning Side.mappedBynutzt keine Datenbank-Spaltennamen.- Lazy Loading lädt verknüpfte Daten erst beim Zugriff.
- Eager Loading lädt verknüpfte Daten sofort.
@ManyToOneist standardmäßig eager.- Bevorzuge in vielen echten Projekten lazy Associations und explizite Fetch Plans.
- Lazy Loading braucht einen offenen Persistence Context.
- Gib Entities nicht direkt aus REST APIs zurück.
- Bidirektionale Beziehungen können endlose JSON-Rekursion verursachen.
- Cascade propagiert Entity-Operationen.
- Nutze
CascadeType.ALLnicht blind. - Sei vorsichtig mit Cascade auf
@ManyToOne. - Orphan Removal löscht Children, die aus Parent-Collections entfernt werden.
- N+1 bedeutet eine Haupt-Query plus viele Relationship-Queries.
- Fetch Join, Entity Graph und DTO Projection können helfen, N+1 zu vermeiden.