Zum Hauptinhalt springen

AB 01 – Projekt aufsetzen und das Modell bauen

Die Situation

Die HiTec GmbH hat ihren Webauftritt überarbeitet. Der Marketingabteilung fehlt eine Möglichkeit für Rückmeldungen: Kundinnen und Kunden sollen einen kurzen Eintrag hinterlassen können — mit Titel, Kommentar und Namen.

Dein Auftrag: Baue das Backend für dieses Gästebuch. Anders als bei der Personenverwaltung dürfen die Einträge auf keinen Fall verloren gehen, wenn der Dienst neu gestartet wird.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • Lombok einsetzen und erklären, was die Annotation @Data beim Kompilieren bewirkt
  • eine Entität mit @Table, @Column und einem automatisch gesetzten Zeitstempel gestalten
  • den Unterschied zwischen einer In-Memory- und einer dateibasierten Datenbank erklären
  • die Auswirkung der Schlüsselstrategie IDENTITY gegenüber AUTO beschreiben
  • ein zweites Spring-Boot-Projekt eigenständig aufsetzen
Was du schon kannst

Dieses Tutorial setzt die Personenverwaltung voraus. Du solltest einen REST-Controller, ein Repository und eine Entität bauen können. Zum Nachschlagen: JPA und Hibernate und HTTP kompakt.

Aufgaben

Aufgabe 1 – Projekt erzeugen

FeldWert
Project / LanguageMaven / Java
Spring Bootdie neueste 4.1.x
Groupde.szut
Artifactgaestebuch
Package namede.szut.gaestebuch
Java26

Vier davon kennst du. Lombok ist neu.

Aufgabe 2 – Lombok verstehen

Sieh dir an, wie viel Platz die Klasse Person aus dem ersten Tutorial gebraucht hat: drei Attribute, dazu ein Konstruktor und sechs Zugriffsmethoden. Rund 40 Zeilen, von denen keine einzige etwas Fachliches aussagt.

Die Klasse Person aus Tutorial 1 – ohne Lombok
Tutorial 1: src/main/java/de/szut/personenverwaltung/model/Person.java
@Entity
public class Person {

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

private String firstname;

private String surname;

/** JPA benötigt einen parameterlosen Konstruktor. */
public Person() {
}

public Long getId() {
return id;
}

public void setId(Long id) {
this.id = id;
}

public String getFirstname() {
return firstname;
}

public void setFirstname(String firstname) {
this.firstname = firstname;
}

public String getSurname() {
return surname;
}

public void setSurname(String surname) {
this.surname = surname;
}
}

Nur der obere Teil bis private String surname; sagt etwas über die Fachlichkeit aus: Eine Person hat eine Id, einen Vor- und einen Nachnamen. Der ganze Rest ist immer gleich — und wäre bei einem Gästebucheintrag mit vier Attributen noch länger.

Lombok erzeugt diesen Code beim Kompilieren. Im Quelltext steht nur noch eine Annotation. Dieselbe Klasse Person sieht damit so aus:

Person – mit Lombok
@Data
@Entity
public class Person {

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

private String firstname;

private String surname;
}

Aus rund 40 Zeilen werden zwölf — und übrig bleibt genau das, was die Klasse fachlich ausmacht.

Daraus macht der Compiler: Getter und Setter für alle drei Attribute, toString(), equals() und hashCode() — und einen Konstruktor mit allen final-Feldern (hier gibt es keine, also einen parameterlosen).

Zwei Dinge musst du wissen
  1. Der erzeugte Code steht nirgends in deinen Dateien. Er entsteht erst beim Kompilieren. Suche nicht danach.
  2. Deine Entwicklungsumgebung braucht das Lombok-Plugin, sonst unterringelt sie person.getFirstname() als unbekannt — obwohl das Projekt einwandfrei baut. IntelliJ bringt es seit einigen Versionen mit; falls nicht, über File → Settings → Plugins nachinstallieren.

