1. Validierung von Listen am Beispiel von Tags
Wenn wir DTOs entwerfen, denken wir sehr leicht nur an „normale Felder“: title, description, assigneeName. Aber in einer echten API tauchen fast sofort Sammlungen auf: eine Liste von Tags, eine Liste von Anhängen, eine Liste von Filtern, eine Liste von irgendetwas anderem. Und genau diese Sammlungen werden meistens zur Quelle des Chaos, weil sie zwei Probleme gleichzeitig haben: wie viele Elemente gekommen sind und wie „adäquat“ jedes Element ist. Tags in der Task Tracker API sind ein hervorragendes Lehrbeispiel: Es ist einfach List<String>, aber der Geschäftssinn ist durchaus erwachsen.
Stellen Sie sich vor, Sie erlauben dem Client, tags zu senden, aber schränken dieses Feld überhaupt nicht ein. Eines schönen Tages sendet jemand 500 Tags (aus Versehen natürlich … nur die Schaltfläche „Tag hinzufügen“ hat sich verhakt). Oder er sendet ["", " ", "\n"], weil das UI auf dem Frontend „irgendwie“ die leere Eingabe nicht blockiert hat. Und dann werden Sie lange darüber nachdenken, warum die Suche nach Tags nicht funktioniert und wie es passiert ist, dass eine Aufgabe das Tag „Leere“ hat.
Die Validierung von Sammlungen ist eine Disziplin, die hilft, den Vertrag einzuhalten. Und hier ist es wichtig, sich an ein einfaches Modell zu gewöhnen: Die Liste hat einen Container (die Liste selbst) und sie hat Elemente. Wenn Sie nur den Container geprüft haben, ist das so, als würden Sie prüfen, ob der Bus nicht überfüllt ist, aber gar nicht prüfen, wer eingestiegen ist. Wenn Sie nur die Elemente geprüft haben, ist das so, als würden Sie sicherstellen, dass jeder Fahrgast zurechnungsfähig ist, aber erlauben, dass der Bus in einer Anzahl von 200 Personen in einen Salon mit 40 Plätzen kommt.
2. Zwei Ebenen: Container und Elemente
Sammlungen werden in Bean Validation „zweistöckig“ geprüft, und das ist der beste Weg, um nicht durcheinanderzukommen. Die erste Etage sind die Einschränkungen für die Sammlung selbst als Objekt: die Größe der Liste, ob sie obligatorisch ist, die Mindestanzahl der Elemente. Die zweite Etage sind die Einschränkungen für jedes einzelne Element darin: leere Zeichenketten, Länge der Zeichenkette, Format, und wenn die Elemente DTOs sind, dann auch kaskadierende Validierung über ihre Felder. Das ist ähnlich wie die Überprüfung eines Pakets auf der Post: zuerst schauen wir, ob der Karton nicht schwerer als das Limit ist und überhaupt existiert, und dann prüfen wir, ob darin nicht „Luft im Tütchen“ statt Ware steckt.
Es ist wichtig, eine grundlegende Besonderheit von Bean Validation zu beachten: Die meisten Constraint-Annotationen (einschließlich @Size) betrachten null als gültigen Wert. Dies wurde speziell so gemacht, dass null bedeutet „nichts zu prüfen“ und die Verbindlichkeit durch eine separate Regel festgelegt wird (@NotNull, @NotEmpty, @NotBlank). Für Sammlungen bedeutet das eine einfache Sache: @Size(max = 10) begrenzt die Größe der Liste, aber wenn das Feld insgesamt null ist, besteht es die Prüfung. Und manchmal ist genau das, was Sie brauchen (z. B. tags sind optional), und manchmal ist es eine Überraschung.
Um dies nicht in eine Reihe magischer Vermutungen zu verwandeln, ist es praktisch, sich folgendes Bild im Kopf zu behalten:
flowchart TD
A["Feld tags: List<String> "] --> B["@Size(max=10) auf dem Feld tags begrenzt die Anzahl der Elemente"]
A --> C["Jedes Element tags[i]: String"]
C --> D["@NotBlank + @Size(max=30) auf dem Elementtyp
begrenzt die Qualität jedes Tags"]
Im Folgenden werden wir dies auf unser TaskCreateRequest anwenden und sehen, wie genau man den Vertrag mit Annotationen so „unterschreibt“, dass er lesbar ist und tatsächlich funktioniert.
3. @Size für die Tag-Liste
Wenn Sie @Size auf eine Sammlung setzen, prüfen Sie die Anzahl der Elemente, nicht die Länge jeder Zeichenkette. Diese Verwechslung tritt sehr häufig auf: Ein Entwickler sieht @Size(max = 10) und denkt „aha, maximal 10 Zeichen pro Tag“, aber in Wirklichkeit hat er gesagt „maximal 10 Tags“. Wenn also im Projekt die Regel gilt „maximal 10 Tags pro Aufgabe“, dann ist @Size(max = 10) genau das, was der Arzt verordnet hat (im übertragenen Sinne; im wörtlichen Sinne verschreibt der Arzt normalerweise Schlaf).
Ein Beispiel für unser Projekt: In TaskCreateRequest fügen wir eine Einschränkung für die Anzahl der Tags hinzu. Beachten Sie: Hier machen wir tags noch nicht zu einem Pflichtfeld – es kann fehlen.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
// DTO zum Erstellen einer Aufgabe: Wir validieren sowohl normale Felder als auch Sammlungen
public record TaskCreateRequest(
@NotBlank @Size(min = 3, max = 120) String title,
@Size(max = 10) List<String> tags // Einschränkung auf den Container: maximal 10 Tags; null/Fehlen des Feldes ist erlaubt
) {}
Was bedeutet das auf Vertragsebene:
- wenn tags nicht übergeben (Feld fehlt im JSON) oder als null übergeben wird, greift die Prüfung @Size(max=10) nicht, weil null als gültiger Wert betrachtet wird;
- wenn tags als Liste übergeben wird, darf ihre Länge nicht größer als 10 sein.
Hier ist ein Beispiel einer Anfrage, die diese spezielle Prüfung besteht:
{
"title": "Milch kaufen",
"tags": ["home", "shopping"]
}
Und hier ein Beispiel einer Anfrage, die durchfällt (11 Tags sind zu viel):
{
"title": "Milch kaufen",
"tags": ["t1","t2","t3","t4","t5","t6","t7","t8","t9","t10","t11"]
}
Nun eine wichtige Frage des API-Designs: Sollte tags überhaupt null sein dürfen? In unserem Projekt kann dieses Feld durchaus optional sein: Der Benutzer erstellt eine Aufgabe und fügt die Tags später hinzu. In diesem Fall sind null und das Fehlen des Feldes kein Problem. Wenn Sie jedoch (bewusst!) den Client zwingen möchten, das Feld immer zu senden, auch wenn es leer ist, dann reicht ein einzelnes @Size nicht aus – Sie müssen die „Verbindlichkeit“ durch eine separate Regel hinzufügen.
Eine einfache und lesbare Variante ist @NotNull plus @Size:
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
@NotNull // Feld ist obligatorisch: Der Client muss tags senden (kann [] sein – das ist gültig)
@Size(max = 10) // Einschränkung auf den Container: maximal 10 Elemente
List<String> tags
) {}
Hier wird der Vertrag strenger: tags kann nicht weggelassen werden. Beachten Sie jedoch, dass @NotNull keine „nicht leere Liste“ verlangt. Das ist ein wichtiges Detail: Die Liste [] ist gültig. Wenn Sie mindestens ein Element verlangen möchten, ist das eine andere Semantik: @Size(min = 1) oder @NotEmpty (aber das ist eine Frage des konkreten Vertrags; für Tags ist dies normalerweise nicht erforderlich).
4. Einschränkungen für die Elemente der Liste
Die Anzahl der Tags zu begrenzen, ist die eine Hälfte der Geschichte. Die andere Hälfte ist die Qualität jedes einzelnen Tags. Wenn wir die Elemente nicht prüfen, kann der Client ["", " ", " "] senden, und formal ist die Liste „nicht größer als 10“, aber inhaltlich ist es Müll. Daher fügen wir Constraints innerhalb des generischen Typs hinzu: Die Annotationen werden auf den Elementtyp geschrieben, also auf String innerhalb von List<...>. Dies wird als Container-Element-Constraints bezeichnet und wird von Hibernate Validator unterstützt (der in unserem Baseline vorhanden ist).
Hier ist das kanonische Beispiel für TaskCreateRequest, das zwei Dinge auf einmal festlegt: „maximal 10 Tags“ und „jeder Tag ist nicht leer und nicht länger als 30 Zeichen“.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
@NotBlank @Size(min = 3, max = 120) String title,
// Container-Regel: Begrenzung der Anzahl der Listenelemente
@Size(max = 10)
// Regeln für die Elemente: Jeder Tag ist nicht leer und nicht länger als 30 Zeichen
List<@NotBlank @Size(max = 30) String> tags
) {}
Lesen Sie dies so, als würden Sie einen Vertrag schreiben:
- @Size(max = 10) bezieht sich auf tags als Liste: „nicht mehr als 10 Elemente“;
- @NotBlank und @Size(max = 30) beziehen sich auf jedes String innerhalb der Liste: „jeder Tag ist kein leerer String, maximal 30 Zeichen“.
Nun eine gute Frage eines Anfängers: Warum kann man nicht einfach @NotBlank auf das Feld tags setzen? Weil @NotBlank für String (und allgemein CharSequence) bestimmt ist, tags aber ein List ist. Im besten Fall erhalten Sie einen Fehler beim Start der Anwendung (der Validator sagt, dass das Constraint auf einen nicht unterstützten Typ angewendet wurde), im schlimmsten Fall ein seltsames Verhalten, das Sie stundenlang „beheben“, weil die Annotation zwar zu stehen scheint, der Sinn aber nicht stimmt.
Es ist auch wichtig zu verstehen, dass @NotBlank „nach trim“ prüft: Der String " " gilt als ungültig. Das ist ideal für Tags, denn „ein Tag aus Leerzeichen“ ist fast Philosophie, aber sicher kein Geschäftswert.
Ein JSON-Beispiel, das bei der Elementvalidierung durchfällt:
{
"title": "Milch kaufen",
"tags": ["home", " "]
}
Aus Vertragssicht ist das sehr gut: Der Fehler wird an der API-Grenze abgefangen, vor dem Service, vor dem Repository, vor dem „warum schlüpft bei uns ein leeres Tag in die Ausgabe“.
5. null, [] und [""]: verschiedene Zustände
Wenn es um Sammlungen in JSON geht, haben wir einen subtilen, aber sehr praktischen Punkt: null, [] und [""] sind nicht dasselbe. Und wenn Sie diese Fälle als „ungefähr gleich“ wahrnehmen, wird der Vertrag schnell unlesbar. Hier ist es nützlich, den Modus eines etwas pedantischen API-Designers einzuschalten, denn genau dieser Pedant rettet das Projekt in ein paar Monaten vor dem Chaos.
Betrachten Sie die drei Zustände so:
| Was der Client gesendet hat | Wie es in Java wird | Welche Bedeutung hat es normalerweise | Welche Prüfungen werden ausgelöst |
|---|---|---|---|
| Feld fehlt oder "tags": null | tags == null | „Tags nicht angegeben“ (oder „Tags nicht ändern“ bei PATCH) | @Size wird nicht ausgelöst, Element-Constraints ebenfalls nicht |
| "tags": [] | tags.size() == 0 | „Keine Tags“ | @Size(max=10) wird ausgelöst (gültig), Element-Constraints ebenfalls (nichts zu prüfen) |
| "tags": ["", " "] | Liste von Strings | „Tags angegeben, aber schlechte“ | Element-Constraints (@NotBlank) schlagen fehl |
Diese Tabelle erklärt, warum @NotNull und @Size unterschiedliche Werkzeuge sind. Wenn Sie null verbieten und verlangen möchten, dass das Feld immer vorhanden ist, setzen Sie @NotNull. Wenn Sie die Anzahl der Elemente begrenzen möchten, setzen Sie @Size. Wenn Sie leere Zeichenketten innerhalb der Liste verbieten möchten, setzen Sie @NotBlank auf das Element.
Nun eine kleine praktische Frage aus unserem Projekt: Wenn tags optional sind, ist es bequem, in der Serviceschicht ständig if(request.tags() == null) ... zu schreiben? Normalerweise nicht. Deshalb machen viele Teams eine sehr einfache Normalisierung: Wenn tags als null kommen, betrachte sie als leere Liste. Das ist keine Validierung, sondern eine Bequemlichkeit.
Zum Beispiel kann man auf Mapper-Ebene Folgendes tun (Mikro-Utility, ohne Magie):
import java.util.List;
public final class TagMapper {
private TagMapper() {
// Utility-Klasse: Instanzen werden nicht benötigt
}
public static List<String> nullToEmpty(List<String> tags) {
// Normalisierung für den Service: „kein Feld“ -> „leere Liste“
// Wichtig: Dies ersetzt nicht die Validierung, sondern vereinfacht nur die weitere Arbeit mit den Daten
return tags == null ? List.of() : tags;
}
}
Ja, das sieht fast zu einfach aus. Aber genau solche „zu einfachen“ Funktionen helfen sehr, die Services sauber zu halten. Und vor allem: Sie ersetzen nicht die Validierung. Sie verwandeln einfach „nicht vorhanden“ in „leer“, wenn dies nach Ihrem Vertrag zulässig ist.
6. Liste von DTOs: List<@Valid ...>
Bisher sind Tags eine Liste von Strings, und hier reichen Element-Constraints (@NotBlank, @Size). Aber im echten Leben (und in anderen Teilen des Projekts) werden Sie mit ziemlicher Sicherheit auf Sammlungen von Objekten stoßen: eine Liste von Formularelementen, eine Liste von Blöcken, eine Liste von irgendwelchen „Item“-DTOs. Hier kommt die gleiche Idee wie bei verschachtelten DTOs aus der vorherigen Vorlesung zum Tragen, nur im „Massenmodus“: Man muss explizit sagen „validiere jedes Element der Liste“.
Für DTO-Elemente funktioniert die kaskadierende Validierung über @Valid, aber es gibt eine wichtige Nuance: @Valid bedeutet nicht, dass das Element nicht null ist. Das heißt, wenn der Client items: [null] sendet, kann @Valid einfach nicht in das null „eintauchen“ und … schweigt. Daher macht man für eine Liste von Objekten normalerweise eine „doppelte Angabe“: @NotNull und @Valid auf dem Element.
Ein kleines Lehrbeispiel (es ist illustrativ; wir verwandeln die Task Tracker API nicht in ein Bulk-Operations-System, sondern trainieren nur die Mechanik):
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
public record CommentItemRequest(
@NotBlank @Size(max = 80) String authorName, // Autoreinschränkung: nicht leer und nicht zu lang
@NotBlank @Size(max = 1000) String text // Texteinschränkung: nicht leer und in angemessenen Grenzen
) {}
public record CommentListRequest(
@Size(max = 5) // Container-Regel: maximal 5 Kommentare in einer Anfrage
List<@NotNull @Valid CommentItemRequest> items // Auf dem Element: verbiete null und validiere kaskadenartig die DTO-Felder
) {}
Hier werden beide „Etagen“ geprüft:
- @Size(max = 5) begrenzt die Anzahl der Elemente in der Liste;
- @NotNull @Valid auf CommentItemRequest sagt: „Jedes Element muss existieren und muss gemäß seinen Feldern gültig sein“.
Diese Kombination macht den Vertrag sehr transparent. Der Client kann nicht mehr null als Element senden und hoffen, dass es „irgendwie durchkommt“. Außerdem kann Spring mitteilen, bei welchem Element der Fehler liegt (normalerweise wird dies als Pfad items[2].text ausgedrückt – der Index wird Teil des Fehlerpfads). Wir bauen noch kein eigenes Fehlerformat, aber selbst der Standardmechanismus von Spring zeigt bereits, dass dies adressiert geschieht, nicht im Stil von „irgendwo ist etwas nicht in Ordnung“.
7. Eindeutigkeit von Tags
Schließlich der heimtückischste Teil der Tags: die Eindeutigkeit. In unserem Projekt müssen Tags innerhalb einer Aufgabe ohne Berücksichtigung der Groß-/Kleinschreibung eindeutig sein. An dieser Stelle kommt einem Anfänger ein sehr natürlicher Gedanke: „Lasst uns einfach Set<String> verwenden – dann gibt es keine Duplikate!“. Das ist eine Idee aus der Kategorie „Lasst es uns so machen, dass das Problem verschwindet, anstatt es zu lösen“. Denn Set entfernt zwar Duplikate … aber still. Der Client hat ["Urgent", "urgent"] gesendet, und der Server hat „ordentlich“ einen Tag gespeichert. Das ist keine Validierung mehr, sondern eine heimliche Datenänderung, und der Client beginnt eine Detektivarbeit: „Ich habe definitiv zwei Tags gesendet, wo ist der zweite geblieben?“.
Wenn der Vertrag sagt „Duplikate sind verboten“, ist es richtiger, sie zu verbieten, anstatt sie „zu schlucken“. Das Problem ist, dass die Standard-Annotationen von Bean Validation nicht in der Lage sind, die Eindeutigkeit einer Liste von Strings „out of the box“ zu prüfen. Und das ist normal: @Size und @NotBlank sind grundlegende Bausteine, keine Enzyklopädie aller Geschäftsregeln.
Was wir auf dem aktuellen Verständnislevel tun können – zumindest sehen, wie die Prüfung auf Eindeutigkeit technisch aussieht. Zum Beispiel ein einfaches Utility zur Erkennung von Duplikaten ohne Berücksichtigung der Groß-/Kleinschreibung:
import java.util.List;
public final class TagRules {
private TagRules() {
// Utility-Klasse: Instanzen werden nicht benötigt
}
public static boolean hasDuplicatesIgnoreCase(List<String> tags) {
// Idee: Tags in „Normalform“ bringen (trim + lowerCase),
// die Anzahl der eindeutigen zählen und mit der ursprünglichen Listengröße vergleichen.
long unique = tags.stream()
.map(t -> t.trim().toLowerCase())
.distinct()
.count();
// Wenn es weniger Eindeutige als Elemente gibt – gibt es Duplikate (gemäß Normalisierungsregeln)
return unique != tags.size();
}
}
Diese Funktion selbst „baut“ keinen Fehler in die API-Antwort ein und legt nicht fest, welchen Status zurückgegeben werden soll. Sie zeigt nur die Grenze auf: Manche Regeln werden durch Annotationen ausgedrückt, manche erfordern separate Logik. Und das ist übrigens eine gute Nachricht: Man muss nicht alles in eine einzige Annotation packen und erwarten, dass Bean Validation Ihr Mini-Anwalt, Mini-Geschäftsanalyst und Mini-Exorzist für Bugs in einem wird.
Die praktische Regel für heute ist einfach: Container-Regeln (Listengröße) und Element-Regeln (Leerheit, Länge, Format) leben hervorragend in Bean Validation. Die „sinnträchtigeren“ Dinge wie Eindeutigkeit erfordern oft eine separate Lösung – und es ist wichtig, zumindest nicht zu versuchen, sie mit einem Set „heimlich“ zu lösen.
8. Typische Fehler bei der Validierung von Sammlungen
Fehler Nr. 1: Nur den Container oder nur die Elemente validieren.
Oft wird entweder die Größe der Liste (@Size(max = 10)) oder nur die Elemente (@NotBlank innerhalb von List) geprüft. Im ersten Fall werden leere Zeichenketten durchgelassen, im zweiten Fall kann man Hunderte gültiger Elemente senden. Container und Elemente sind verschiedene Ebenen, und beide erfordern eine explizite Validierung.
Fehler Nr. 2: Constraint auf den falschen Typ setzen.
@NotBlank auf List<String> wirkt „logisch“, ist aber technisch ein Fehler: Die Annotation ist für Zeichenketten gedacht, nicht für Sammlungen. In der Folge stürzt der Validator entweder ab oder verhält sich unerwartet. Für eine Liste verwendet man @NotNull / @NotEmpty, und für die Elemente separate Constraints innerhalb von List<...>.
Fehler Nr. 3: null innerhalb der Sammlung vergessen.
Wenn Sie @Valid für DTO-Elemente verwenden, aber kein @NotNull hinzufügen, wird das null-Element einfach „übersprungen“, da nichts zu validieren ist. Dies erzeugt versteckte Löcher in den Daten. Für Sammlungen ist es wichtig, null separat zu verbieten, wenn es im Vertrag keinen Sinn ergibt.
Fehler Nr. 4: Versuchen, die Eindeutigkeit durch Set zu „lösen“.
Ja, Set entfernt Duplikate – aber still. Der Client hat bestimmte Daten gesendet, der Server andere zurückgegeben, und niemand weiß, wo die Änderung stattfand. Wenn die Regel lautet „Duplikate sind verboten“, muss man sie explizit validieren und einen Fehler zurückgeben, nicht die Eingabe stillschweigend umschreiben.
Fehler Nr. 5: Validierung als „Dekoration“ betrachten, nicht als Teil des Vertrags.
Wenn Annotationen nur „pro forma“ hinzugefügt werden, ohne durchdachtes Modell, beginnt die API, seltsame Datenkombinationen zu akzeptieren. Validierung dient nicht der Bequemlichkeit des Entwicklers, sondern der Festlegung klarer Regeln für die Interaktion mit dem Client. Wenn eine Regel nicht explizit beschrieben ist, beginnt das System, sich unvorhersehbar zu verhalten.
GO TO FULL VERSION