Einleitung

Zeitangaben mit Offset gehören zu den häufigsten und zugleich sensibelsten Daten in modernen Softwareanwendungen. Die Java-Klasse OffsetDateTime aus dem Paket java.time erlaubt es, neben Datum und Uhrzeit auch explizit den zugehörigen UTC-Offset (etwa +02:00) mitzuführen. Dies ist insbesondere in domänenkritischen Anwendungen, wie Protokollierung, Auditing oder internationalen Zeitvergleichen, von großer Bedeutung.

Wird eine solche Zeitangabe mit Offset in einer PostgreSQL-Datenbank gespeichert, erwartet man typischerweise, dass der Offset ebenfalls persistiert wird. Doch genau hier liegt die Herausforderung: Der gängige PostgreSQL-Datentyp timestamptz (timestamp with time zone) speichert den Offset nicht mit. Stattdessen wird der Wert intern nach UTC normalisiert, wodurch der ursprüngliche Offset verloren geht. Das führt dazu, dass bei der späteren Abfrage nicht mehr rekonstruierbar ist, welcher Offset ursprünglich verwendet wurde.

In diesem Artikel wird ein Ansatz vorgestellt, wie man OffsetDateTime-Werte in einer Spring-Boot-Anwendung mithilfe von Spring Data so speichern und laden kann, dass der Offset vollständig erhalten bleibt. Der beschriebene Weg ist unabhängig von der Systemzeitzone oder JDBC-Konfiguration und basiert auf einem verlustfreien Speichermodell.

Problemstellung: Der Verlust des Offsets in PostgreSQL

PostgreSQL unterscheidet zwischen zwei Zeittypen: timestamp without time zone und timestamp with time zone. Während ersterer keinerlei Kontext zu Zeitzonen bietet, suggeriert letzterer, Zeitinformationen inklusive Zeitzonen oder Offsets zu speichern. Tatsächlich speichert PostgreSQL jedoch bei timestamptz ausschließlich Zeitpunkte in UTC und verwirft jegliche Offset-Angaben der Eingabewerte.

Ein Beispiel veranschaulicht das Verhalten: Wird der Wert 2025-05-10T14:00:00+02:00 gespeichert, legt PostgreSQL ihn intern als 2025-05-10T12:00:00+00:00 ab. Der ursprüngliche Offset +02:00 ist damit dauerhaft verloren. Auch beim Auslesen über JDBC wird der Offset nicht rekonstruiert, es sei denn, er wird auf Anwendungsebene separat gespeichert.

Da OffsetDateTime explizit für solche Informationen gedacht ist, ist der Einsatz von timestamptz in Verbindung mit dieser Klasse nur bedingt geeignet. Eine zuverlässige Speicherung des vollständigen Werts erfordert eine alternative Lösung.

Lösung: Speicherung als ISO-8601-String

Die zuverlässigste Möglichkeit, OffsetDateTime inklusive Offset zu speichern, besteht darin, ihn als formatierten String in einer PostgreSQL-Spalte vom Typ TEXT oder VARCHAR abzulegen. Das ISO-8601-Format (yyyy-MM-dd'T'HH:mm:ssXXX) eignet sich ideal dafür, da es von Java nativ unterstützt wird und sowohl den Zeitpunkt als auch den Offset in einem Wert kodiert.

Ein Beispielwert sieht folgendermaßen aus:

2025-05-10T14:23:00+02:00
Code-Sprache: CSS (css)

Wird dieser String gespeichert, bleibt der Offset erhalten und lässt sich später exakt wieder in ein OffsetDateTime-Objekt umwandeln. Für diese Konvertierung bietet sich in Spring ein benutzerdefinierter AttributeConverter an.

Umsetzung mit Spring Boot und Spring Data

Die Implementierung in einer Spring-Anwendung ist unkompliziert. Die relevanten Schritte umfassen die Definition des Datenbankmodells, die Entitätsklasse, den Konverter sowie das Repository.

1. Datenbankstruktur

Die entsprechende Tabelle in PostgreSQL verwendet für die Zeitspalte den Typ TEXT:

CREATE TABLE events (
    id UUID PRIMARY KEY,
    name TEXT NOT NULL,
    occurred_at TEXT NOT NULL
);
Code-Sprache: PHP (php)

Alternativ kann VARCHAR mit einer Längenbegrenzung verwendet werden. Ein ISO-8601-String benötigt typischerweise 25 bis 35 Zeichen, abhängig von Genauigkeit und Offset.

2. Konverter für OffsetDateTime