Ausführlich steht das im Infoblatt Lombok.

Hier ist noch nichts einzugeben

Die Klasse Person dient nur zum Vergleich — du kennst sie schon, deshalb siehst du an ihr am schnellsten, was Lombok abnimmt. Die Entität für das Gästebuch baust du in Aufgabe 4.

Aufgabe 3 – Die Datenbank soll überleben

Im ersten Tutorial verschwanden alle Daten beim Neustart. Das lag an einer einzigen Stelle in der Konfiguration.

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

# --- Datenbank: jetzt in einer DATEI statt im Hauptspeicher ---
spring.datasource.url=jdbc:h2:file:./data/guestbookdb
spring.datasource.username=sa
spring.datasource.password=

spring.h2.console.enabled=true

# --- Hibernate ---
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=false

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

Zwei Zeilen haben sich gegenüber dem ersten Tutorial geändert:

VorherJetztWirkung
jdbc:h2:mem:persondbjdbc:h2:file:./data/guestbookdbDie Daten liegen in einer Datei statt im Hauptspeicher
ddl-auto=create-dropddl-auto=updateVorhandene Tabellen bleiben erhalten, statt beim Start gelöscht zu werden
Der Pfad ./data/guestbookdb

Der Punkt am Anfang bedeutet „relativ zum Projektverzeichnis". Beim ersten Start legt H2 dort den Ordner data mit der Datei guestbookdb.mv.db an.

Beide Zeilen gehören zusammen

Nur file: einzustellen und create-drop stehen zu lassen, hilft nichts: Hibernate würde die Tabelle beim Start weiterhin löschen und neu anlegen — nur eben in einer Datei.

Die Datenbankdatei von der Versionsverwaltung ausnehmen

Eine Versionsverwaltung hast du für dieses Projekt nicht eingerichtet — die Datei .gitignore liegt trotzdem schon bereit. Spring Initializr legt sie in jedem erzeugten Projekt mit an, direkt neben der pom.xml. Darin steht, was Git später einmal ignorieren soll: der Ordner target mit den Kompilaten, die Einstellungsdateien der Entwicklungsumgebung — alles, was sich jederzeit wiederherstellen lässt oder bei jedem im Team anders aussieht.

Die Datenbankdatei gehört in dieselbe Kategorie: Sie enthält Daten, keinen Quelltext, und ändert sich bei jedem Start.

.gitignore
### Lokale H2-Datenbank ###
data/

Der Schrägstrich am Ende sagt: Gemeint ist ein Ordner, nicht eine Datei dieses Namens. Speichern — mehr ist nicht zu tun. Wirksam wird der Eintrag in dem Moment, in dem das Projekt in eine Versionsverwaltung wandert.

Aufgabe 4 – Die Entität

Bevor du etwas tippst: Was steht eigentlich in einem Gästebuch?

Denk an ein Gästebuch, wie es in einem Hotel oder einem Museum ausliegt. Jemand schlägt es auf und schreibt hinein:

Toller Kurs

Hat mir gut gefallen! Besonders der Teil mit den Datenbanken.

Anna, 14. März 2026

Aus diesem einen Eintrag lassen sich vier Angaben ablesen — und genau die werden zu den Attributen deiner Klasse:

Was im Buch stehtAttributTypWarum dieser Typ
die ÜberschrifttitleStringkurzer Text, eine Zeile
der eigentliche TextcommentStringkann lang werden — mehr dazu gleich
wer es geschrieben hatauthorStringein Name
wann es geschrieben wurdedateLocalDateTimeDatum und Uhrzeit, damit zwei Einträge desselben Tages eine Reihenfolge haben

Dazu kommt eine fünfte Angabe, die im Papier-Gästebuch niemand hinschreibt: die Id. Im Buch genügt die Reihenfolge der Seiten, um einen Eintrag zu finden. Eine Datenbank braucht dafür einen eindeutigen Schlüssel — und die Schnittstelle braucht ihn, um eine Adresse wie /guestbook-entries/7 bilden zu können.

