Zum Hauptinhalt springen

AB 02 – Personen speichern und lesen

Die Situation

Das Grundgerüst steht. Jetzt soll es um die eigentliche Aufgabe gehen: Personen müssen dauerhaft gespeichert werden. Die Personalabteilung will neue Mitarbeiter anlegen und einzelne Datensätze wieder abrufen können.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • eine Klasse als JPA-Entität kennzeichnen und erklären, was Hibernate daraus macht
  • ein Repository anlegen und begründen, warum du dafür keine Implementierung schreiben musst
  • eine H2-Datenbank konfigurieren und ihren Inhalt über die Web-Konsole prüfen
  • einen POST- und einen GET-Endpunkt implementieren
  • Konstruktor-Injektion anwenden und erklären, was Spring dabei tut
  • passende HTTP-Statuscodes zurückgeben, auch im Fehlerfall

Die drei Schichten

Fast jede Backend-Anwendung ist in Schichten aufgebaut. Jede hat genau eine Aufgabe:

ClientBrowser, App, anderes ProgrammHTTP-Anfrage mit JSONController@RestControllernimmt Anfragen entgegenJava-MethodenaufrufRepositoryextends JpaRepositoryliest und schreibt DatensätzeSQL — von Hibernate erzeugtH2-DatenbankTabelle PERSONModel / Entity@Entity class Personbeschreibt, wie die Datenaussehen — und wird vonbeiden Schichten benutzt
Kommt dir bekannt vor?

Im ersten Lehrjahr hast du im NHPlus-Projekt eine DAO-Klasse geschrieben — mit JDBC-Verbindung und selbst formulierten SQL-Anweisungen.

Das Repository ist dasselbe Konzept. Der Unterschied: Du schreibst es nicht mehr selbst.

Aufgaben

Aufgabe 1 – Die Datenbank konfigurieren

src/main/resources/application.properties
spring.application.name=personenverwaltung

# --- Datenbank ---
spring.datasource.url=jdbc:h2:mem:persondb
spring.datasource.username=sa
spring.datasource.password=

# --- H2-Console im Browser ---
spring.h2.console.enabled=true

# --- Hibernate ---
# Legt die Tabellen beim Start an und raeumt beim Beenden auf.
# Das ist auch die Voreinstellung fuer eine eingebettete Datenbank -
# hier steht sie ausdruecklich da, damit man sie spaeter aendern kann.
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=false

# --- Fehlerantworten nach RFC 9457 ---
spring.mvc.problemdetails.enabled=true
server.error.include-stacktrace=never

Was die wichtigen Zeilen bewirken:

ZeileBedeutung
jdbc:h2:mem:persondbH2 läuft im Hauptspeicher (mem). Die Daten sind nach dem Beenden weg. Für die Entwicklung ideal, weil nichts installiert werden muss.
username=sa, password=Zugangsdaten. Genau diese trägst du gleich in der H2-Console ein.
spring.h2.console.enabled=trueSchaltet die Weboberfläche der Datenbank frei.
ddl-auto=create-dropHibernate legt die Tabellen beim Start an und löscht sie beim Beenden. Für eine eingebettete Datenbank ist das ohnehin die Voreinstellung.
spring.jpa.show-sql=trueZeigt dir im Log, welches SQL Hibernate erzeugt. Sehr lehrreich.
problemdetails.enabled=trueFehler werden im Standardformat RFC 9457 zurückgegeben.
include-stacktrace=neverVerhindert, dass interne Programmdetails nach außen gelangen.
Häufigster Fehler bei der H2-Console

Die JDBC-URL, die du gleich in der Console eingibst, muss exakt mit der Zeile in dieser Datei übereinstimmen. Weicht sie ab, verbindet sich die Console mit einer anderen, leeren Datenbank — und du wunderst dich, wo deine Daten sind.

Aufgabe 2 – Die Entität Person

src/main/java/de/szut/personenverwaltung/model/Person.java
package de.szut.personenverwaltung.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Person {

@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;

private String firstname;

private String surname;

public Person() {
}

// Getter und Setter für alle drei Attribute
// (von der Entwicklungsumgebung erzeugen lassen)
}

Die Annotationen im Einzelnen:

AnnotationWirkung
@EntityMacht die Klasse zu einer JPA-Entität. Hibernate legt beim Start automatisch eine Tabelle PERSON an, jedes Attribut wird eine Spalte.
@IdKennzeichnet das Attribut als Primärschlüssel.
@GeneratedValueDer Wert wird automatisch vergeben, nicht vom Programm gesetzt. Bei AUTO wählt Hibernate das Verfahren, das zur Datenbank passt.
Der parameterlose Konstruktor muss bleiben

Hibernate erzeugt Objekte über den parameterlosen Konstruktor und füllt sie anschließend. Fehlt er, bekommst du beim Start eine schwer verständliche Fehlermeldung.

