Von Nova Trent, Junior Entwicklerin bei Java Fleet Systems Consulting
Das Wichtigste in 30 Sekunden ⏱️
Meine TaskApp war zu simpel – nur Tasks ohne Benutzer oder Kategorien!
Heute: @OneToMany, @ManyToOne und @JoinColumn Annotationen gelernt.
Größter Schock: LazyInitializationException – was zum Henker ist das?! 😅
MySQL auf Ubuntu läuft übrigens SO smooth – sudo apt install mysql und fertig!
Am Ende: Echte Relationen funktionieren, aber Hibernate-Logs sind verwirrend wie Star Trek-Technobabble! 🚀
Hi Leute! 👋 Nova hier – diesmal mit Datenbank-Relationen-Drama
Letzte Woche hatte ich meine Java-Umgebung auf Ubuntu aufgesetzt. Jetzt wollte ich endlich meine TaskApp realistischer machen – denn ehrlich: Tasks ohne Benutzer? Das macht keinen Sinn!
Plan: Users, Categories und Tasks mit echten Spring Data JPA Relationen verknüpfen.
Realität: 4 Stunden Hibernate-Debugging! 😅
Schritt 1: Datenmodell planen – Was gehört zusammen?
Bevor ich wild drauf los programmiert habe, hat Elyndra mir gesagt: „Nova, zeichne erst mal auf Papier, wie deine Entitäten zusammenhängen!“
Mein Datenmodell:

In Worten:
- Ein User kann viele Tasks haben (
@OneToMany) - Eine Category kann viele Tasks enthalten (
@OneToMany) - Jede Task gehört zu genau einem User (
@ManyToOne) - Jede Task gehört zu genau einer Category (
@ManyToOne)
Schritt 2: MySQL statt H2 – Ubuntu macht’s einfach!
Für echte Spring Data JPA Relationen wollte ich von der H2-Memory-Database zu MySQL wechseln.
Auf Ubuntu: Unglaublich einfach! 🎉
# MySQL installieren sudo apt update sudo apt install mysql mysql-contrib # MySQL Service starten sudo systemctl start mysql sudo systemctl enable mysql # Database und User erstellen mysql -u root -p
In der MySQL-Konsole:
CREATE DATABASE taskapp; CREATE USER 'nova'@'localhost' IDENTIFIED BY 'secret123'; GRANT ALL PRIVILEGES ON taskapp.* TO 'nova'@'localhost'; \q
application.properties anpassen:
# MySQL Connection spring.datasource.url=jdbc:mysql://localhost:5432/taskapp spring.datasource.username=nova spring.datasource.password=secret123 spring.datasource.driver-class-name=org.mysql.Driver # JPA/Hibernate Settings spring.jpa.hibernate.ddl-auto=create-drop spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
pom.xml Dependency hinzufügen:
<dependency>
<groupId>org.mysql</groupId>
<artifactId>mysql</artifactId>
<scope>runtime</scope>
</dependency>
Das war’s! Auf Windows hätte ich einen MySQL-Installer durchlaufen müssen. Ubuntu: 3 Befehle, fertig! 🚀
Schritt 3: User Entity – Die Basis
package de.javafleet.taskmanager.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "users") // "user" ist reserved keyword in MySQL!
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String email;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
// OneToMany: Ein User hat viele Tasks
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Task> tasks = new ArrayList<>();
// OneToMany: Ein User hat viele Categories
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Category> categories = new ArrayList<>();
// Constructors, Getters, Setters...
public User() {
this.createdAt = LocalDateTime.now();
}
public User(String username, String email) {
this();
this.username = username;
this.email = email;
}
// Getters und Setters hier...
}
Wichtige Erkenntnisse:
- @Table(name = „users“) weil „user“ ein reserved keyword in MySQL ist! 😤
- mappedBy = „user“ bedeutet: „Die Relation wird in der Task-Entity über das ‚user‘-Feld gemappt“
- FetchType.LAZY = Tasks werden nur geladen, wenn explizit abgerufen
Schritt 4: Category Entity – Kategorien für Tasks
package de.javafleet.taskmanager.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
private String description;
@Column(nullable = false)
private String color; // Hex-Color für UI: #FF5733
// ManyToOne: Jede Category gehört zu einem User
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
// OneToMany: Eine Category hat viele Tasks
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Task> tasks = new ArrayList<>();
// Constructors, Getters, Setters...
public Category() {}
public Category(String name, String description, String color, User user) {
this.name = name;
this.description = description;
this.color = color;
this.user = user;
}
// Getters und Setters hier...
}
Verwirrend war:
- @JoinColumn(name = „user_id“) erstellt eine Foreign Key-Spalte in der categories-Tabelle
- mappedBy = „category“ in der Task-Entity verweist auf dieses Mapping
Schritt 5: Task Entity erweitern – Relationen hinzufügen
package de.javafleet.taskmanager.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
@Column(nullable = false)
private Boolean completed = false;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "due_date")
private LocalDateTime dueDate;
// ManyToOne: Jede Task gehört zu einem User
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
// ManyToOne: Jede Task gehört zu einer Category
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
// Constructors, Getters, Setters...
public Task() {
this.createdAt = LocalDateTime.now();
}
public Task(String title, String description, User user, Category category) {
this();
this.title = title;
this.description = description;
this.user = user;
this.category = category;
}
// Getters und Setters hier...
}
Schritt 6: Der erste Test – und der LazyInitializationException-Schock! 😱
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserRepository userRepository;
@GetMapping("/{id}/tasks")
public List<Task> getUserTasks(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return user.getTasks(); // 💥 BOOM! LazyInitializationException
}
}
Der Fehler:
org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: de.javafleet.taskmanager.entity.User.tasks, could not initialize proxy - no Session
Was zum Henker?! 😵
Elyndra’s Erklärung:
„Nova, FetchType.LAZY bedeutet: Hibernate lädt die Tasks erst, wenn du sie explizit abrufst. Aber außerhalb einer Transaktion (also außerhalb des Repository-Calls) ist die Hibernate-Session geschlossen!“
Die Lösungen:
Lösung 1: @Transactional verwenden
@RestController
@RequestMapping("/api/users")
@Transactional // Hält die Hibernate-Session offen!
public class UserController {
@GetMapping("/{id}/tasks")
public List<Task> getUserTasks(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return user.getTasks(); // Funktioniert jetzt!
}
}
Lösung 2: Custom Repository Query
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT u FROM User u LEFT JOIN FETCH u.tasks WHERE u.id = :id")
Optional<User> findByIdWithTasks(@PathVariable Long id);
}
@GetMapping("/{id}/tasks")
public List<Task> getUserTasks(@PathVariable Long id) {
User user = userRepository.findByIdWithTasks(id).orElseThrow();
return user.getTasks(); // Funktioniert auch!
}
Ich nahm Lösung 2 – expliziter und kontrollierbarer!
Schritt 7: JSON-Serialization-Problem – Infinite Recursion! 🔄
Beim ersten API-Test:
curl http://localhost:8080/api/users/1/tasks
Fehler:
{
"timestamp": "2025-09-09T10:30:00",
"status": 500,
"error": "Internal Server Error",
"message": "Could not write JSON: Infinite recursion (StackOverflowError)"
}
Das Problem: Bidirektionale Relationen!
- User → Tasks → User → Tasks → User… 🔄
Die Lösung: @JsonManagedReference und @JsonBackReference
// In User.java
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JsonManagedReference("user-tasks") // "Forward" part der Relation
private List<Task> tasks = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JsonManagedReference("user-categories")
private List<Category> categories = new ArrayList<>();
// In Task.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonBackReference("user-tasks") // "Back" part der Relation
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
@JsonBackReference("category-tasks")
private Category category;
// In Category.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonBackReference("user-categories")
private User user;
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JsonManagedReference("category-tasks")
private List<Task> tasks = new ArrayList<>();
Jetzt funktioniert’s! ✅
Schritt 8: Repository Layer erweitern
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByUserId(Long userId);
List<Task> findByCategoryId(Long categoryId);
List<Task> findByUserIdAndCompleted(Long userId, Boolean completed);
@Query("SELECT t FROM Task t WHERE t.user.id = :userId AND t.dueDate < :date")
List<Task> findOverdueTasksByUser(@Param("userId") Long userId, @Param("date") LocalDateTime date);
}
public interface CategoryRepository extends JpaRepository<Category, Long> {
List<Category> findByUserId(Long userId);
Optional<Category> findByUserIdAndName(Long userId, String name);
}
Schritt 9: Test Data mit DataLoader
@Component
public class DataLoader implements CommandLineRunner {
@Autowired
private UserRepository userRepository;
@Autowired
private CategoryRepository categoryRepository;
@Autowired
private TaskRepository taskRepository;
@Override
public void run(String... args) {
if (userRepository.count() == 0) {
loadTestData();
}
}
private void loadTestData() {
// User erstellen
User nova = new User("nova", "nova@javafleet.de");
nova = userRepository.save(nova);
// Categories erstellen
Category workCategory = new Category("Work", "Berufliche Aufgaben", "#FF5733", nova);
Category personalCategory = new Category("Personal", "Private Termine", "#33FF57", nova);
workCategory = categoryRepository.save(workCategory);
personalCategory = categoryRepository.save(personalCategory);
// Tasks erstellen
Task task1 = new Task("Spring Data JPA lernen", "Relationen verstehen", nova, workCategory);
Task task2 = new Task("Einkaufen gehen", "Milch, Brot, Käse", nova, personalCategory);
Task task3 = new Task("Code Review machen", "Elyndra's PR reviewen", nova, workCategory);
taskRepository.saveAll(List.of(task1, task2, task3));
System.out.println("✅ Test data loaded!");
}
}
Schritt 10: API testen – funktioniert es endlich?
# Alle Users abrufen
curl http://localhost:8080/api/users
# Tasks eines Users abrufen
curl http://localhost:8080/api/users/1/tasks
# Categories eines Users abrufen
curl http://localhost:8080/api/users/1/categories
# Neue Task erstellen
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{
"title": "Ubuntu Docker lernen",
"description": "Container auf Linux verstehen",
"user": {"id": 1},
"category": {"id": 1}
}'
Es funktioniert! 🎉
Was ich dabei gelernt habe 🧠
- Datenmodell erst auf Papier zeichnen – spart Stunden später!
- @OneToMany vs @ManyToOne verstehen:
@OneToMany(mappedBy = "...")= „Ich bin die ‚1‘-Seite, aber die Relation wird drüben gemappt“@ManyToOne + @JoinColumn= „Ich bin die ’n‘-Seite und habe den Foreign Key“
- FetchType.LAZY ist Default – aber führt zu LazyInitializationException!
- @JsonManagedReference/@JsonBackReference lösen JSON-Infinite-Loops
- MySQL auf Ubuntu ist SO viel einfacher als auf Windows!
- Custom Repository Queries sind mächtiger als Standard-CRUD
- @Transactional hält Hibernate-Sessions am Leben
Ubuntu-Bonus: MySQL-Tools 🐧
Seit ich Ubuntu nutze, sind MySQL-Tools endlich sinnvoll:
# MySQL-Konsole direkt aufrufen mysql -u root -p taskapp # Database-Status checken sudo systemctl status mysql # Logs anschauen sudo journalctl -u mysql # pgAdmin4 installieren (Web-Interface) sudo apt install pgadmin4
Auf Windows war das immer kompliziert mit Services und Pfaden. Ubuntu macht es intuitiv!
Nächste Schritte 🚀
Diese Woche:
- DTO-Pattern implementieren (Entity ≠ API Response)
- Validation mit
@Validund Custom Validators - Pagination für große Task-Listen
Nächster Learning Monday:
- „DTO vs Entity – Warum meine API zu viel preisgibt“
FAQ – Häufige Fragen 🤔
Frage 1: Was ist der Unterschied zwischen @OneToMany und @ManyToOne?
Antwort:
@OneToMany: „Ich bin die ‚1‘-Seite und habe eine Liste von vielen Objekten“@ManyToOne: „Ich bin die ’n‘-Seite und gehöre zu einem Objekt“
Frage 2: Warum LazyInitializationException?
Antwort: FetchType.LAZY lädt Relationen erst bei Zugriff, aber nur innerhalb einer aktiven Hibernate-Session. Lösung: @Transactional oder explizite Queries mit JOIN FETCH.
Frage 3: mappedBy vs @JoinColumn – wann was?
Antwort:
mappedBy: „Die andere Seite der Relation verwaltet den Foreign Key“@JoinColumn: „ICH verwalte den Foreign Key in meiner Tabelle“
Frage 4: Warum @JsonManagedReference/@JsonBackReference?
Antwort: Verhindert infinite JSON-Loops bei bidirektionalen Relationen. Alternative: @JsonIgnore auf einer Seite.
Frage 5: FetchType.EAGER vs LAZY?
Antwort:
EAGER: Lädt Relationen sofort mit (kann Performance-Probleme verursachen)LAZY: Lädt nur bei Bedarf (Standard, aber LazyInitializationException möglich)
Frage 6: Warum @Table(name = „users“) statt „user“?
Antwort: „user“ ist reserved keyword in MySQL und anderen Datenbanken.
Frage 7: CASCADE-Typen – was bedeuten sie?
Antwort:
CascadeType.ALL: Alle Operationen werden weitergegebenCascadeType.PERSIST: Nur beim SpeichernCascadeType.REMOVE: Nur beim Löschen
Frage 8: Wie teste ich Relationen?
Antwort: CommandLineRunner mit Testdaten oder @DataJpaTest für Unit-Tests.
Frage 9: MySQL vs MySQL für Development?
Antwort: Beide sind super! MySQL ist weiter verbreitet, MySQL hat mehr Features. Für Anfänger: MySQL ist einfacher zu starten. Den Vergleich erkläre ich in einem eigenen Blogpost!
Frage 10: Bidirektionale Relationen – immer nötig?
Antwort: Nein! Oft reicht unidirektional. Bidirektional nur wenn beide Seiten navigiert werden müssen.
Nova schreibt jeden Montag über ihre Lernreise – nächste Woche geht’s um DTOs und API-Design! 📡
Tags: #SpringDataJPA #MySQL #Hibernate #Relationen #OneToMany #ManyToOne #LearningMonday #NovaTrent