Zwei Entscheidungen fallen dabei jetzt schon auf:

  • Der Verfasser ist ein schlichter Text, keine eigene Klasse Person. Für ein Gästebuch reicht das: Wer sich einträgt, hat kein Benutzerkonto. Wäre es ein Forum, sähe es anders aus.
  • Das Datum setzt nicht der Client, sondern der Server. Sonst könnte jemand einen Eintrag von 1998 einreichen.
Falls du beim Anlegen ins Stocken kommst

Ein Package ist nichts weiter als ein Ordner plus die passende package-Zeile in den Dateien darin — und er liegt nicht im Projektverzeichnis, sondern unterhalb von src/main/java. Ob du ihn über New → Package oder über New → Directory anlegst, ist gleichgültig; nur Punkte gehören ausschließlich in den ersten der beiden Dialoge. Ausführlich steht das in Tutorial 01, Arbeitsblatt 01.

src/main/java/de/szut/gaestebuch/model/GuestbookEntry.java
package de.szut.gaestebuch.model;

import java.time.LocalDateTime;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;

@Entity
@Table(name = "guestbook_entry")
@Data
@NoArgsConstructor
public class GuestbookEntry {

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

private String title;

@Column(length = 2000)
private String comment;

private String author;

@CreationTimestamp
@Column(name = "date_of_entry", nullable = false, updatable = false)
private LocalDateTime date;
}

Diese Annotationen sind neu:

AnnotationWirkung
@Table(name = "guestbook_entry")Legt den Tabellennamen ausdrücklich fest, statt ihn Hibernate zu überlassen
@Column(name = "date_of_entry")Legt den Spaltennamen fest — für ein Attribut dasselbe, was @Table für die Klasse tut
@Column(length = 2000)Erweitert die Spalte; ohne Angabe wären es nur 255 Zeichen
@Column(nullable = false)Die Spalte darf nicht leer bleiben
@Column(updatable = false)Der Wert darf nachträglich nicht geändert werden
@CreationTimestampHibernate setzt den Zeitpunkt automatisch beim ersten Speichern
@NoArgsConstructorDer parameterlose Konstruktor, den Hibernate braucht. @Data liefert ihn hier zufällig mit — aber nur, solange die Klasse kein final-Feld hat. Warum man sich darauf nicht verlässt, steht im Infoblatt Lombok
Woher käme der Tabellenname ohne @Table?

Hibernate leitet ihn aus dem Klassennamen ab und schreibt ihn in der Schreibweise der Datenbankwelt: Jeder Großbuchstabe im Wortinneren wird zu einem Unterstrich, alles wird klein.

Klasseabgeleiteter Tabellenname
Personperson
GuestbookEntryguestbook_entry
PersonAddressperson_address

In Tutorial 1 stand deshalb kein @Table an der Klasse Person — der abgeleitete Name person war schon der richtige.

Hier ist es genauso: GuestbookEntry ergäbe auch ohne die Annotation guestbook_entry. Warum sie trotzdem dasteht? Weil sie den Namen festhält. Wird die Klasse eines Tages umbenannt, wandert der Tabellenname sonst stillschweigend mit — und die vorhandenen Daten stehen weiterhin in der alten Tabelle, die niemand mehr abfragt.

Warum heißt die Spalte anders als das Attribut?

@Column ist eine Annotation mit mehreren Einstellungen — in der Tabelle stehen sie nur einzeln, damit jede für sich erkennbar ist. Im Quelltext sind alle drei zu einer Angabe zusammengefasst:

@Column(name = "date_of_entry", nullable = false, updatable = false)
private LocalDateTime date;

Ohne name hieße die Spalte wie das Attribut, also date. Das ist in der SQL-Norm ein reserviertes Wort. Wie streng eine Datenbank das nimmt, ist von Hersteller zu Hersteller verschieden: H2 und MySQL akzeptieren es, Oracle nicht. Wechselt das Projekt später die Datenbank, kann genau diese Spalte zum Problem werden — und der Fehler zeigt sich erst dann.