Genau deshalb kann eine Entität kein Record sein: Ein Record hat nur den Konstruktor mit allen Werten, seine Felder sind final, und Hibernate müsste nach dem Einfügen die erzeugte id nachträglich hineinschreiben — was bei final nicht geht.

Ausführlich steht das im Infoblatt JPA und Hibernate.

Aufgabe 3 – Das Repository

src/main/java/de/szut/personenverwaltung/repository/PersonRepository.java
package de.szut.personenverwaltung.repository;

import de.szut.personenverwaltung.model.Person;
import org.springframework.data.jpa.repository.JpaRepository;

public interface PersonRepository extends JpaRepository<Person, Long> {
}

Das ist alles. Kein implements, keine SQL-Anweisung, keine Verbindungsverwaltung.

Die beiden Typparameter bedeuten:

  • Person — welcher Entitätstyp verwaltet wird
  • Long — welchen Datentyp der Primärschlüssel hat
Wo kommt die Implementierung her?

Spring Data erzeugt sie beim Start zur Laufzeit. Es liest das Interface, erkennt den Entitätstyp und baut daraus eine vollständige Klasse mit allen CRUD-Methoden:

save() · findById() · findAll() · deleteById() · count() · existsById()

Vergleiche das mit der DAO-Klasse aus dem ersten Lehrjahr. Dort hast du jede dieser Methoden von Hand geschrieben.

Aufgabe 4 – Der Controller mit POST und GET

src/main/java/de/szut/personenverwaltung/controller/PersonController.java
package de.szut.personenverwaltung.controller;

