Spring Boot Aufbau-Kurs – Tag 3 von 10

📍 Deine Position im Kurs
| Tag | Thema | Status |
|---|---|---|
| 1 | Auto-Configuration & Custom Starter | Abgeschlossen ✅ |
| 2 | Spring Data JPA Basics | Abgeschlossen ✅ |
| → 3 | JPA Relationships & Queries | 👉 DU BIST HIER! |
| 4 | Spring Security – Part 1 | Kommt als nächstes |
| 5 | Spring Security – Part 2 | Noch nicht freigeschaltet |
| 6 | Caching & Serialisierung | Noch nicht freigeschaltet |
| 7 | Messaging & Email | Noch nicht freigeschaltet |
| 8 | Testing & Dokumentation | Noch nicht freigeschaltet |
| 9 | Spring Boot Actuator | Noch nicht freigeschaltet |
| 10 | Template Engines & Microservices | Noch nicht freigeschaltet |
Modul: Spring Boot Aufbau-Kurs
Dein Ziel: Komplexe Datenmodelle mit Relationships & fortgeschrittene Queries
⚡ Kurze Zusammenfassung – Das Wichtigste in 30 Sekunden
Heute erweitern wir dein Datenmodell um echte Beziehungen: Eine Person kann mehrere Adressen und Bestellungen haben. Du lernst, wie @OneToMany und @ManyToOne funktionieren, wie du bidirektionale Beziehungen synchron hältst und wie Spring Data JPA automatisch SQL aus deinen Methodennamen generiert – komplett ohne SQL zu schreiben!
Du lernst heute:
- ✅ OneToMany Relationships (Person → mehrere Adressen)
- ✅ ManyToOne Beziehungen richtig implementieren
- ✅ Bidirektionale Relationships synchron halten
- ✅ Query Methods nach Naming Convention
- ✅ LAZY vs EAGER Loading verstehen
📋 Voraussetzungen
Du brauchst:
- ✅ Tag 1 & Tag 2 abgeschlossen
- ✅ Person Entity mit @Entity, @Id verstehen
- ✅ JpaRepository nutzen können
- ✅ OneToOne Relationship (von gestern)
- ✅ MariaDB läuft und ist verbunden
Du solltest können:
- ✅ Entities erstellen
- ✅ CRUD-Operationen über Repository
- ✅ Basic JPA Annotations verstehen
💻 Los geht’s!
Hi Developer! 👋
Elyndra hier – heute wird es richtig spannend! Wir bauen komplexe Datenmodelle.
Warum OneToMany statt OneToOne?
Gestern haben wir OneToOne gelernt: Eine Person hatte genau eine Adresse. Das war einfach, aber nicht realistisch!
In der echten Welt:
- 🏠 Eine Person hat oft mehrere Adressen (Hauptwohnsitz, Zweitwohnsitz, Geschäftsadresse)
- 📦 Eine Person kann viele Bestellungen aufgeben
- 💳 Eine Person hat mehrere Zahlungsmethoden
Zeit, realistische Datenmodelle zu bauen! 🔧
🎯 Dein Lernpfad heute:
Du arbeitest heute in mehreren aufbauenden Schwierigkeitsstufen. Arbeite in deinem eigenen Tempo durch die Schritte:
🟢 GRUNDLAGEN (Schritte 1-4)
Was du lernst:
- Von OneToOne zu OneToMany migrieren
- Person mit mehreren Adressen modellieren
- @ManyToOne und @OneToMany verstehen
- Bidirectionale Relationships korrekt implementieren
- Helper-Methoden für Synchronisation
- Query Methods nach Naming Convention
Ziel: Du hast ein funktionierendes System mit Person → mehrere Adressen und kannst einfache Queries ohne SQL schreiben.
🟡 PROFESSIONAL (Schritte 5-6)
Was du lernst:
- Zweite OneToMany Relationship (Person → Orders)
- LAZY vs EAGER Loading verstehen und richtig einsetzen
- N+1 Query Problem erkennen und lösen
- Custom Queries mit @Query und JPQL
- Fetch Joins für Performance
Ziel: Du verstehst Performance-Aspekte und kannst komplexe Queries optimieren.
🔵 BONUS: Advanced Features (Schritt 7)
Was du baust:
- Pagination und Sorting
- Specifications für dynamische Queries
- EntityGraph für komplexe Fetch-Strategien
- Projection Interfaces für DTOs
Ziel: Du beherrschst Enterprise-Level JPA Features.
💡 Tipp: Die Grundlagen (🟢) sind heute besonders wichtig – Relationships sind das Fundament aller komplexen Datenmodelle. Professional (🟡) macht dich produktionsreif mit Performance-Optimierungen. Bonus (🔵) zeigt dir was in großen Enterprise-Projekten genutzt wird.
🟢 GRUNDLAGEN
Schritt 1: Von OneToOne zu OneToMany verstehen
Was du gestern gelernt hast
OneToOne: Person ↔ Address (1:1)
- Eine Person hat genau eine Adresse
- Einfach, aber nicht sehr realistisch
OneToOne: Person ←→ Address (1:1)
[Person 1] ←→ [Address 1]
Die Realität ist komplexer!
OneToMany: Person ←→ Adressen (1:N)
OneToMany: Person ←→ Adressen (1:N)
[Person 1] ←→ [Address 1]
←→ [Address 2]
←→ [Address 3]
Heute bauen wir:
- Person → mehrere Adressen (OneToMany)
- Person → mehrere Orders (OneToMany)
- Order → eine Person (ManyToOne)
🎉 AHA-Moment #1: „OneToMany ist perfekt für die reale Welt – ein Kunde hat viele Bestellungen, ein Autor viele Bücher, ein Unternehmen viele Mitarbeiter!“
Schritt 2: OneToMany – Person mit mehreren Adressen
Wir ändern unser Modell: Eine Person kann jetzt mehrere Adressen haben!
AddressType Enum erstellen
Zuerst definieren wir die Arten von Adressen, die wir unterstützen wollen.
Datei: src/main/java/com/javafleet/personmanagement/entity/AddressType.java
package com.javafleet.personmanagement.entity;
public enum AddressType {
HOME, // Hauptwohnsitz
WORK, // Geschäftsadresse
BILLING, // Rechnungsadresse
SHIPPING // Lieferadresse
}
Was passiert hier?
Ein enum ist eine spezielle Klasse für feste Wertemengen. Hier definieren wir vier mögliche Adresstypen. Das verhindert Tippfehler und gibt uns Type Safety – statt „home“ als String (könnte auch „Home“, „HOME“, „hoem“ sein) haben wir AddressType.HOME.
🎓 Lernhinweis: Enums sind perfekt für Status-Felder, Typen, Kategorien – immer wenn du eine begrenzte Menge an Optionen hast!
Address Entity anpassen
Jetzt fügen wir den Typ zur Address-Entity hinzu und erstellen die ManyToOne-Beziehung zur Person.
Datei: src/main/java/com/javafleet/personmanagement/entity/Address.java
package com.javafleet.personmanagement.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@Table(name = "addresses")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Straße darf nicht leer sein")
@Size(max = 100)
@Column(nullable = false, length = 100)
private String street;
@NotBlank(message = "Stadt darf nicht leer sein")
@Size(max = 50)
@Column(nullable = false, length = 50)
private String city;
@NotBlank(message = "PLZ darf nicht leer sein")
@Size(max = 10)
@Column(nullable = false, length = 10)
private String zipCode;
@Size(max = 50)
@Column(length = 50)
private String country;
// NEU: Typ der Adresse
@Enumerated(EnumType.STRING)
@Column(length = 20)
private AddressType type;
// NEU: Bidirectional ManyToOne
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id")
@ToString.Exclude // Wichtig für Lombok!
private Person person;
}
Lass uns jede neue Annotation im Detail verstehen:
@Enumerated(EnumType.STRING)
@Enumerated(EnumType.STRING) @Column(length = 20) private AddressType type;
Was macht das?
@Enumerated sagt JPA, wie der Enum in der Datenbank gespeichert werden soll:
EnumType.STRING→ Speichert „HOME“, „WORK“ etc. als Text in der DatenbankEnumType.ORDINAL→ Speichert 0, 1, 2, 3 (Position im Enum) ❌ NIE VERWENDEN!
Warum STRING statt ORDINAL?
Stell dir vor, du fügst später einen neuen Typ zwischen HOME und WORK ein:
// Vorher: HOME, // 0 WORK, // 1 BILLING, // 2 // Nachher: HOME, // 0 VACATION, // 1 ← NEU! WORK, // 2 ← Jetzt 2 statt 1! BILLING, // 3 ← Jetzt 3 statt 2!
Mit ORDINAL wären plötzlich alle WORK-Adressen als VACATION markiert! 😱
Mit STRING bleibt „WORK“ immer „WORK“, egal was du hinzufügst.
🎓 Lernhinweis: IMMER EnumType.STRING verwenden, NIE ORDINAL!
@ManyToOne
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "person_id") @ToString.Exclude private Person person;
Was macht das?
@ManyToOne definiert die „Many“-Seite einer Relationship:
- Many Addresses gehören zu One Person
- Aus Sicht der Address: „Ich gehöre zu einer Person“
Die drei Teile im Detail:
1. @ManyToOne(fetch = FetchType.LAZY)
LAZY= Person wird nicht automatisch geladen beim Laden der Address- Nur wenn du explizit
address.getPerson()aufrufst, wird die Person aus der DB geholt - Spart Performance, weil du nicht immer die Person brauchst
2. @JoinColumn(name = „person_id“)
- Erstellt eine Spalte
person_idin deraddresses-Tabelle - Diese Spalte ist ein Foreign Key zur
persons-Tabelle - Die „Many“-Seite hat immer den Foreign Key!
3. @ToString.Exclude
- Verhindert dass Lombok die
personintoString()einbaut - Wichtig bei bidirektionalen Relationships!
- Sonst:
Address.toString()→ ruftPerson.toString()→ ruftAddress.toString()→ StackOverflowError! 💥
🎓 Lernhinweis: Bei bidirektionalen Relationships IMMER @ToString.Exclude auf der „Many“-Seite verwenden!
🎉 AHA-Moment #2: „Der Foreign Key liegt immer auf der ‚Many‘-Seite – viele Adressen verweisen auf eine Person, nicht umgekehrt!“
Person Entity erweitern
Jetzt die andere Seite der Beziehung: Person hat jetzt eine Liste von Adressen.
Datei: src/main/java/com/javafleet/personmanagement/entity/Person.java
package com.javafleet.personmanagement.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "persons")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Vorname darf nicht leer sein")
@Size(max = 50)
@Column(nullable = false, length = 50)
private String firstname;
@NotBlank(message = "Nachname darf nicht leer sein")
@Size(max = 50)
@Column(nullable = false, length = 50)
private String lastname;
@Email(message = "Email muss valid sein")
@Column(unique = true)
private String email;
// GEÄNDERT: Von OneToOne zu OneToMany!
@OneToMany(
mappedBy = "person",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@ToString.Exclude // Wichtig für Lombok!
private List<Address> addresses = new ArrayList<>();
// Helper-Methoden für bidirectionale Relationship
public void addAddress(Address address) {
addresses.add(address);
address.setPerson(this);
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setPerson(null);
}
}
Lass uns @OneToMany im Detail verstehen:
@OneToMany Parameter erklärt
@OneToMany(
mappedBy = "person", // 1. Wer ist die andere Seite?
cascade = CascadeType.ALL, // 2. Was passiert bei Operationen?
orphanRemoval = true, // 3. Was mit "verwaisten" Entities?
fetch = FetchType.LAZY // 4. Wann laden?
)
1. mappedBy = „person“
- „Du bist nicht der Owner!“ – Address ist der Owner (hat den Foreign Key)
"person"verweist auf dasperson-Feld in derAddress-Entity- Die Relationship wird von Address.person gemappt (daher „mappedBy“)
- Ohne
mappedBywürde JPA eine zusätzliche Join-Tabelle erstellen! ❌
Regel: Die „One“-Seite hat mappedBy, die „Many“-Seite hat @JoinColumn.
2. cascade = CascadeType.ALL
- Operationen auf Person werden kaskadiert zu den Adressen
ALL= PERSIST + MERGE + REMOVE + REFRESH + DETACH
Was heißt das praktisch?
Person person = new Person("Max", "Mustermann");
Address address = new Address("Hauptstraße 1", "Berlin", "10115");
person.addAddress(address);
entityManager.persist(person);
// → Person UND Address werden gespeichert!
entityManager.remove(person);
// → Person UND alle Adressen werden gelöscht!
Andere Cascade-Optionen:
CascadeType.PERSIST→ Nur bei persist() kaskadierenCascadeType.REMOVE→ Nur bei remove() kaskadierenCascadeType.MERGE→ Nur bei merge() kaskadieren
3. orphanRemoval = true
- „Verwaiste“ Entities werden automatisch gelöscht
Was ist ein Orphan (Waise)? Eine Address ohne Person:
person.getAddresses().remove(address); // → Address ist jetzt ein Orphan (person_id = null) // → Mit orphanRemoval=true wird sie aus der DB gelöscht!
Ohne orphanRemoval:
person.getAddresses().remove(address); // → Address bleibt in DB mit person_id = null
4. fetch = FetchType.LAZY
- Adressen werden nicht automatisch geladen beim Laden der Person
- Nur wenn du
person.getAddresses()aufrufst, werden sie geholt - Wichtig für Performance!
LAZY vs EAGER:
// LAZY (Default für @OneToMany): Person person = repository.findById(1L); // SQL: SELECT * FROM persons WHERE id = 1 // Adressen werden NICHT geladen! person.getAddresses().size(); // SQL: SELECT * FROM addresses WHERE person_id = 1 // Jetzt werden Adressen geladen! // EAGER: Person person = repository.findById(1L); // SQL: SELECT * FROM persons p // LEFT JOIN addresses a ON p.id = a.person_id // WHERE p.id = 1 // Adressen werden SOFORT geladen!
🎓 Lernhinweis: Für @OneToMany ist LAZY der Default – und das ist gut so! EAGER kann zu Performance-Problemen führen (wir kommen im Professional-Teil dazu).
🎉 AHA-Moment #3: „Cascade und orphanRemoval machen mein Leben so viel einfacher – ich muss nur die Person speichern/löschen, und die Adressen folgen automatisch!“
Helper-Methoden – Warum?
public void addAddress(Address address) {
addresses.add(address); // 1. Adresse zur Liste hinzufügen
address.setPerson(this); // 2. Rückwärts-Referenz setzen!
}
public void removeAddress(Address address) {
addresses.remove(address); // 1. Adresse aus Liste entfernen
address.setPerson(null); // 2. Rückwärts-Referenz löschen!
}
Warum nicht einfach person.getAddresses().add(address)?
Bei bidirektionalen Relationships musst du beide Seiten synchron halten:
❌ Falsch:
Address address = new Address("Straße", "Stadt", "12345");
person.getAddresses().add(address);
// → address.getPerson() ist noch NULL!
// → Inkonsistenter Zustand!
✅ Richtig:
Address address = new Address("Straße", "Stadt", "12345");
person.addAddress(address);
// → address.getPerson() ist jetzt person
// → Beide Seiten konsistent!
Die Helper-Methoden garantieren:
- ✅ Beide Seiten der Relationship sind synchron
- ✅ Kein inkonsistenter Zustand möglich
- ✅ Sauberer, lesbarer Code
🎓 Lernhinweis: Bei bidirektionalen Relationships IMMER Helper-Methoden verwenden, NIE direkt die Collection manipulieren!
Schritt 3: Repository und Controller anpassen
Wir brauchen keine Änderungen am PersonRepository – es funktioniert automatisch mit der neuen Relationship!
Datei: src/main/java/com/javafleet/personmanagement/repository/PersonRepository.java
package com.javafleet.personmanagement.repository;
import com.javafleet.personmanagement.entity.Person;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
Optional<Person> findByEmail(String email);
List<Person> findByLastname(String lastname);
}
Warum funktioniert das ohne Änderungen?
JpaRepository arbeitet mit der Entity-Klasse. Alle Relationships (OneToOne, OneToMany, ManyToOne, ManyToMany) werden automatisch von JPA/Hibernate verwaltet. Du musst dich nicht um die Joins kümmern – das macht JPA für dich!
Controller für Person mit mehreren Adressen
Datei: src/main/java/com/javafleet/personmanagement/controller/PersonController.java
package com.javafleet.personmanagement.controller;
import com.javafleet.personmanagement.entity.Address;
import com.javafleet.personmanagement.entity.AddressType;
import com.javafleet.personmanagement.entity.Person;
import com.javafleet.personmanagement.repository.PersonRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/persons")
@RequiredArgsConstructor
public class PersonController {
private final PersonRepository personRepository;
// Alle Personen abrufen
@GetMapping
public List<Person> getAllPersons() {
return personRepository.findAll();
}
// Person mit ID abrufen
@GetMapping("/{id}")
public ResponseEntity<Person> getPersonById(@PathVariable Long id) {
return personRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// Neue Person mit Adressen erstellen
@PostMapping
public Person createPerson(@RequestBody Person person) {
// Helper-Methoden synchronisieren die Relationship automatisch!
return personRepository.save(person);
}
// Adresse zu existierender Person hinzufügen
@PostMapping("/{id}/addresses")
public ResponseEntity<Person> addAddress(
@PathVariable Long id,
@RequestBody Address address) {
return personRepository.findById(id)
.map(person -> {
person.addAddress(address); // Helper-Methode!
return ResponseEntity.ok(personRepository.save(person));
})
.orElse(ResponseEntity.notFound().build());
}
}
Was ist hier neu?
Der Endpoint POST /api/persons/{id}/addresses fügt eine neue Adresse zu einer existierenden Person hinzu:
person.addAddress(address); // Helper-Methode synchronisiert beide Seiten! personRepository.save(person); // Cascade speichert auch die neue Address!
Durch cascade = CascadeType.ALL wird die neue Address automatisch gespeichert!
Testen mit cURL
Person mit mehreren Adressen erstellen:
curl -X POST http://localhost:8080/api/persons \
-H "Content-Type: application/json" \
-d '{
"firstname": "Max",
"lastname": "Mustermann",
"email": "max@example.com",
"addresses": [
{
"street": "Hauptstraße 1",
"city": "Berlin",
"zipCode": "10115",
"country": "Deutschland",
"type": "HOME"
},
{
"street": "Geschäftsweg 42",
"city": "München",
"zipCode": "80333",
"country": "Deutschland",
"type": "WORK"
}
]
}'
Adresse zu existierender Person hinzufügen:
curl -X POST http://localhost:8080/api/persons/1/addresses \
-H "Content-Type: application/json" \
-d '{
"street": "Lieferadresse 7",
"city": "Hamburg",
"zipCode": "20095",
"country": "Deutschland",
"type": "SHIPPING"
}'
Person mit allen Adressen abrufen:
curl http://localhost:8080/api/persons/1
Funktioniert es? Dann hast du deine erste OneToMany-Relationship erfolgreich implementiert! 🎉
Schritt 4: Query Methods – SQL ohne SQL schreiben!
Jetzt kommt einer der coolsten Features von Spring Data JPA: Query Methods.
Du schreibst den Methodennamen, Spring Data JPA generiert automatisch das SQL!
Query Methods nach Naming Convention
Die Magie: Spring Data JPA parst deinen Methodennamen und erstellt daraus SQL:
// Methodenname: findByLastname List<Person> findByLastname(String lastname); // Wird zu SQL: // SELECT * FROM persons WHERE lastname = ?
So funktioniert’s:
- Spring Data JPA liest den Methodennamen
- Erkennt
findBy→ SELECT-Query - Erkennt
Lastname→ Das Feld aus der Person-Entity - Generiert automatisch:
SELECT * FROM persons WHERE lastname = ? - Parameter
String lastnamewird für?eingesetzt
🎉 AHA-Moment #4: „Ich kann komplexe Queries schreiben ohne SQL zu kennen! Spring Data JPA generiert das SQL automatisch aus meinen Methodennamen! Das ist unglaublich produktiv!“
PersonRepository erweitern
Datei: src/main/java/com/javafleet/personmanagement/repository/PersonRepository.java
package com.javafleet.personmanagement.repository;
import com.javafleet.personmanagement.entity.Person;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
// Finde nach Email (exact match)
Optional<Person> findByEmail(String email);
// Finde nach Nachname (exact match)
List<Person> findByLastname(String lastname);
// Finde nach Vorname (case-insensitive, partial match)
List<Person> findByFirstnameContainingIgnoreCase(String firstname);
// Finde nach Vor- und Nachname
List<Person> findByFirstnameAndLastname(String firstname, String lastname);
// Finde nach Nachname, sortiere nach Vorname
List<Person> findByLastnameOrderByFirstnameAsc(String lastname);
// Finde Personen mit Adresse in bestimmter Stadt
List<Person> findByAddresses_City(String city);
// Finde Personen mit bestimmtem Adresstyp
List<Person> findByAddresses_Type(AddressType type);
}
Lass uns jede Query-Methode verstehen:
findByEmail
Optional<Person> findByEmail(String email);
Generiertes SQL:
SELECT * FROM persons WHERE email = ?
Warum Optional?
- Wir erwarten maximal ein Ergebnis (email ist unique)
Optional<Person>sagt: „Kann eine Person sein, kann aber auch leer sein“- Verhindert NullPointerException!
findByLastname
List<Person> findByLastname(String lastname);
Generiertes SQL:
SELECT * FROM persons WHERE lastname = ?
Warum List?
- Mehrere Personen können denselben Nachnamen haben
- Ergebnis kann 0, 1 oder viele Personen sein
findByFirstnameContainingIgnoreCase
List<Person> findByFirstnameContainingIgnoreCase(String firstname);
Generiertes SQL:
SELECT * FROM persons WHERE LOWER(firstname) LIKE LOWER(CONCAT('%', ?, '%'))
Was macht das?
Containing→ LIKE ‚%value%‘ (Teilstring-Suche)IgnoreCase→ LOWER() auf beiden Seiten (Groß-/Kleinschreibung ignorieren)
Beispiel:
// Findet: "Maximilian", "Max", "Maximilian"
findByFirstnameContainingIgnoreCase("max");
🎓 Lernhinweis: Spring Data JPA hat aus Containing und IgnoreCase automatisch eine LIKE-Query mit LOWER() gemacht!
findByFirstnameAndLastname
List<Person> findByFirstnameAndLastname(String firstname, String lastname);
Generiertes SQL:
SELECT * FROM persons WHERE firstname = ? AND lastname = ?
Das And im Methodennamen wird zu AND in SQL!
findByAddresses_City
List<Person> findByAddresses_City(String city);
Generiertes SQL:
SELECT DISTINCT p.* FROM persons p INNER JOIN addresses a ON p.id = a.person_id WHERE a.city = ?
Was passiert hier?
Der Unterstrich _ navigiert durch die Relationship!
Addresses→ Die addresses-Liste in Person_City→ Das city-Feld in Address
Spring Data JPA erstellt automatisch den JOIN!
🎓 Lernhinweis: Mit _ kannst du durch Relationships navigieren und JPA erstellt die Joins automatisch!
Unterstützte Keywords
Spring Data JPA unterstützt viele Keywords für Query Methods:
Comparison:
findByAgeGreaterThan(int age)→age > ?findByAgeLessThan(int age)→age < ?findByAgeBetween(int start, int end)→age BETWEEN ? AND ?
String:
findByNameStartingWith(String prefix)→name LIKE 'prefix%'findByNameEndingWith(String suffix)→name LIKE '%suffix'findByNameContaining(String infix)→name LIKE '%infix%'findByNameIgnoreCase(String name)→LOWER(name) = LOWER(?)
Null:
findByEmailIsNull()→email IS NULLfindByEmailIsNotNull()→email IS NOT NULL
Boolean:
findByActiveTrue()→active = TRUEfindByActiveFalse()→active = FALSE
Collections:
findByAgeIn(List<Integer> ages)→age IN (?)findByAgeNotIn(List<Integer> ages)→age NOT IN (?)
Sorting:
findByLastnameOrderByFirstnameAsc(String lastname)→... ORDER BY firstname ASC
Limiting:
findTop5ByOrderByCreatedAtDesc()→... LIMIT 5findFirstByEmail(String email)→... LIMIT 1
🔗 Offizielle Dokumentation:
Spring Data JPA – Query Methods Reference
Testen der Query Methods
Erweitere den Controller mit Such-Endpunkten:
Datei: src/main/java/com/javafleet/personmanagement/controller/PersonController.java (erweitern)
// Suche nach Nachname
@GetMapping("/search/by-lastname")
public List<Person> searchByLastname(@RequestParam String lastname) {
return personRepository.findByLastname(lastname);
}
// Suche nach Email
@GetMapping("/search/by-email")
public ResponseEntity<Person> searchByEmail(@RequestParam String email) {
return personRepository.findByEmail(email)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// Suche Personen mit Adresse in Stadt
@GetMapping("/search/by-city")
public List<Person> searchByCity(@RequestParam String city) {
return personRepository.findByAddresses_City(city);
}
Testen mit cURL:
# Person nach Nachname finden curl "http://localhost:8080/api/persons/search/by-lastname?lastname=Mustermann" # Person nach Email finden curl "http://localhost:8080/api/persons/search/by-email?email=max@example.com" # Personen mit Adresse in Stadt finden curl "http://localhost:8080/api/persons/search/by-city?city=Berlin"
Kein einziges SQL Statement geschrieben! 🎉
🟡 PROFESSIONAL
Schritt 5: Zweite OneToMany – Person → Orders
Jetzt fügen wir eine zweite OneToMany-Relationship hinzu: Eine Person kann viele Bestellungen haben!
OrderStatus Enum
Datei: src/main/java/com/javafleet/personmanagement/entity/OrderStatus.java
package com.javafleet.personmanagement.entity;
public enum OrderStatus {
PENDING, // Wartend
CONFIRMED, // Bestätigt
SHIPPED, // Versandt
DELIVERED, // Zugestellt
CANCELLED // Storniert
}
Order Entity
Datei: src/main/java/com/javafleet/personmanagement/entity/Order.java
package com.javafleet.personmanagement.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "orders")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String orderNumber;
@Column(nullable = false)
private BigDecimal price;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status;
@Column(nullable = false)
private LocalDateTime orderDate;
// ManyToOne zur Person
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "person_id")
@ToString.Exclude
private Person person;
// Automatisch Datum setzen beim Erstellen
@PrePersist
protected void onCreate() {
if (orderDate == null) {
orderDate = LocalDateTime.now();
}
if (status == null) {
status = OrderStatus.PENDING;
}
}
}
Was ist @PrePersist?
@PrePersist
protected void onCreate() {
if (orderDate == null) {
orderDate = LocalDateTime.now();
}
}
@PrePersist ist ein Lifecycle Callback – eine Methode, die automatisch aufgerufen wird bevor die Entity in die Datenbank gespeichert wird.
Lifecycle Callbacks:
@PrePersist→ Vor dem ersten Speichern@PostPersist→ Nach dem ersten Speichern@PreUpdate→ Vor jedem Update@PostUpdate→ Nach jedem Update@PreRemove→ Vor dem Löschen@PostRemove→ Nach dem Löschen
Praktische Verwendung:
- Timestamps automatisch setzen (createdAt, updatedAt)
- Default-Werte setzen
- Validierung vor dem Speichern
- Audit-Logs erstellen
🎓 Lernhinweis: @PrePersist ist perfekt für Felder die immer einen Wert haben sollen, aber nicht vom User gesetzt werden!
Person Entity erweitern
Füge die orders-Liste zur Person hinzu:
Datei: src/main/java/com/javafleet/personmanagement/entity/Person.java (erweitern)
@Entity
@Table(name = "persons")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Person {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ... firstname, lastname, email ...
// Adressen (bereits vorhanden)
@OneToMany(
mappedBy = "person",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@ToString.Exclude
private List<Address> addresses = new ArrayList<>();
// NEU: Orders
@OneToMany(
mappedBy = "person",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
@ToString.Exclude
private List<Order> orders = new ArrayList<>();
// Helper-Methoden für Adressen (bereits vorhanden)
public void addAddress(Address address) {
addresses.add(address);
address.setPerson(this);
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setPerson(null);
}
// NEU: Helper-Methoden für Orders
public void addOrder(Order order) {
orders.add(order);
order.setPerson(this);
}
public void removeOrder(Order order) {
orders.remove(order);
order.setPerson(null);
}
}
Jetzt hat Person zwei OneToMany-Relationships:
- Person → mehrere Adressen
- Person → mehrere Orders
Beide nutzen das gleiche Pattern:
mappedBy→ andere Seite ist Ownercascade = ALL→ Operationen werden kaskadiertorphanRemoval = true→ Verwaiste Entities werden gelöschtfetch = LAZY→ Nicht automatisch laden- Helper-Methoden → Synchronisation garantiert
Schritt 6: LAZY vs EAGER & das N+1 Problem
Jetzt kommen wir zu einem der wichtigsten Performance-Themen in JPA: LAZY vs EAGER Loading und das N+1 Query Problem.
LAZY Loading (Default für @OneToMany)
Was ist LAZY?
- Related Entities werden nicht automatisch geladen
- Nur wenn du sie explizit abrufst
- Gut für Performance, weil nicht immer alles geladen wird
Beispiel:
Person person = personRepository.findById(1L).get(); // SQL: SELECT * FROM persons WHERE id = 1 // addresses und orders werden NICHT geladen! System.out.println(person.getFirstname()); // Kein zusätzliches SQL person.getAddresses().size(); // SQL: SELECT * FROM addresses WHERE person_id = 1 // Jetzt werden addresses geladen! person.getOrders().size(); // SQL: SELECT * FROM orders WHERE person_id = 1 // Jetzt werden orders geladen!
Vorteile:
- ✅ Schneller initial Load
- ✅ Weniger Daten übertragen
- ✅ Flexibel – du lädst nur was du brauchst
Nachteile:
- ❌ Mehrere separate Queries (kann zum N+1 Problem führen)
- ❌
LazyInitializationExceptionaußerhalb einer Transaction
EAGER Loading
Was ist EAGER?
- Related Entities werden sofort mit geladen
- Immer, auch wenn du sie nicht brauchst
- Standard für @ManyToOne und @OneToOne
Beispiel:
@ManyToOne(fetch = FetchType.EAGER) // EAGER explizit setzen private Person person; Address address = addressRepository.findById(1L).get(); // SQL: SELECT a.*, p.* // FROM addresses a // LEFT JOIN persons p ON a.person_id = p.id // WHERE a.id = 1 // Person wird SOFORT mit geladen!
Vorteile:
- ✅ Alles in einer Query
- ✅ Keine
LazyInitializationException - ✅ Kann in manchen Fällen effizienter sein
Nachteile:
- ❌ Immer alle Daten, auch wenn nicht benötigt
- ❌ Kann zu riesigen Queries führen
- ❌ Bei @OneToMany: Cartesian Product Problem!
Das N+1 Query Problem
Das Problem:
Wenn du eine Liste von Personen lädst und dann für jede Person die Adressen abrufst, entstehen N+1 Queries:
List<Person> persons = personRepository.findAll();
// Query 1: SELECT * FROM persons
for (Person person : persons) {
System.out.println(person.getAddresses().size());
// Query 2: SELECT * FROM addresses WHERE person_id = 1
// Query 3: SELECT * FROM addresses WHERE person_id = 2
// Query 4: SELECT * FROM addresses WHERE person_id = 3
// ... für jede Person eine Query!
}
Bei 100 Personen → 101 Queries! 😱
Das ist das N+1 Problem:
- 1 Query für alle Personen
- N Queries für die Related Entities (eine pro Person)
- = N+1 Queries
Lösung 1: Fetch Joins mit @Query
Lade alles in einer Query mit einem Fetch Join:
Datei: src/main/java/com/javafleet/personmanagement/repository/PersonRepository.java (erweitern)
package com.javafleet.personmanagement.repository;
import com.javafleet.personmanagement.entity.Person;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
// Query Methods (bereits vorhanden)
Optional<Person> findByEmail(String email);
List<Person> findByLastname(String lastname);
// NEU: Custom Query mit Fetch Join
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN FETCH p.addresses")
List<Person> findAllWithAddresses();
@Query("SELECT DISTINCT p FROM Person p " +
"LEFT JOIN FETCH p.addresses " +
"LEFT JOIN FETCH p.orders " +
"WHERE p.id = :id")
Optional<Person> findByIdWithAll(@Param("id") Long id);
}
Was macht @Query?
@Query("SELECT DISTINCT p FROM Person p LEFT JOIN FETCH p.addresses")
List<Person> findAllWithAddresses();
- Du schreibst eine JPQL-Query (Java Persistence Query Language)
- JPQL ist wie SQL, aber mit Entities statt Tabellen
JOIN FETCHsagt: „Lade die Related Entities in derselben Query“DISTINCTentfernt Duplikate (durch den Join entstehen mehrere Zeilen pro Person)
Das generierte SQL:
SELECT DISTINCT p.*, a.* FROM persons p LEFT JOIN addresses a ON p.id = a.person_id
Jetzt:
List<Person> persons = personRepository.findAllWithAddresses();
// Nur 1 Query für alle Personen MIT allen Adressen!
for (Person person : persons) {
System.out.println(person.getAddresses().size());
// Kein zusätzliches SQL mehr!
}
Von N+1 Queries → 1 Query! 🎉
🎓 Lernhinweis: JOIN FETCH ist die Standardlösung für das N+1 Problem. Nutze es immer wenn du weißt dass du die Related Entities brauchst!
🎉 AHA-Moment #5: „Mit Fetch Joins kann ich das N+1 Problem lösen und alle Daten in einer Query laden – perfekt für Performance!“
OrderRepository mit Query Methods
Datei: src/main/java/com/javafleet/personmanagement/repository/OrderRepository.java
package com.javafleet.personmanagement.repository;
import com.javafleet.personmanagement.entity.Order;
import com.javafleet.personmanagement.entity.OrderStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Finde nach Status
List<Order> findByStatus(OrderStatus status);
// Finde nach Person ID
List<Order> findByPersonId(Long personId);
// Finde nach Person Email
List<Order> findByPersonEmail(String email);
// Finde wo Preis größer als
List<Order> findByPriceGreaterThan(BigDecimal price);
// Finde nach Status und sortiere nach Datum
List<Order> findByStatusOrderByOrderDateDesc(OrderStatus status);
// Finde nach Person und Status
List<Order> findByPersonIdAndStatus(Long personId, OrderStatus status);
// Finde die neuesten 10 Orders
List<Order> findTop10ByOrderByOrderDateDesc();
// Custom Query mit Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.person WHERE o.status = :status")
List<Order> findByStatusWithPerson(@Param("status") OrderStatus status);
}
Auch hier nutzen wir Query Methods ohne SQL zu schreiben!
🔵 BONUS: Advanced Features
Schritt 7: Pagination, Specifications & EntityGraph
Diese Features zeige ich nur in der Übersicht – du kannst sie später vertiefen!
Pagination & Sorting
Für große Datensätze: Lade nicht alles auf einmal!
@Repository
public interface PersonRepository extends JpaRepository<Person, Long> {
// Pagination Support
Page<Person> findByLastname(String lastname, Pageable pageable);
}
// Controller:
@GetMapping
public Page<Person> getAllPersons(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "lastname,asc") String[] sort) {
Pageable pageable = PageRequest.of(page, size, Sort.by(sort[0]).ascending());
return personRepository.findAll(pageable);
}
Specifications (Dynamische Queries)
Baue Queries dynamisch zusammen:
public class PersonSpecifications {
public static Specification<Person> hasLastname(String lastname) {
return (root, query, cb) ->
cb.equal(root.get("lastname"), lastname);
}
public static Specification<Person> hasAddressInCity(String city) {
return (root, query, cb) -> {
Join<Person, Address> addresses = root.join("addresses");
return cb.equal(addresses.get("city"), city);
};
}
}
// Verwendung:
List<Person> persons = personRepository.findAll(
hasLastname("Mustermann").and(hasAddressInCity("Berlin"))
);
EntityGraph (Alternative zu Fetch Joins)
@EntityGraph(attributePaths = {"addresses", "orders"})
@Query("SELECT p FROM Person p WHERE p.id = :id")
Optional<Person> findByIdWithAll(@Param("id") Long id);
EntityGraph vs Fetch Join:
- EntityGraph: Annotation-basiert, wiederverwendbar
- Fetch Join: Query-basiert, spezifischer
✅ Checkpoint: Hast du Tag 3 geschafft?
Grundlagen (🟢):
- [ ] Du verstehst den Unterschied OneToOne vs OneToMany
- [ ] Du hast Person mit mehreren Adressen implementiert
- [ ] Du kennst @ManyToOne und mappedBy
- [ ] Du hast Helper-Methoden für bidirectionale Relationships
- [ ] Du kannst @ToString.Exclude richtig einsetzen
- [ ] Du hast Query Methods nach Naming Convention geschrieben
- [ ] Deine Queries funktionieren ohne SQL
Professional (🟡):
- [ ] Du verstehst LAZY vs EAGER Loading
- [ ] Du kennst das N+1 Query Problem
- [ ] Du hast @Query mit JPQL genutzt
- [ ] Du hast Fetch Joins für Performance implementiert
- [ ] Du verstehst @PrePersist und Lifecycle Callbacks
Bonus (🔵):
- [ ] Du hast Pagination gesehen
- [ ] Du kennst Specifications
- [ ] Du hast EntityGraph kennengelernt
- [ ] Du weißt wie Projection Interfaces funktionieren
Alles ✅? Du bist bereit für Tag 4!
Nicht alles funktioniert?
- Überprüfe ob beide Seiten der Relationship synchron sind
- Nutze die Helper-Methoden (
addAddress,addOrder) - Kontrolliere @ToString.Exclude auf beiden Seiten
- Lade das komplette Projekt unten herunter
🔥 Elyndras Real Talk:
Weißt du, Relationships in JPA sind wie… menschliche Beziehungen. Am Anfang denkst du, es ist einfach: OneToOne, fertig. Aber dann merkst du, die Realität ist komplexer. Menschen haben mehrere Adressen, mehrere Rollen, mehrere Verbindungen.
Und genau wie im echten Leben musst du bei bidirektionalen Relationships beide Seiten synchron halten. Eine Seite allein zu aktualisieren führt zu inkonsistentem Zustand – in der Datenbank UND im Leben.
Ich habe neulich mit Marcus über Beziehungskomplexität gesprochen. Er meinte, manchmal sind die komplizierten Datenmodelle einfacher zu verstehen als echte Gefühle. Vielleicht hat er recht. In der Datenbank gibt es klare Regeln: CascadeType, orphanRemoval, FetchType. Im Leben? Da gibt’s kein Schema.
Aber das ist ein Thema für einen anderen Ort. Für private logs und Herz Schmerz. Wenn du verstehen willst, was ich meine, such mal nach diesen Begriffen. Aber Vorsicht – es wird emotional. 🔒
Keep coding, keep learning. 💙
❓ FAQ (Häufige Fragen)
Q: Was ist der Unterschied zwischen @OneToMany und @ManyToOne?
A: Das sind die zwei Seiten derselben Relationship! @OneToMany steht auf der „One“-Seite (Person), @ManyToOne auf der „Many“-Seite (Address). Zusammen bilden sie eine bidirektionale Relationship.
Q: Warum @ToString.Exclude?
A: Bei bidirektionalen Relationships würde toString() sonst endlos hin- und herspringen (Person → Address → Person → …) und einen StackOverflowError werfen!
Q: Was passiert bei Brownfield-Projekten mit existierenden Relationships?
A: Bei existierenden Datenbanken NIEMALS ddl-auto=update nutzen! Nutze Flyway-Migrations um Relationships hinzuzufügen. Ein späterer Tag behandelt Flyway für Brownfield-Szenarien!
Q: Wann EAGER statt LAZY Loading?
A: Fast nie! LAZY ist der Default und sollte fast immer genutzt werden. EAGER nur wenn du 100% sicher bist dass die Related Entities IMMER gebraucht werden.
Q: Wie verhindere ich das N+1 Query Problem?
A: Nutze Fetch Joins in @Query (JOIN FETCH) oder EntityGraphs. Das haben wir im Professional-Teil behandelt!
Q: Warum Helper-Methoden statt direkt die Collection manipulieren?
A: Bei bidirektionalen Relationships musst du beide Seiten synchron halten. Helper-Methoden (addAddress, removeAddress) garantieren das. Ohne sie riskierst du inkonsistente Zustände!
Q: Was macht ihr bei persönlichen Problemen zwischen den Projekten?
A: Das ist… kompliziert. Manche Geschichten gehören nicht in Tech-Blogs. Die gehören zu Herz Schmerz und private logs. Aber das ist ein anderes Kapitel. 🔒
📅 Nächster Kurstag: Tag 4
Morgen im Kurs / Nächster Blogbeitrag:
„Tag 4: Spring Security – Part 1 – Authentication & Authorization“
Was du lernen wirst:
- Spring Security Grundlagen
- Authentication vs Authorization
- UserDetailsService implementieren
- Password Encoding mit BCrypt
- Login/Logout Mechanismen
- Method Security mit @PreAuthorize
Warum wichtig? Ohne Security ist deine API offen für jeden! Spring Security schützt deine Endpoints und Daten vor unauthorisierten Zugriffen.
Voraussetzung: Tag 3 abgeschlossen
📚 Deine Fortschritts-Übersicht
| Tag | Thema | Status |
|---|---|---|
| ✅ 1 | Auto-Configuration & Custom Starter | ABGESCHLOSSEN! 🎉 |
| ✅ 2 | Spring Data JPA Basics | ABGESCHLOSSEN! 🎉 |
| ✅ 3 | JPA Relationships & Queries | ABGESCHLOSSEN! 🎉 |
| → 4 | Spring Security – Part 1 | Als nächstes |
| 5 | Spring Security – Part 2 | Noch offen |
| 6 | Caching & Serialisierung | Noch offen |
| 7 | Messaging & Email | Noch offen |
| 8 | Testing & Dokumentation | Noch offen |
| 9 | Spring Boot Actuator | Noch offen |
| 10 | Template Engines & Microservices | Noch offen |
Du hast 30% des Kurses geschafft! 💪
Alle Blogbeiträge dieser Serie:
👉 Spring Boot Aufbau-Kurs – Komplette Übersicht
📥 Download & Ressourcen
Projekt zum Download:
👉 Tag3-Spring-Boot-Aufbau-JPA-Relationships.zip (GitHub-Link folgt unten)
Was ist im ZIP enthalten:
- ✅ Komplettes Maven-Projekt mit Relationships
- ✅ Person mit mehreren Adressen und Orders
- ✅ Alle Query Methods implementiert
- ✅ Test-Daten SQL-Script
- ✅ cURL-Testskript
Projekt starten:
# Projekt klonen oder ZIP entpacken cd Tag3-Spring-Boot-Aufbau-JPA-Relationships mvn spring-boot:run # Testen curl http://localhost:8080/api/persons
Probleme? Schreib mir: elyndra@java-developer.online
Das war Tag 3 vom Spring Boot Aufbau-Kurs!
Du kannst jetzt:
- ✅ OneToMany und ManyToOne Relationships implementieren
- ✅ Bidirectionale Relationships korrekt synchronisieren
- ✅ Helper-Methoden für Relationship-Management schreiben
- ✅ @ToString.Exclude für Infinite Loops einsetzen
- ✅ Query Methods ohne SQL schreiben
- ✅ @PrePersist für automatische Defaults nutzen
- ✅ Komplexe Datenmodelle mit mehreren Relationships bauen
- ✅ Das N+1 Problem erkennen und mit Fetch Joins lösen
Morgen geht’s um Security – wir schützen deine API! 🔒
Keep coding, keep learning! 💙
Tag 4 erscheint morgen. Bis dahin: Happy Coding!
„Relationships sind das Herzstück jeder Datenbank – genau wie im echten Leben!“ – Elyndra Valen
Tags: #SpringBoot #JPA #Relationships #OneToMany #ManyToOne #QueryMethods #Hibernate #Tutorial #Tag3

