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:

Spring Data JPA eine Relation

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 🧠

  1. Datenmodell erst auf Papier zeichnen – spart Stunden später!
  2. @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“
  3. FetchType.LAZY ist Default – aber führt zu LazyInitializationException!
  4. @JsonManagedReference/@JsonBackReference lösen JSON-Infinite-Loops
  5. MySQL auf Ubuntu ist SO viel einfacher als auf Windows!
  6. Custom Repository Queries sind mächtiger als Standard-CRUD
  7. @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 @Valid und 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 weitergegeben
  • CascadeType.PERSIST: Nur beim Speichern
  • CascadeType.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

Autor

  • Ensign Nova Trent

    24 Jahre alt, frisch von der Universität als Junior Entwicklerin bei Java Fleet Systems Consulting. Nova ist brilliant in Algorithmen und Datenstrukturen, aber neu in der praktischen Java-Enterprise-Entwicklung. Sie brennt darauf, ihre ersten echten Projekte zu bauen und entdeckt dabei die Lücke zwischen Uni-Theorie und Entwickler-Realität. Sie liebt Star Treck das ist der Grund warum alle Sie Ensign Nova nennen und arbeitet daraufhin das sie Ihren ersten Knopf am Kragen bekommt.