1. Datum‑Uhrzeit als Teil des API‑Vertrags
Wenn ein Anfänger das Feld createdAt sieht, greift die Hand oft zu zwei „universellen“ Lösungen: entweder man macht String oder nimmt einen irgendwie „ähnlichen“ Typ, der compiliert. Aber Zeit ist ein Thema, bei dem die Compilation nichts garantiert: Man kann funktionierenden Code bekommen, der eine falsche Bedeutung liefert. Unsere Aufgabe in der API ist nicht nur, Zahlen zu speichern, sondern dem Client ein klares Signal zu geben: dies ist ein Datum, eine lokale Datumszeit oder ein genauer Zeitpunkt.
Stellen Sie sich vor, Sie entwickeln eine Task Tracker API, und eine Aufgabe hat eine Frist (dueDate) und einen Erstellungszeitpunkt (createdAt). Auf den ersten Blick sind beide Felder „über Zeit“, aber ihrem Sinn nach sind es verschiedene Wesen. Die Frist wird meist als „bis zu welchem Tag einschließlich abgeschlossen sein muss“ verstanden, ohne Bindung an Stunden und Minuten. createdAt dagegen ist ein „Moment auf der Zeitachse“: Er muss in Tokio, New York und auf dem Server, der in einer munteren Cloud‑Zone steht, gleich interpretiert werden.
Hier hilft ein einfacher Gedanke sehr: Der Typ im DTO ist ein Teil des öffentlichen Vertrags. Wenn Sie einen Typ wählen, der nicht dem Sinn entspricht, wird der Client trotzdem gezwungen sein, einen Sinn zu wählen. Und wie die Praxis zeigt, wird der Client ihn wählen… „wie es halt kommt“. Später werden Sie Bugs der Stufe „Die Aufgabe wird morgen erstellt, obwohl sie heute erstellt wurde“ fangen und darüber diskutieren, was „heute“ bedeutet, wenn der Benutzer in Australien sitzt.
Um die Zeit nicht zur Philosophie werden zu lassen, halten wir uns im Kurs an drei grundlegende Typen aus java.time:
| Typ | Bedeutung | Gibt es eine lokale Zone im Wert | Typisches JSON |
|---|---|---|---|
| LocalDate | Kalenderdatum (Jahr‑Monat‑Tag) | nein | "2026-03-21" |
| LocalDateTime | Datum + Uhrzeit (ohne Zone) | nein | "2026-03-21T10:15:30" |
| Instant | genauer Zeitpunkt | nein, es ist ein absoluter Moment auf der gemeinsamen Zeitachse | "2026-03-21T10:15:30Z" |
Im Folgenden betrachten wir jeden Typ als Werkzeug. Nicht „welcher schöner ist“, sondern „welcher den Sinn eines Feldes ehrlicher ausdrückt“.
2. LocalDate: Wann nur das Datum wichtig ist
LocalDate ist eine hervorragende Möglichkeit, dem Client zu sagen: „Hier gibt es keine Stunden, Minuten und Zeitzonen – sucht sie nicht.“ Dieser Typ ist für Felder gedacht, die im menschlichen Kopf als Datum im Kalender leben. In der Task Tracker API ist es ein fast idealer Kandidat für dueDate: Die Frist einer Aufgabe benötigt normalerweise keine Sekundengenauigkeit, besonders wenn wir keinen Raketenstart‑Planer bauen (dort, so heißt es, gibt es empfindliche Zeitzonen).
Mit LocalDate lebt es sich auch angenehm, weil JSON maximal lesbar wird. Ein String wie "2026-03-31" ist fast jedem Menschen klar, und eine Maschine parst ihn problemlos ohne Lokalisierung und Tänze mit 03/31/2026 (wo die eine Hälfte des Planeten März sieht und die andere Hälfte den 3. Januar). Wichtig ist, dass wir das Datum nicht von Hand „in einen String umwandeln“: Wir wählen einfach den richtigen Java‑Typ, und Jackson 3 erledigt den Rest.
Ein Mini‑Beispiel, um die „Form“ des Wertes zu sehen:
import java.time.LocalDate;
// Erstellen eines Kalenderdatums: ohne Uhrzeit und ohne Zeitzone
LocalDate dueDate = LocalDate.of(2026, 3, 31);
// `LocalDate#toString()` gibt ISO-8601-Format YYYY-MM-DD
System.out.println(dueDate); // 2026-03-31
Nun ein Beispiel für ein DTO im Sinne unseres Projekts. Wir komplizieren es nicht und zeigen nur die wichtigen Felder:
import java.time.LocalDate;
public class TaskCreateRequest {
private String title;
// Frist ist ein Datum, kein "Zeitpunkt": wir erwarten einen String der Form "2026-03-31"
private LocalDate dueDate;
public String getTitle() { return title; }
public LocalDate getDueDate() { return dueDate; }
}
Und so wird es für den Client aussehen, der eine Aufgabe erstellt:
POST /api/v1/tasks
Content-Type: application/json
{
"title": "Write docs",
"dueDate": "2026-03-31"
}
Beachten Sie: Der Client übermittelt keine „Zeitzone der Frist“, denn die Frist als Datum hat nichts mit einer Zeitzone zu tun. Das ist ein wichtiger Gewinn an Klarheit des Vertrags.
Innerhalb des Services oder der Factory der Aufgabe nehmen Sie einfach den Wert und legen ihn ab, ohne String‑Parsing:
import java.time.Instant;
public class TaskService {
public Task create(TaskCreateRequest request) {
Task task = new Task();
// Werte, die vom Client kamen
task.setTitle(request.getTitle());
task.setDueDate(request.getDueDate());
// Audit‑Felder werden vom Server verwaltet: Der Client gibt sie nicht vor
task.setCreatedAt(Instant.now());
return task;
}
}
Ja, hier haben wir bereits Instant angesprochen – denn createdAt ist seiner Bedeutung nach ein anderes Tier. Und das ist eine gute Illustration: verschiedene Felder, verschiedene Typen, weil verschiedene Bedeutung.
3. LocalDateTime: Datum‑Uhrzeit ohne Zone
LocalDateTime ist ein Typ, der wie „das Richtige“ aussieht, weil er sowohl Datum als auch Uhrzeit enthält. Aber er hat eine grundlegende Eigenschaft: Im Wert selbst steckt keine Information über Zeitzone oder Offset. Das heißt, "2026-03-21T10:15:30" ist „10:15:30 irgendwo“. Wo genau – entscheiden Sie selbst. Wenn Server und Client unterschiedliche Entscheidungen treffen, bekommen Sie einen sehr realistischen Bug, der alle zwei Wochen je nach Laune auftaucht.
Um das Problem zu fühlen, stellen Sie sich vor, der Server hat LocalDateTime als „lokale Serverzeit“ gespeichert, und der Server ist auf eine andere Maschine umgezogen, die eine andere Zeitzone hat. Formal funktioniert alles: Das Feld wird serialisiert, JSON wird ausgeliefert, Tests (die Sie nicht geschrieben haben) haben nichts bemerkt. Aber der Sinn ist verloren gegangen. Die Zeit in den Daten bedeutet nun „lokale Zeit einer unbekannten Maschine“, und das ist kein Vertrag mehr, sondern ein Rätsel.
Dennoch kann LocalDateTime sinnvoll sein. Normalerweise in Fällen, in denen das Domänenmodell von der „Wanduhrzeit“ (wall clock time) in einem bekannten Kontext spricht. Zum Beispiel: „Jeden Tag um 10:00 findet das Stand‑up nach Tokio‑Zeit statt“ – das ist kein Zeitpunkt auf der Weltzeitskala, sondern ein Zeitplan in einer bestimmten Zone. In solchen Systemen muss man dennoch oft entweder die Zeitzone separat speichern oder klar vereinbaren, dass alles in UTC ist, oder zu anderen Typen übergehen. Aber als grundlegendes Konzept ist es nützlich, LocalDateTime zu kennen.
Wie LocalDateTime und sein JSON‑Format aussehen:
import java.time.LocalDateTime;
// Lokale Datumszeit: im Wert steckt weder UTC noch Offset noch Zeitzone
LocalDateTime updatedAt = LocalDateTime.of(2026, 3, 21, 10, 15, 30);
// Im JSON steht normalerweise ein String der Form "2026-03-21T10:15:30"
System.out.println(updatedAt); // 2026-03-21T10:15:30
Und hier ist ein wichtiger Punkt für das API‑Denken: Wenn Ihr Vertrag dem Client "updatedAt": "2026-03-21T10:15:30" ausliefert, hat der Client das Recht zu fragen: „Ist das UTC? In meiner Zeitzone? In der Zeitzone des Servers? In der Zeitzone des Autors der Aufgabe?“ Wenn die Antwort nirgends festgehalten ist, beginnt der Client zu raten. Meistens endet das Raten falsch.
In der Task Tracker API können wir ehrlich sagen: Für Audit‑Felder (createdAt, updatedAt, uploadedAt) wünschen wir uns nicht die „lokale Zeit“, sondern einen genauen Moment, damit alle Clients das Ereignis gleich verstehen. Daher wählen wir für diese Felder Instant. Und LocalDateTime lassen wir als Konzept, das Sie erkennen und dort anwenden sollten, wo der Sinn wirklich „lokal“ und vorher vereinbart ist.
4. Instant: Genauer UTC‑Moment und Audit‑Felder einer Ressource
Instant ist im Grunde ein „Punkt auf der gemeinsamen Zeitachse“. Er speichert keine Benutzer‑Zeitzone und versucht nicht, die „lokale Zeit irgendwo“ zu sein; er gibt einfach einen eindeutigen Moment an, der überall auf der Welt gleich interpretiert werden kann. Im JSON sieht Instant normalerweise wie ein ISO‑8601‑String mit dem Suffix Z aus. Z ist die übliche UTC‑Darstellung dieses Moments, nicht „irgendeine lokale Zeitzone im Objekt“. Es sieht niedlich aus, also wird die Hälfte der Leute denken, es sei ein geheimer Code. In Wirklichkeit ist es einfach eine gute Art zu sagen: „Die Zeit ist universell“.
Warum ist Instant eine gute Wahl für createdAt und updatedAt in unserem Projekt? Weil es nicht die „Wanduhrzeit“ ist, sondern ein Ereignisfakt: Die Ressource wurde zu einem bestimmten Moment erstellt, zu einem bestimmten Moment aktualisiert. Der Client kann dies dem Benutzer als lokale Zeit anzeigen (in seine Zeitzone umrechnen), aber der Ausgangspunkt sollte einer sein.
Mini‑Beispiel für einen „kanonischen“ Instant:
import java.time.Instant;
// `Z` am Ende bedeutet UTC (Offset 0)
Instant createdAt = Instant.parse("2026-03-21T10:15:30Z");
System.out.println(createdAt); // 2026-03-21T10:15:30Z
Beachten Sie das Z. Wenn Sie versuchen, einen String ohne Zone zu parsen, sagt Instant ehrlich: „Ich verstehe nicht, was das für ein Moment ist, auf welcher Skala?“. Und das ist ein ausgezeichnetes Verhalten für eine API: Besser einen Fehler beim Lesen bekommen, als den Wert still zu akzeptieren und ihn irgendwie zu interpretieren.
Jetzt fügen wir Instant in das Response‑DTO der Aufgabe ein. Unten sehen Sie ein gekürztes, auf Zeit fokussiertes Fragment von TaskDetailsResponse: Wir lassen nur die Felder, die für das Gespräch über Datum und Audit‑Felder wichtig sind.
import java.time.Instant;
import java.time.LocalDate;
public class TaskDetailsResponse {
// Fragment eines DTO: wir zeigen nur time-bezogene Felder, der Rest ist ausgelassen.
private final String id;
private final LocalDate dueDate;
private final Instant createdAt;
private final Instant updatedAt;
public TaskDetailsResponse(String id, LocalDate dueDate, Instant createdAt, Instant updatedAt) {
this.id = id;
this.dueDate = dueDate;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
Und ein Beispiel des JSON, das der Client sieht:
{
"id": "t-1",
"dueDate": "2026-03-31",
"createdAt": "2026-03-21T10:15:30Z",
"updatedAt": "2026-03-21T10:15:30Z"
}
Wenn Sie das noch lebensechter gestalten wollen, können Sie zwei Seiten desselben Zeitpunkts darstellen:
flowchart TD A["Instant: 2026-03-21T10:15:30Z"] --> B["New York: 2026-03-21 06:15:30 (UTC-04)"] A --> C["Berlin: 2026-03-21 11:15:30 (UTC+01)"]
Der Sinn ist, dass Instant derselbe bleibt, während die Darstellung unterschiedlich sein kann. Und genau das brauchen wir: Der Vertrag ist stabil, und die UI‑Ebene kann ihr eigenes Leben leben.
Innerhalb des Services setzen Sie bei der Erstellung der Aufgabe diese Felder einmal (jetzt einfach über Instant.now(), weil wir den Kurs auf einem vernünftigen Komplexitätsniveau halten):
import java.time.Instant;
public class TaskFactory {
public Task newTask(TaskCreateRequest req) {
// Ein einziger "Zeit-Schnappschuss", damit createdAt und updatedAt zu Beginn übereinstimmen
Instant now = Instant.now();
Task t = new Task();
t.setDueDate(req.getDueDate());
// Audit-Felder setzt der Server
t.setCreatedAt(now);
t.setUpdatedAt(now);
return t;
}
}
Ja, Instant.now() hängt von der Maschinenzeit ab. Das ist für Beispiele in Ordnung, aber für Seed‑Daten ist es besser, feste Werte zu verwenden, damit Demo‑Anfragen und Ergebnisse in den Vorlesungen reproduzierbar sind.
5. ISO‑8601 und Zeitzonen‑Disziplin
Wir haben uns für die Typen entschieden, aber es ist wichtig, eine praktische Vereinbarung zu treffen: Im JSON verwenden wir das maschinenlesbare ISO‑8601‑Format. Das ist ein Fall, in dem der Standard Ihr bester Freund ist. Er entfernt die Lokalisierung, entfernt das „amerikanische“ Datumsformat und zwingt den Client nicht zu raten, was 03/04/05 bedeutet. (Ist das März, April oder das Jahr 2005? Ja.)
In unserem Kurs halten wir uns an diese „Mikro‑Legende“ der Formate:
| Bedeutung | Java‑Typ | Beispielwert in JSON | Kommentar |
|---|---|---|---|
| Nur Datum | LocalDate | "2026-03-31" | ohne Uhrzeit, ohne Zone |
| Datum + Uhrzeit (lokal) | LocalDateTime | "2026-03-21T10:15:30" | Zone fehlt, nur bewusst verwenden |
| Zeitpunkt | Instant | "2026-03-21T10:15:30Z" | absoluter Moment; im JSON wird normalerweise die UTC‑Darstellung mit Z ausgegeben |
Um Ihren Verstand zu prüfen, ist es nützlich, sich einen Lifehack zu merken: Wenn Sie unsicher sind, was Ihr Server genau erwartet, versuchen Sie in Java einen parse() des Formats, das Sie ins JSON legen wollen. Das ersetzt keine Tests, deckt aber schnell die groben Fehler auf.
import java.time.Instant;
import java.time.LocalDate;
// ISO-8601 Kalenderdatum
LocalDate d = LocalDate.parse("2026-03-31");
// ISO-8601 Zeitpunkt (zwingend mit Z oder Offset)
Instant t = Instant.parse("2026-03-21T10:15:30Z");
System.out.println(d); // 2026-03-31
System.out.println(t); // 2026-03-21T10:15:30Z
Es ist wichtig, nicht in „benutzerdefinierte Formate“ abzudriften, denn ein benutzerdefiniertes Format ist immer eine versteckte Abhängigkeit zwischen Client und Server. Heute einigen Sie sich auf "31.03.2026", morgen kommt ein anderer Client, der nichts von Ihrer Absprache weiß, und bei ihm geht alles kaputt. ISO‑8601 ist genau dazu da, die API nicht in einen Club der Liebhaber geheimer Protokolle zu verwandeln.
Ein separates Wort zu Zeitzonen. In dieser Vorlesung reicht es, die grundlegende Disziplin zu verstehen: Wenn ein Feld ein Zeitpunkt ist, muss es eindeutig interpretierbar sein. Instant liefert das „out of the box“. LocalDateTime erfordert zusätzliche Vereinbarungen. LocalDate braucht keine Zone, weil es ein Datum ist.
6. Mini‑Übung: Wir aktualisieren DTO und Seed‑Daten
Jetzt machen wir einen kleinen, aber sehr aussagekräftigen Schritt im Projekt: Wir setzen die „richtigen“ Zeittypen für bestimmte Felder fest. Wichtig ist nicht die Menge des Codes, sondern dass Sie danach auf das JSON schauen und sagen können: „Ich verstehe, was jedes Feld bedeutet, und der Client wird es auch verstehen.“
Beginnen wir damit, dass wir im Domänenmodell Task (intern, nicht DTO) dueDate als LocalDate und Audit‑Felder als Instant halten. Die vollständige Klasse ist riesig, daher zeige ich nur die sinntragenden Felder:
import java.time.Instant;
import java.time.LocalDate;
public class Task {
private LocalDate dueDate;
private Instant createdAt;
private Instant updatedAt;
public LocalDate getDueDate() { return dueDate; }
public Instant getCreatedAt() { return createdAt; }
}
Jetzt das Request‑DTO für die Erstellung einer Aufgabe. Wenn dueDate optional ist, kann es null sein (die Bedeutung von null werden wir separat besprechen, aber die Tatsache, dass ein Feld fehlen kann, stört uns jetzt nicht):
import java.time.LocalDate;
public class TaskCreateRequest {
private String title;
private LocalDate dueDate; // "2026-03-31" oder null
public String getTitle() { return title; }
public LocalDate getDueDate() { return dueDate; }
}
Danach setzt der Service bei der Erstellung die Audit‑Felder. Beachten Sie: dueDate stammt aus der Anfrage, createdAt/updatedAt sind server‑verwaltete Werte, der Client steuert sie nicht.
import java.time.Instant;
public class TaskService {
public Task create(TaskCreateRequest req) {
Instant now = Instant.now();
Task t = new Task();
t.setDueDate(req.getDueDate());
t.setCreatedAt(now);
t.setUpdatedAt(now);
return t;
}
}
Danach erzeugt der Mapper TaskDetailsResponse, und dort sieht man, wie der externe Vertrag unsere Entscheidungen festhält. Auch hier zeigen wir es kurz:
public class TaskMapper {
public TaskDetailsResponse toDetails(Task task) {
return new TaskDetailsResponse(
task.getId(),
task.getDueDate(),
task.getCreatedAt(),
task.getUpdatedAt()
);
}
}
Schließlich genügt ein einfaches .http‑Szenario, um die Serialisierung manuell zu prüfen. Stellen Sie sicher, dass Sie dueDate im Format YYYY-MM-DD senden:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
{
"title": "Learn LocalDate",
"dueDate": "2026-03-31"
}
Wenn Sie irgendwo versehentlich versuchen, "31.03.2026" oder "2026/03/31" zu senden, wird Spring MVC den Body höchstwahrscheinlich nicht in das DTO deserialisieren können, und die Anfrage erreicht nicht einmal Ihren Controller. Das ist keine „Spitzfindigkeit“, sondern Vertragsschutz: Der Server ist nicht verpflichtet, das Format des Clients zu erraten.
Für Seed‑Daten (wenn Sie im Voraus vorbereitete Aufgaben im Speicher ablegen) ist es sinnvoll, feste Instant zu verwenden, damit die Beispiele von Lauf zu Lauf nicht „schwimmen“:
import java.time.Instant;
Instant createdAt = Instant.parse("2026-03-01T12:00:00Z");
Instant updatedAt = Instant.parse("2026-03-10T09:30:00Z");
So können Sie die Dokumentation öffnen, manuell testen und immer dieselben Werte sehen – das hilft beim Lernen ungemein.
7. Typische Fehler bei der Arbeit mit Datum und Uhrzeit in JSON APIs
Fehler mit der Zeit sehen selten aus wie „Die Anwendung startet nicht“. Meistens startet sie und „funktioniert“ sogar, aber der Sinn der Daten driftet allmählich ab. Daher ist es nützlich, im Voraus zu wissen, wo die häufigsten Fallstricke liegen, um nicht jeden Montag darauf zu treten (und jedes Mal zu staunen, wie das möglich ist).
Fehler Nr. 1: Daten und Zeiten als String speichern, weil „das einfacher ist“.
Am Anfang scheint String dueDate schneller zu sein, als sich mit LocalDate auseinanderzusetzen. Aber dann bekommen Sie unweigerlich zwei Formate von zwei Clients, einen dritten Client aus einem anderen Land und ein viertes Format „wie in Excel“. Am Ende wird aus „einfacher“ ein ständiger Bug und manuelles Parsen. Wenn die Bedeutung eines Feldes ein Datum ist, drücken Sie es besser gleich als LocalDate aus und halten Sie ISO‑8601 ein.
Fehler Nr. 2: LocalDateTime als Audit‑Timestamp verwenden und vergessen, dass es keine Zone enthält.
LocalDateTime sieht solide aus, aber es ist kein Zeitpunkt – es ist eine „lokale Datumszeit ohne Kontext“. Sobald der Server oder der Client in einer anderen Zeitzone landet (oder die Einstellungen ändert), fangen Sie an, Unvergleichbares zu vergleichen. Für createdAt und updatedAt ist Instant viel ehrlicher, weil er eindeutig ist.
Fehler Nr. 3: Instant ohne Z oder ohne Offset senden.
Der String "2026-03-21T10:15:30" ist keine korrekte Darstellung eines Zeitpunkts für Instant, weil er nicht angibt, zu welcher Zone diese Zeit gehört. Die richtige Variante ist "2026-03-21T10:15:30Z" (UTC) oder ein String mit Offset. Wenn Ihre API Instant gewählt hat, muss der Client eindeutige Werte senden und empfangen.
Fehler Nr. 4: Unterschiedliche Typen für denselben Sinn in verschiedenen Endpunkten mischen.
Manchmal wird in einer Antwort das Feld createdAt als Instant ausgeliefert, in einer anderen als String „der Schönheit halber“ und in einer dritten als LocalDateTime. Formal wird alles serialisiert, aber der Vertrag wird unberechenbar. Der Client ist gezwungen, einen „Zoo“ von Parsern zu schreiben. Viel besser ist es, einmal zu entscheiden, dass createdAt ein Instant ist, und diese Linie konsequent durchzuhalten.
Fehler Nr. 5: „Datum“ und „Zeitpunkt“ bei Feldern wie dueDate verwechseln.
Wenn die Frist ein Datum ist, sollten Sie sie nicht „für alle Fälle“ in ein Instant verwandeln. Sie ziehen sofort Zeitzonen in einen Bereich, wo sie nicht nötig sind, und schaffen endlose Fragen: Läuft die Frist um Mitternacht ab? Am Ende des Tages? Nach Benutzer‑ oder Serverzeit? LocalDate erlaubt es, den Sinn einfach und klar zu halten.
GO TO FULL VERSION