CodeGym /Kurse /Spring Data JPA /Spring und DataIntegrityViolationException

Spring und DataIntegrityViolationException

Spring Data JPA
Level 24, Lektion 2
Verfügbar

1. Wo der Datenbankfehler entsteht

Wenn wir „Datenbankfehler“ hören, denkt ein Anfänger meist: „Aha, dann fliegt jetzt SQLException, ich fange sie und alles ist gut.“ Aber in einer Spring-Boot-Anwendung steckt zwischen Ihrem Service und der Datenbank ein ganzer „Schichtstapel“: JDBC-Treiber, Hibernate, Spring ORM, Spring Data. Der Fehler entsteht zwar in der Datenbank, erreicht Ihren Code aber bereits in einer „zivilisierteren Verpackung“.

Stellen wir uns ein einfaches Szenario vor: Wir versuchen, in die Tabelle product eine zweite Zeile mit derselben sku einzufügen, obwohl in der Migration ein Constraint uk_product_sku unique (sku) definiert ist. Auf PostgreSQL-Seite ist das keine „Java-Exception“, sondern eine Ablehnung des INSERT. Die Datenbank erzeugt eine Antwort mit Fehlercode und Beschreibung. Der JDBC-Treiber (org.postgresql) wandelt diese Antwort in eine Java-Exception auf Treiberebene um (normalerweise PSQLException, eine Unterklasse von SQLException). Hibernate fängt dies ab, verpackt es in seine eigenen Exceptions (oft etwas wie ConstraintViolationException aus Hibernate). Erst dann sagt Spring: „Okay, ich habe verstanden, das ist ein Integritätsproblem“ – und verwandelt das Ganze in eine DataIntegrityViolationException.

Es ist hilfreich, sich diese „Fehlerlieferkette“ einzuprägen:

flowchart TD
    A[PostgreSQL: constraint violation] --> B["JDBC-Treiber: SQLException / PSQLException"]
    B --> C["Hibernate: JDBCException / ConstraintViolationException"]
    C --> D["Spring ORM/JPA: Exception-Übersetzung"]
    D --> E[Spring Data: DataAccessException]
    E --> F[Ihr Service: DataIntegrityViolationException]

Sie fangen also nicht die „rohe“ Stimme der Datenbank, sondern ein „übersetztes“ und stabileres Signal. Und das ist keine Magie, sondern eine durchaus ingenieurmäßige Lösung, die Ihren Code vor der Bindung an einen bestimmten Treiber, Dialekt und an Fehlermeldungen schützt.

2. Die Idee von DataAccessException

Wenn Sie jemals einen Datenbankfehler manuell abgefangen haben, ist Ihnen vielleicht aufgefallen: Die Fehlermeldungen sind bei verschiedenen Datenbanken unterschiedlich, die Exception-Klassen bei verschiedenen Treibern unterschiedlich, und selbst dasselbe Szenario (z. B. UNIQUE violation) kann anders benannt und aussehen. Spring hat einen Weg gefunden, diese „Vielfalt“ nicht in den Anwendungscode zu tragen: Es übersetzt die Low-Level-Fehler in eine eigene Exception-Hierarchie DataAccessException.

Der Sinn von DataAccessException ist, ein einheitlicher Vertrag für die Datenzugriffsschicht zu sein. Wenn Sie heute mit JPA arbeiten, morgen in einem anderen Projekt JDBC Template begegnen und übermorgen einen ganz anderen Persistenzansatz, werden Sie trotzdem verstehen: „DataIntegrityViolationException – bedeutet Integritätsverletzung, DeadlockLoserDataAccessException – bedeutet Deadlock“ (ja, die Namen klingen manchmal wie eine Rockband, aber das gehört zum Genre).

Wichtig ist auch, dass DataAccessException eine Runtime Exception (unchecked) ist. Spring hat die Datenzugriffsschicht bewusst so gestaltet: Die meisten Datenbankfehler lassen sich in der Praxis ohnehin nicht „vor Ort“ im Repository beheben; sie werden entweder in eine verständliche Reaktion des Anwendungsfalls auf Service-Ebene umgewandelt oder führen zu einem Rollback der Transaktion. Daher zwingen die Exceptions Sie nicht dazu, in jeder Methode throws zu schreiben.