import de.szut.personenverwaltung.model.Person;
import de.szut.personenverwaltung.repository.PersonRepository;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/v1/persons")
public class PersonController {

private final PersonRepository repository;

public PersonController(PersonRepository repository) {
this.repository = repository;
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Person createPerson(@RequestBody Person person) {
return repository.save(person);
}

@GetMapping("/{id}")
public Person getPersonById(@PathVariable Long id) {
return repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Keine Person mit der Id " + id));
}
}

Woher kommt das Repository?

Der Controller braucht ein PersonRepository — erzeugt aber nirgends eines mit new. Stattdessen verlangt der Konstruktor eines.

Beim Start sucht Spring nach einer passenden Instanz und übergibt sie. Das nennt man Dependency Injection: Die Abhängigkeit wird von außen hineingereicht, statt dass die Klasse sie sich selbst beschafft.

Spring-Containerläuft beim Start und baut alle Objekte, die deine Anwendung braucht1erzeugt das RepositoryPersonRepository2und steckt es in den KonstruktorPersonControllerbekommt das Repository von außen — er baut es nicht selbstpublic PersonController(PersonRepository repository)3das Feld ist final — nach dem Erzeugen steht es festSo nicht:new PersonRepository()dann müsste der Controller selbst wissen, wie ein Repository gebaut wird — und wäre nicht mehr testbar
Warum über den Konstruktor?

Weil das Feld dadurch final sein kann — es ist nach dem Erzeugen garantiert gesetzt und kann nicht mehr verändert werden. Der Controller ist damit nie in einem halb fertigen Zustand.

Und es gibt einen zweiten Grund, der später wichtig wird: Man kann dem Konstruktor beim Testen ein Ersatzobjekt übergeben. Darauf kommen wir zurück.

Was die neuen Annotationen tun

AnnotationBedeutung
@PostMappingBeantwortet POST-Anfragen — hier zum Anlegen.
@ResponseStatus(HttpStatus.CREATED)Antwortet mit 201 statt mit 200. Das ist der richtige Code nach einem Anlegen.
@RequestBodyDer JSON-Rumpf der Anfrage wird in ein Person-Objekt umgewandelt (Deserialisierung).
@GetMapping("/{id}")Ergänzt den Klassenpfad um einen variablen Teil: /api/v1/persons/1
@PathVariableBindet den variablen Teil der URL an den Methodenparameter.
In älterem Code siehst du die Langform

@PostMapping ist die Kurzform von @RequestMapping(method = RequestMethod.POST). Dasselbe gilt für @GetMapping, @PutMapping und @DeleteMapping.

Früher gab es nur die Langform, und man gab zusätzlich an, welche Datenformate der Endpunkt annimmt und liefert:

@RequestMapping(method = RequestMethod.POST,
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)

Beides ist heute überflüssig, weil JSON das Standardformat von Spring-REST-Endpunkten ist. Nötig wird es erst, wenn ein Endpunkt mehrere Formate bedienen soll — siehe HTTP kompakt.

Die Kurzform ist heute der Normalfall. Die Langform solltest du aber wiedererkennen, wenn du in einem Betrieb auf älteren Code stößt.

Was dabei mit den Daten passiert

Zwischen der Außenwelt und deiner Anwendung verläuft eine Grenze. Draußen gibt es nur Text — JSON ist eine Zeichenkette. Drinnen gibt es nur Java-Objekte. Bei jeder Überquerung muss umgewandelt werden.

AUSSENWELT — nur TextDEINE ANWENDUNG — Java-ObjektePOST /api/v1/persons{ }{"firstname":"Anna", ...}Deserialisierung@RequestBodyPersonsave()DatenbankPerson +idSerialisierungRückgabewert{ }{"firstname":"Anna","id":1, ...}zwei Überquerungen der Grenze — hinein und hinausGET /api/v1/persons/1kein Rumpf — nur die Id in der URLkein JSONfindById(1)DatenbankPersonSerialisierung{ }{"firstname":"Anna","id":1, ...}nur eine Überquerung — ausschließlich hinaus
Die Reihenfolge im JSON ist nicht deine Reihenfolge

In deiner Klasse steht id zuerst. In der Antwort erscheint es zwischen firstname und surname:

{
"firstname": "Anna",
"id": 1,
"surname": "Schmidt"
}

Das ist kein Fehler. Ein JSON-Objekt ist laut Standard eine ungeordnete Menge von Paaren — welches Feld zuerst steht, hat keine Bedeutung. Die Bibliothek, die Spring zum Umwandeln benutzt, sortiert die Felder deshalb alphabetisch.

Merke dir daraus vor allem eines: Verlass dich in einem Client niemals auf die Reihenfolge der Felder. Gesucht wird über den Namen, nie über die Position.

Woran du das im Code wiedererkennst

Die Zahl der Überquerungen steht in der Methodensignatur:

public Person createPerson(@RequestBody Person person)
// ────── ────────────────────────────
// hinaus herein

public Person getPersonById(@PathVariable Long id)
// ────── ─────────────────────
// hinaus nur eine Zahl aus der URL, kein JSON

Ein Endpunkt mit @RequestBody nimmt JSON entgegen, einer ohne nicht. Beide geben JSON zurück, sobald sie ein Objekt zurückliefern.

orElseThrow statt isPresent und get

findById() liefert kein Person-Objekt, sondern ein Optional<Person> — einen Behälter, der ein Objekt enthalten kann oder eben nicht.

Man könnte mit isPresent() prüfen und mit get() herausholen. Kürzer und sicherer ist orElseThrow(): Ist etwas drin, wird es zurückgegeben — sonst wird die angegebene Ausnahme geworfen. Und daraus macht Spring die Antwort 404.

Aufgabe 5 – Personen anlegen und abrufen

Zum Testen brauchst du ein Werkzeug, das POST-Anfragen schicken kann — über die Adresszeile des Browsers geht nur GET.

Erst markieren, dann anlegen

IntelliJ legt die neue Datei dort an, wo im Projektbaum gerade etwas ausgewählt ist. Steht die Auswahl noch auf einer Klasse im Package controller, landet die Datei dort — und nicht neben der pom.xml.

Sollte dein Menü den Eintrag HTTP Request nicht anbieten, tut es auch eine gewöhnliche neue Datei mit dem Namen requests.http. Entscheidend ist die Endung; an ihr erkennt die Entwicklungsumgebung, was sie vor sich hat.

requests.http
### Person anlegen
POST http://localhost:8080/api/v1/persons
Content-Type: application/json

{
"firstname": "Anna",
"surname": "Schmidt"
}

### Zweite Person anlegen
POST http://localhost:8080/api/v1/persons
Content-Type: application/json

{
"firstname": "Ben",
"surname": "Kaya"
}

### Person mit der Id 1 abrufen
GET http://localhost:8080/api/v1/persons/1

### Person abrufen, die es nicht gibt
GET http://localhost:8080/api/v1/persons/99
Was du beobachten solltest

Beim POST schickst du kein id-Feld mit — in der Antwort ist eines enthalten. Die Datenbank hat es vergeben.

Der letzte Request liefert 404 und eine Fehlerantwort im Format RFC 9457:

{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "Keine Person mit der Id 99",
"instance": "/api/v1/persons/99"
}

Fehlerantwort nach RFC 9457 im Browser

Aufgabe 6 – In die Datenbank schauen

Login-Maske der H2-Console

SELECT * FROM PERSON;

Ergebnis der SQL-Abfrage in der H2-Console

Zwei Wege, dieselben Daten

Du siehst dieselben Datensätze einmal als JSON über den Webservice und einmal als Tabelle über SQL.

Das ist der Kern des objektrelationalen Mappings: Hibernate übersetzt zwischen Java-Objekten und Datenbankzeilen. Du hast nie eine INSERT-Anweisung geschrieben — im Log kannst du nachlesen, dass Hibernate sie erzeugt hat.

Wie diese Übersetzung funktioniert und was JPA, Hibernate und Spring Data JPA jeweils dazu beitragen, erklärt das Infoblatt JPA und Hibernate.

Aufgabe 7 – Testfälle ausführen

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Tabelle wird beim Start angelegtAnwendung wurde neu gestartet
  1. Konsolenausgabe beim Start durchsehen
Eine Anweisung create table person ist im Log zu finden.
TF-02Person anlegenAnwendung läuft, Datenbank ist leer
  1. POST auf /api/v1/persons mit {"firstname":"Anna","surname":"Schmidt"}
Status 201. Die Antwort enthält zusätzlich ein Feld id mit dem Wert 1.
TF-03Zweite Person bekommt eine andere IdTF-02 wurde ausgeführt
  1. POST auf /api/v1/persons mit {"firstname":"Ben","surname":"Kaya"}
Status 201, id ist 2.
TF-04Person per Id abrufenTF-02 wurde ausgeführt
  1. GET auf /api/v1/persons/1
Status 200, Antwort enthält "firstname":"Anna".
TF-05Unbekannte Id liefert 404Anwendung läuft
  1. GET auf /api/v1/persons/99
Status 404. Der Rumpf enthält "detail" mit der Meldung zur Id 99 — nicht Status 200 mit leerem Inhalt.
TF-06Daten sind in der Datenbank angekommenTF-02 und TF-03 wurden ausgeführt
  1. H2-Console öffnen und verbinden
  2. SELECT * FROM PERSON; ausführen
Die Ergebnistabelle zeigt genau zwei Zeilen mit den Ids 1 und 2.
TF-07Daten sind nach Neustart wegTF-02 und TF-03 wurden ausgeführt
  1. Anwendung beenden und neu starten
  2. GET auf /api/v1/persons/1
Status 404 — die In-Memory-Datenbank war leer beim Start.
TF-07 ist kein Fehler

Dass die Daten nach einem Neustart verschwunden sind, ist gewollt: jdbc:h2:mem: bedeutet Hauptspeicher.

Im nächsten Tutorial stellst du auf eine dateibasierte Datenbank um und siehst den Unterschied.

Zusammenfassung

Das hast du gelernt
  • @Entity macht aus einer Java-Klasse eine Datenbanktabelle; @Id und @GeneratedValue regeln den Primärschlüssel.
  • Ein Repository ist nur ein Interface — Spring Data erzeugt die Implementierung zur Laufzeit.
  • Konstruktor-Injektion: Spring reicht benötigte Objekte von außen hinein; das Feld kann final sein.
  • @RequestBody wandelt JSON in ein Java-Objekt, der Rückgabewert wird wieder zu JSON.
  • 201 Created nach dem Anlegen, 404 Not Found bei unbekannter Id — nie 200 mit leerem Inhalt.
  • Eine Entität kann kein Record sein: Hibernate braucht den parameterlosen Konstruktor und veränderbare Felder.

Selbstkontrolle