In Java heißt das Attribut weiterhin date — dort ist der Name unproblematisch und beschreibt die Sache am besten. Dass Attribut und Spalte verschieden heißen dürfen, ist keine Notlösung, sondern der Kern des objektrelationalen Mappings: Beide Seiten benennen ihre Dinge nach ihren eigenen Regeln, die Zuordnung steht in der Annotation.

Warum updatable = false beim Datum?

Weil ein Eintrag zu einem bestimmten Zeitpunkt verfasst wurde — und dieser Zeitpunkt sich nachträglich nicht ändert. Hibernate lässt die Spalte deshalb in jeder UPDATE-Anweisung aus, die es erzeugt. Selbst wenn dein Code später setDate(...) aufruft, kommt die Änderung nicht in der Datenbank an.

Wie weit reicht dieser Schutz? Genau so weit wie Hibernate. Sieh dir die CREATE TABLE-Anweisung im Log an:

date_of_entry timestamp(6) not null

Dort steht not null — das kommt von nullable = false und ist eine echte Regel der Datenbank. Von updatable steht dort nichts. Wer die H2-Console öffnet und

UPDATE guestbook_entry SET date_of_entry = '2020-01-01 00:00:00' WHERE id = 1;

ausführt, ändert das Datum ohne Weiteres.

Merke dir den Unterschied: nullable wird zu einer Regel in der Datenbank, updatable ist eine Anweisung an Hibernate. Wer eine Regel wirklich auf jedem Zugriffsweg durchsetzen will, braucht dafür die Datenbank selbst — Rechte oder einen Trigger.

IDENTITY statt AUTO

Im ersten Tutorial stand dort GenerationType.AUTO. Sieh dir an, was der Unterschied bewirkt:

StrategieWie die Id entstehtWas du beobachtest
AUTO (Tutorial 1)Hibernate legt eine Sequenz an und holt sich Nummern blockweise auf VorratNach einem Neustart springen die Ids — und zwar weit
IDENTITY (hier)Die Datenbank zählt pro Tabelle einzeln hochIds sind fortlaufend: 1, 2, 3 …

Für ein Gästebuch, dessen Einträge dauerhaft gespeichert werden, sind fortlaufende Nummern angenehmer.

Wie groß der Sprung wirklich ist

Im ersten Tutorial lag die Datenbank im Arbeitsspeicher — nach einem Neustart war ohnehin alles weg, und die Zählung begann wieder bei 1. Mit einer Datei-Datenbank lässt sich das nachmessen. Genau dieses Projekt, nur mit AUTO statt IDENTITY:

Ids der neu angelegten Einträge
erster Start1, 2, 3
nach dem ersten Neustart1552, 1553
nach dem zweiten Neustart3152

Kein Tippfehler. Zwei Vorräte liegen übereinander:

  1. Hibernate legt die Sequenz mit increment by 50 an und teilt jede geholte Nummer in einen Block von 50 auf. Im Log steht die Zeile mit: create sequence guestbook_entry_seq start with 1 increment by 50
  2. H2 selbst hält von einer Sequenz standardmäßig noch einmal 32 Werte im Zwischenspeicher.

Beim Herunterfahren sind beide Vorräte verfallen. Beim nächsten Start beginnt die Zählung hinter dem verbrauchten Bereich — 32 × 50 = 1600 Nummern weiter.

Ist das schlimm? Fachlich nicht: Eine Id soll eindeutig sein, nicht lückenlos. Genau deshalb darf man aus einer Id auch nichts ablesen — „wir haben 3152 Einträge" wäre hier grob falsch. Wer Nummern ohne Lücken braucht, nimmt IDENTITY, zahlt dafür aber mit einem Datenbankzugriff je Einfügung: Die Nummer entsteht erst beim INSERT, deshalb kann Hibernate mehrere Einfügungen nicht mehr sammeln.