Um nicht nur abstrakt zu bleiben, hier ein kurzes Beispiel: Wir sind nicht an SQLException gebunden, wir fangen direkt den Spring-Vertrag:

import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;

@Service
public class DataLayerExample {

    public void doSomething() {
        try {
            // Hier steht normalerweise der Aufruf des Repositories / EntityManager.
            // Wichtig: Wir fangen nicht SQLException, wir arbeiten mit dem Spring-Vertrag.
        } catch (DataAccessException ex) {
            // Allgemeiner Spring-Vertrag für Data-Layer-Fehler.
            // In ex.getCause() kann eine Treiber-/Hibernate-Exception stecken,
            // aber nach außen "halten" wir einheitliche Fehlertypen.
        }
    }
}

Das bedeutet nicht, dass man DataAccessException „überall und immer“ fangen sollte (das wäre eher ein Anti-Pattern). Aber es ist wichtig, die Idee des Vertrags zu verstehen: Spring macht Fehler portabel und auf Architekturebene verständlich, nicht auf der Ebene „welcher Treiber auf der Maschine installiert war“.

3. Exception-Übersetzung im JPA-Stack

Es scheint, als sitze „irgendwo in Spring“ ein kleiner Übersetzer, der von PostgreSQL-Sprache in Java-Sprache übersetzt. Tatsächlich gibt es diesen Übersetzer, nur ist er nicht klein und nicht allein. In der JPA-Welt findet die Übersetzung an zwei Schlüsselstellen statt: auf Repository-Ebene und auf Transaktionsebene (Commit/Flush). Das ist wichtig, weil der Fehler möglicherweise nicht sofort auftaucht, sondern erst bei der Synchronisation mit der Datenbank.

Einfach ausgedrückt versucht Spring, sicherzustellen, dass aus der Data-Layer nur Exceptions aus dem Paket org.springframework.dao auftauchen und nicht ein „Zoo“ aus java.sql, org.hibernate und dem Treiber. Dafür sind Mechanismen aus Spring ORM und Spring TX zuständig.

Eine wichtige Idee: Spring Data Repositories sind Spring-Beans und beteiligen sich an der Übersetzungsinfrastruktur. Die klassische Spring-Mechanik verwendet die Annotation @Repository (oder die Rolle des Repositories im Container) als Marker: „Wenn aus dieser Komponente ein Persistenzfehler fliegt – übersetze ihn.“ Bei Spring Data Repositories werden sie als Proxy-Bean erstellt, und ihr Verhalten ist bereits in dieses Schema „eingebaut“.

Eine weitere Schicht ist der Transaktionsmanager. Wenn wir in unserem Service @Transactional schreiben, führt der JpaTransactionManager den Commit der Transaktion durch. Wenn während des Commit/Flush ein Fehler auftritt, ist er oft die Stelle, an der die Exception in die Spring-Form übersetzt wird und nach außen fliegt.

Aus Entwicklersicht sieht das einfach aus: Sie arbeiten mit JpaRepository und sehen bei Problemen eine DataIntegrityViolationException. Aber intern beteiligen sich:

JDBC-Treiber (gibt SQLException zurück), Hibernate (verpackt), Spring ORM (erkennt Fehlertypen), Spring TX (beendet Transaktionen und kann dort den Fehler „auswerfen“), Spring Data (macht das Repository zu einer Container-Komponente).

Ja, das ist mehrschichtig. Aber Ihr Service-Code wird dadurch nicht zu einem Kurs „Errate den Fehler anhand des SQLSTATE“, sondern bleibt normaler Anwendungscode.

4. flush und der Zeitpunkt des Fehlers

Die häufigste Emotion eines Anfängers in der JPA-Welt lautet: „Ich habe save() aufgerufen, aber der Fehler kommt fünf Zeilen später und erst am Ende der Methode. Hat Hibernate etwa Groll gehegt?“ Tatsächlich ist fast immer flush und die Tatsache schuld, dass JPA über den Persistenzkontext arbeitet: Sie ändern Java-Objekte, und SQL wird nicht unbedingt sofort an die Datenbank gesendet.