Der Konverter übernimmt die Umwandlung zwischen OffsetDateTime und String:

import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;

@Converter(autoApply = true)
public class OffsetDateTimeStringConverter implements AttributeConverter<OffsetDateTime, String> {

    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;

    @Override
    public String convertToDatabaseColumn(OffsetDateTime attribute) {
        return attribute != null ? FORMATTER.format(attribute) : null;
    }

    @Override
    public OffsetDateTime convertToEntityAttribute(String dbData) {
        return dbData != null ? OffsetDateTime.parse(dbData, FORMATTER) : null;
    }
}
Code-Sprache: PHP (php)

Dank autoApply = true wird der Konverter automatisch auf alle Felder vom Typ OffsetDateTime angewendet, sofern kein anderer Konverter angegeben ist.

3. Entity-Klasse

Die Entität verwendet das OffsetDateTime-Feld wie gewohnt:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.OffsetDateTime;
import java.util.UUID;

@Entity
public class Event {

    @Id
    private UUID id;

    private String name;

    private OffsetDateTime occurredAt;

    public Event() {}

    public Event(UUID id, String name, OffsetDateTime occurredAt) {
        this.id = id;
        this.name = name;
        this.occurredAt = occurredAt;
    }

    // Getter und Setter
}
Code-Sprache: JavaScript (javascript)

4. Repository und Nutzung

Das zugehörige Repository benötigt keine besonderen Anpassungen:

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;

public interface EventRepository extends JpaRepository<Event, UUID> {
}
Code-Sprache: CSS (css)

Ein typischer Anwendungscode kann wie folgt aussehen:

import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;

@Service
public class EventService {

    private final EventRepository repository;

    public EventService(EventRepository repository) {
        this.repository = repository;
    }

    public void createEvent() {
        OffsetDateTime dateTime = OffsetDateTime.now(ZoneOffset.ofHours(2));
        Event event = new Event(UUID.randomUUID(), "Systemstart", dateTime);
        repository.save(event);
    }

    public OffsetDateTime getOccurredAt(UUID id) {
        return repository.findById(id)
                .map(Event::getOccurredAt)
                .orElseThrow();
    }
}
Code-Sprache: PHP (php)

Validierung und Tests

Beim Schreiben von Tests empfiehlt es sich, nicht nur den Zeitpunkt, sondern auch den Offset zu prüfen:

import static org.junit.jupiter.api.Assertions.*;

@Test
void testOffsetDateTimePersistence() {
    OffsetDateTime expected = OffsetDateTime.of(2025, 5, 10, 14, 0, 0, 0, ZoneOffset.ofHours(3));
    Event event = new Event(UUID.randomUUID(), "Testevent", expected);
    repository.save(event);

    Event loaded = repository.findById(event.getId()).orElseThrow();
    assertEquals(expected, loaded.getOccurredAt());
}
Code-Sprache: JavaScript (javascript)

Damit ist sichergestellt, dass Offset und Zeitpunkt identisch bleiben – unabhängig von der aktuellen JVM-Zeitzone oder Datenbankkonfiguration.

Alternative: Zeit und Offset getrennt speichern

Ein alternativer Ansatz besteht darin, den UTC-Zeitpunkt (Instant) und den Offset separat zu speichern, etwa in zwei Spalten: TIMESTAMPTZ und INTEGER (für Minuten-Offset). Diese Methode erlaubt komplexe zeitbasierte SQL-Abfragen, erfordert jedoch mehr manuellen Aufwand im Mapping und der Konvertierung.

Fazit

Wenn es erforderlich ist, OffsetDateTime-Werte in PostgreSQL vollständig inklusive ihres Offsets zu speichern und wieder exakt zu laden, ist der Standardweg über timestamptz nicht ausreichend. Der Offset geht bei diesem Datentyp zwangsläufig verloren, da PostgreSQL intern alle Zeitangaben nach UTC konvertiert.

Die zuverlässigste Lösung besteht darin, die Werte als ISO-8601-konforme Strings in einer TEXT-Spalte zu speichern und in der Anwendung mit einem AttributeConverter zu behandeln. Dadurch bleibt die gesamte Information – Datum, Uhrzeit und Offset – vollständig erhalten und ist unabhängig von Zeitzonen- oder Konfigurationseinflüssen der Datenbank oder des JDBC-Treibers.

Diese Methode ist einfach zu implementieren, transparent zu debuggen und eignet sich besonders für Anwendungen, bei denen zeitliche Präzision und Nachvollziehbarkeit eine entscheidende Rolle spielen.