Aufgabe 5 – Repository und Controller

Das Muster kennst du aus dem ersten Tutorial — es ist dasselbe wie beim PersonController: das Repository über den Konstruktor hineingeben, @PostMapping mit @RequestBody und Status 201, @GetMapping ohne Parameter. Schreibe die Klasse zuerst selbst.

Warum die Adresse guestbook-entries heißt und nicht guestbook

Eine Adresse benennt in REST die Ressource, um die es geht — und zwar im Plural, weil unter dieser Adresse eine Sammlung liegt. Im ersten Tutorial war das /api/v1/persons — nicht /api/v1/person (das wäre eine einzelne) und nicht /api/v1/getPersons (ein Verb; was getan wird, sagt schon die HTTP-Methode).

Hier sind die Ressourcen die Einträge, nicht das Gästebuch. Das Gästebuch ist nur der Zusammenhang, in dem sie stehen. Man sieht es an den anderen Adressen:

Adresseliest sich als
POST /api/v1/guestbook-entries„lege einen Eintrag an"
GET /api/v1/guestbook-entries/7„gib mir Eintrag 7"
DELETE /api/v1/guestbook-entries/7„lösche Eintrag 7"

Mit /guestbook stünde dort „lösche das Gästebuch 7" — das ergibt keinen Sinn.

Zur Schreibweise: kleingeschrieben und mit Bindestrich. guestbookEntries wäre Java-Schreibweise, und Adressen sind kein Java.

Ein date schickst du nicht mit — das setzt Hibernate.

Wenn du nicht weiterkommst – so sieht die Klasse aus
src/main/java/de/szut/gaestebuch/controller/GuestbookEntryController.java
@RestController
@RequestMapping("/api/v1/guestbook-entries")
public class GuestbookEntryController {

private final GuestbookEntryRepository repository;

public GuestbookEntryController(GuestbookEntryRepository repository) {
this.repository = repository;
}

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public GuestbookEntry createEntry(@RequestBody GuestbookEntry entry) {
return repository.save(entry);
}

@GetMapping
public List<GuestbookEntry> findAllEntries() {
return repository.findAll();
}
}

Aufgabe 6 – Ausprobieren

requests.http
### Eintrag anlegen
POST http://localhost:8080/api/v1/guestbook-entries
Content-Type: application/json

{
"title": "Toller Kurs",
"comment": "Hat mir gut gefallen!",
"author": "Anna"
}

### Alle Einträge
GET http://localhost:8080/api/v1/guestbook-entries
Das ist der Unterschied zum ersten Tutorial

Dort war nach einem Neustart alles weg. Hier stehen deine Einträge noch da — sie liegen in einer Datei auf der Festplatte.

Lege noch einen dritten Eintrag an und achte auf die Id: Sie zählt dort weiter, wo es vor dem Neustart aufgehört hat.

Aufgabe 7 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Tabelle und Spalten tragen die festgelegten NamenProjekt frisch erstellt, Ordner data existiert noch nicht
  1. Anwendung starten
  2. Konsolenausgabe durchsehen
Im Log steht create table guestbook_entry, und darin die Spalte date_of_entry — nicht date.
TF-02Datenbankdatei wird angelegtAnwendung wurde einmal gestartet
  1. Im Projektverzeichnis nachsehen
Der Ordner data enthält die Datei guestbookdb.mv.db.
TF-03Eintrag anlegen, Datum wird gesetztAnwendung läuft
  1. POST mit title, comment und author — ohne date
Status 201. Die Antwort enthält ein Feld date mit dem aktuellen Zeitpunkt und eine id.
TF-04Ids sind fortlaufendDatenbank ist neu
  1. Drei Einträge nacheinander anlegen