Wir haben bereits über den Persistenzkontext und Dirty Checking gesprochen: Solange sich eine Entität im managed-Zustand befindet, kann Hibernate Änderungen sammeln und sie später gebündelt bei der Synchronisation senden. Diese Synchronisation ist flush. Eine wichtige Nuance: save() ist kein Befehl „sofort SQL ausführen“. Oft ist es der Befehl „Entität in den Kontext aufnehmen und Änderungen vorbereiten“. Der tatsächliche INSERT/UPDATE erfolgt beim flush.

Und flush passiert in typischen Szenarien:

— automatisch beim Commit der Transaktion;
— manchmal vor der Ausführung einer Query (damit die Abfrage die aktuellen Daten sieht);
— manuell, wenn Sie flush() oder saveAndFlush() aufrufen.

Daraus ergibt sich eine praktische Konsequenz: Eine Verletzung von UNIQUE, NOT NULL oder FK kann nicht beim Ändern des Feldes und nicht beim Aufruf von save() erkannt werden, sondern erst beim Flush/Commit. Und das ist normal: Die Datenbank erfährt von dem Problem erst, wenn ihr tatsächlich SQL gesendet wird.

Eine kleine „Zeitleiste“ zum Verständnis:

sequenceDiagram
    participant S as "Service (@Transactional)"
    participant PC as "Persistenzkontext"
    participant DB as PostgreSQL

    S->>PC: productRepository.save(product)
    Note over PC: Änderungen im Kontext, SQL wurde möglicherweise noch nicht gesendet
    S->>S: ...weitere Geschäftslogik...
    S->>PC: flush (normalerweise beim Commit)
    PC->>DB: INSERT INTO product ...
    DB-->>PC: FEHLER: unique violation / FK violation / not null
    PC-->>S: Exception (von Spring übersetzt)

Wenn man dieses Schema im Kopf behält, hören die „plötzlichen“ Fehlerstellen auf, plötzlich zu sein. Der Fehler tritt einfach dort auf, wo das SQL tatsächlich die Datenbank erreicht hat.

5. save(), flush() und saveAndFlush()

Wenn Sie zum ersten Mal auf DataIntegrityViolationException stoßen, möchten Sie oft verstehen: „Okay, wie kann ich dafür sorgen, dass der Fehler direkt hier auftaucht, wo es mir passt, und nicht erst am Ende der Transaktion?“ Dafür bietet Spring Data JPA ein sehr einfaches und praktisches Werkzeug: einen erzwungenen flush – entweder durch einen separaten Aufruf oder über die Methode saveAndFlush().

Zuerst klären wir die Bedeutung. save() kann innerhalb einer Transaktion die Änderungen nur „vorbereiten“. flush() zwingt Hibernate, die angesammelten SQLs sofort an die Datenbank zu senden. saveAndFlush() führt beide Schritte nacheinander aus. Das ist keine „Magie“, sondern lediglich die Steuerung des Synchronisationszeitpunkts. Im Kontext von Tag 24 ist das besonders nützlich, weil wir Einschränkungsverletzungen an einem klaren Punkt im Szenario sehen möchten.

Hier ist eine kompakte Tabelle, die Sie sich wirklich merken sollten:

Methode Was sie tut Wann sie normalerweise benötigt wird
save(entity) Fügt Entität hinzu/aktualisiert sie im Persistenzkontext Fast immer als Basisschritt
flush() Sendet angesammeltes SQL sofort an die DB Wenn Sie einen Constraint-Fehler an einer bestimmten Stelle „fangen“ wollen
saveAndFlush(entity) save() + flush() Wenn Sie etwas erstellen/aktualisieren und sofort wissen wollen, ob die DB es akzeptiert hat

Beispiel „Fehler direkt hier fangen“ in unserem CatalogService. Beachten Sie: Der Code ist bewusst kurz, weil wir jetzt die Mechanik lernen und keinen perfekten Service-API bauen.

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    @Transactional
    public void createProductAndFailFast(Product product) {
        try {
            // saveAndFlush = "speichern und sofort SQL an die DB senden",
            // damit der Constraint-Fehler an dieser Stelle erscheint und nicht erst beim Commit.
            productRepository.saveAndFlush(product);
        } catch (DataIntegrityViolationException ex) {
            // Auf dieser Ebene fangen wir den Spring-Vertrag.
            // Treiberdetails (PSQLException) liegen tiefer in der cause.
            throw ex; // vorerst nur weiterwerfen, der "Sinn" kommt in Lektion 4
        }
    }
}