  1. Was passiert beim Anwendungsstart mit einer Klasse, die @Entity trägt?
  2. Warum musst du PersonRepository nicht implementieren?
  3. Was ist der Unterschied zwischen @PathVariable und @RequestBody?
  4. Warum liefert findById() ein Optional und nicht direkt ein Person-Objekt?
  5. Ein Kollege gibt bei unbekannter Id 200 OK mit dem Inhalt null zurück. Nenne zwei Nachteile.
  6. Nenne zwei Gründe, warum eine JPA-Entität kein Record sein kann.
Antworten
  1. Hibernate liest die Annotationen und erzeugt eine passende Tabelle. Jedes Attribut wird zu einer Spalte, das mit @Id markierte zum Primärschlüssel.
  2. Spring Data erzeugt die Implementierung zur Laufzeit aus dem Interface. Aus JpaRepository<Person, Long> leitet es Entitätstyp und Schlüsseltyp ab und stellt alle CRUD-Methoden bereit.
  3. @PathVariable bindet einen Teil der URL an einen Parameter. @RequestBody wandelt den Rumpf der Anfrage in ein Java-Objekt um.
  4. Weil es sein kann, dass zu der Id nichts existiert. Das Optional macht diesen Fall sichtbar und zwingt den Aufrufer, ihn zu behandeln — statt stillschweigend null zurückzugeben.
  5. Erstens muss der Client den Rumpf auswerten, um überhaupt zu merken, dass etwas fehlt — der Statuscode allein sagt „alles in Ordnung". Zweitens lässt sich „nicht gefunden" nicht mehr von „gefunden, aber leer" unterscheiden.
  6. Erstens braucht Hibernate einen parameterlosen Konstruktor, den ein Record nicht hat. Zweitens sind die Felder eines Records final — Hibernate muss aber die erzeugte id nachträglich setzen und Änderungen erkennen können.