Die Ids lauten 1, 2, 3 — jede Zahl unmittelbar nach der vorigen.
TF-05Daten überleben den NeustartMindestens zwei Einträge wurden angelegt
  1. Anwendung beenden
  2. Anwendung neu starten
  3. GET auf /api/v1/guestbook-entries
Alle vorher angelegten Einträge sind noch vorhanden.
TF-06Zählung läuft nach dem Neustart weiterTF-05 wurde ausgeführt, zuletzt gab es Id 3
  1. Neuen Eintrag anlegen
Die neue Id ist 4 — die Zählung beginnt nicht wieder bei 1.
TF-07Langer Kommentar wird gespeichertAnwendung läuft
  1. Eintrag mit einem Kommentar von etwa 500 Zeichen anlegen
Status 201, der Text wird vollständig gespeichert — dank @Column(length = 2000).

Zusammenfassung

Das hast du gelernt
  • Lombok erzeugt Getter, Setter, Konstruktor und weitere Methoden beim Kompilieren; @Data fasst sie zusammen.
  • jdbc:h2:file: speichert in eine Datei, jdbc:h2:mem: nur im Hauptspeicher.
  • ddl-auto=update erhält vorhandene Tabellen; create-drop löscht sie bei jedem Start.
  • @Table und @Column legen Namen und Eigenschaften in der Datenbank ausdrücklich fest.
  • @CreationTimestamp setzt einen Zeitstempel automatisch; updatable = false hält ihn aus Hibernates UPDATE-Anweisungen heraus — es ist keine Regel der Datenbank.
  • IDENTITY zählt pro Tabelle einzeln hoch, AUTO holt Werte auf Vorrat und erzeugt dadurch größere Sprünge. Lückenlos ist aber auch IDENTITY nicht: Wird ein Eintrag gelöscht oder eine Transaktion zurückgerollt, fehlt die Nummer für immer. Zugesichert ist nur die Eindeutigkeit.

Selbstkontrolle

  1. Wo steht der von Lombok erzeugte Code?
  2. Warum reicht es nicht, nur mem durch file zu ersetzen?
  3. Warum ist updatable = false beim Datum sinnvoll?
  4. Welchen Vorteil hat IDENTITY gegenüber AUTO für ein Gästebuch?
  5. Warum gehört der Ordner data nicht in die Versionsverwaltung?
  6. Das Attribut heißt date, die Spalte date_of_entry. Wer sorgt dafür, dass beides zusammenfindet?
Antworten
  1. Nirgends im Quelltext. Er entsteht beim Kompilieren und steht nur im Bytecode. Deshalb braucht die Entwicklungsumgebung ein Plugin, um ihn anzuzeigen.
  2. Weil ddl-auto=create-drop die Tabelle bei jedem Start löschen und neu anlegen würde — dann wäre die Datei zwar vorhanden, aber jedes Mal leer. Es braucht zusätzlich update.
  3. Weil der Zeitpunkt, zu dem ein Eintrag verfasst wurde, sich nicht nachträglich ändert. updatable = false hält die Spalte aus allen UPDATE-Anweisungen heraus, die Hibernate erzeugt — eine Regel der Datenbank ist es nicht, ein direktes SQL-UPDATE käme weiterhin durch.
  4. IDENTITY vergibt die Nummern einzeln und damit dicht aufeinanderfolgend. AUTO holt sich 50 Werte auf Vorrat, wodurch nach einem Neustart größere Lücken entstehen. Garantiert lückenlos ist keine der beiden Strategien — nach einem Löschen oder einem Rollback fehlt die Nummer.
  5. Weil er Daten enthält, keinen Quelltext. Jeder Entwickler hat andere Testdaten, und die Datei ändert sich bei jedem Start.
  6. Hibernate. Die Zuordnung steht in @Column(name = "date_of_entry"). Im Java-Code sprichst du weiterhin vom Attribut date, im erzeugten SQL steht date_of_entry — das Übersetzen ist die Aufgabe des objektrelationalen Mappers.