Manchmal ist es praktischer, save() und separat flush() zu verwenden – zum Beispiel, wenn Sie mehrere Entitäten speichern und wissen möchten, bei welchem Schritt die Datenbank abgelehnt hat:

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void createCategoryAndProduct(Category cat, Product p) {
    // Zuerst legen wir die Änderungen im Persistenzkontext ab.
    categoryRepository.save(cat);
    productRepository.save(p);

    // Und jetzt synchronisieren wir erzwungen mit der DB.
    // Wenn UNIQUE/FK/NOT NULL verletzt sind, erscheint der Fehler genau hier.
    productRepository.flush();
}

Das ist nicht per se „richtiger“ oder „schneller“. Es ist ein Werkzeug, das hilft, das Szenario beobachtbar und steuerbar zu machen. Und im Lernprojekt hilft es auch zu verstehen, was wirklich passiert.

6. Was steckt in DataIntegrityViolationException

Wenn Sie DataIntegrityViolationException sehen, taucht eine zweite häufige Frage auf: „Wie kann ich herausfinden, was genau kaputt ist – UNIQUE, FK oder NOT NULL? Auf der obersten Ebene versucht Spring nicht, allzu spezifisch zu raten: Es sagt „Integrität verletzt“, und das ist ehrlich. Aber im Inneren der Exception steckt fast immer eine Kette von cause, in der eine konkretere Ursache liegt.

Eine typische Kette für PostgreSQL könnte so aussehen (vereinfacht):

— DataIntegrityViolationException (Spring)
— ConstraintViolationException (Hibernate, wegen DB-Constraints)
— PSQLException (PostgreSQL-Treiber)

Die wichtigste methodische Warnung des Tages: Verwechseln Sie nicht die zwei verschiedenen ConstraintViolationException.

1) jakarta.validation.ConstraintViolationException – das betrifft Bean Validation (Annotationen wie @NotBlank).
2) org.hibernate.exception.ConstraintViolationException – das betrifft DB-Constraint-Fehler (UNIQUE, FK, NOT NULL usw.).

Sie sind Namensvettern. Im echten Leben ist das, als gäbe es zwei verschiedene Personen namens „Alex“ in einem Team: Wenn man nicht den Nachnamen präzisiert, bricht Chaos aus.

In unserem Projekt (Level 24) sprechen wir genau von Verletzungen des DB-Schemas, daher werden wir öfter die Hibernate-Variante sehen.

Um die Grundursache zu sehen, müssen wir kein Rad neu erfinden. Spring bietet das Hilfsmittel NestedExceptionUtils:

import org.springframework.core.NestedExceptionUtils;
import org.springframework.dao.DataIntegrityViolationException;

public class IntegrityLogHelper {

    public static String rootMessage(DataIntegrityViolationException ex) {
        // Holt die "spezifischste" Ursache in der Kette (oft PSQLException).
        Throwable root = NestedExceptionUtils.getMostSpecificCause(ex);

        // Nützlich für Logs/Diagnose: Sie sehen, was die DB/der Treiber tatsächlich gesagt hat.
        return root.getClass().getSimpleName() + ": " + root.getMessage();
    }
}

Für die Root-Nachricht ist das in Ordnung: Wir brauchen die tiefste Stimme des Treibers. Aber für den Constraint-Namen taugt dieser Shortcut nicht. Die tiefste Ursache ist oft PSQLException, während der Constraint-Name normalerweise eine Ebene höher lebt – in der Hibernate ConstraintViolationException.

Wenn Sie den Constraint-Namen extrahieren möchten (und der ist bei uns in den Migrationen sauber vergeben, z. B. uk_product_sku), ist es besser, die gesamte cause-Kette zu durchlaufen, anstatt nur die tiefste Ursache zu betrachten:

import org.hibernate.exception.ConstraintViolationException;
import org.springframework.dao.DataIntegrityViolationException;

public class ConstraintNameExtractor {

    public static String tryGetConstraintName(DataIntegrityViolationException ex) {
        Throwable current = ex;

        // Wir gehen die gesamte cause-Kette durch: Der konkrete Constraint-Name
        // kann in einer nicht tiefsten Exception liegen.
        while (current != null) {
            if (current instanceof ConstraintViolationException cve) {
                return cve.getConstraintName();
            }
            current = current.getCause();
        }

        // Es ist nicht immer möglich, den Constraint-Namen zu bestimmen – das ist ein normales Szenario.
        return null;
    }
}

Wichtig: Wir parsen nicht den Text der PostgreSQL-Meldung („Key (sku)=(...) already exists“), denn das ist zerbrechlich. Wir arbeiten mit strukturierteren Informationen (Ursachenklasse, Constraint-Name). Nicht immer wird er verfügbar sein, aber wenn Sie Constraints in Flyway selbst benennen, ist die Chance deutlich höher.

Und noch eine Nuance. Selbst wenn Sie den Constraint-Namen extrahiert haben, sollten Sie daraus keine „Geschäftslogik auf Basis von SQL-Codes“ machen. Das ist nützlich für Logs und punktuelle Diagnose, aber noch kein fertiges Geschäftskriterium. Eine DataIntegrityViolationException, die aus createProduct() kommt, kann nicht automatisch als DuplicateSkuException bezeichnet werden: Zuerst muss geklärt werden, ob der Konflikt erwartet war und ob er den Constraint betrifft, der Sie interessiert.

7. Typische Fehler mit DataIntegrityViolationException

Fehler Nr. 1: Im Service auf SQLException warten und darauf Logik aufbauen.
In Spring Data JPA erhalten Sie fast immer eine Exception aus der DataAccessException-Hierarchie. Wenn Sie catch (SQLException e) schreiben, ist das normalerweise ein Zeichen dafür, dass Sie versuchen, mit der falschen Abstraktionsebene zu kommunizieren. Die Treiberfehler stecken in der cause, der Hauptvertrag ist Spring.

Fehler Nr. 2: Denken, DataIntegrityViolationException bedeute nur UNIQUE.
In der Praxis kann es sowohl NOT NULL, FOREIGN KEY als auch etwas Exotischeres sein. Spring sagt ehrlich „Integrität verletzt“, verspricht aber nicht, dass es ein Schlüsselduplikat ist. Daher darf man nicht automatisch jeden solchen Fehler in „SKU existiert bereits“ umwandeln.

Fehler Nr. 3: Den flush-Zeitpunkt nicht berücksichtigen und sich wundern, „warum nicht beim save()“.
Wenn SQL erst beim Commit/Flush zur Datenbank geht, erscheint der Fehler auch dort. In einer Service-Transaktion ist das oft das Ende der Methode. Für ein klares Verhalten muss man manchmal saveAndFlush() oder flush() an einer Kontrollstelle aufrufen – besonders wenn Sie den Fehler als Teil des Szenarios behandeln möchten.

Fehler Nr. 4: Die PostgreSQL-Fehlermeldung als Geschäftsvertrag parsen.
Die Treibermeldung kann sich ändern, lokalisiert werden oder sich zwischen Versionen unterscheiden. Wenn Sie if (ex.getMessage().contains("duplicate key")) schreiben, bauen Sie Ihr Haus auf Sand. Arbeiten Sie besser mit dem Constraint-Namen (wenn verfügbar), SQLSTATE (wenn Sie genau wissen, was Sie tun), oder interpretieren Sie die Situation einfach auf Szenarioebene ohne „Raten“.

Fehler Nr. 5: Hibernate- und Bean-Validation-Exceptions mit demselben Namen verwechseln.
jakarta.validation.ConstraintViolationException betrifft die Validierung mit Annotationen in Java. org.hibernate.exception.ConstraintViolationException betrifft die DB-Ablehnung wegen eines Constraints. Sie unterscheiden sich in ihrer Bedeutung und dem Ort ihres Auftretens. An Tag 24 interessiert uns die DB-Variante, sonst „behandeln“ Sie die falsche Ursache.

1
Aufgabe
Spring Data JPA, Level 24, Lektion 2
Gesperrt
DataIntegrityViolationException bei saveAndFlush() abfangen
DataIntegrityViolationException bei saveAndFlush() abfangen
1
Aufgabe
Spring Data JPA, Level 24, Lektion 2
Gesperrt
Zeigen, dass der Fehler `FOREIGN KEY` bei `flush()` auftritt
Zeigen, dass der Fehler `FOREIGN KEY` bei `flush()` auftritt
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION