1. Chaos in der Validation-Schicht der REST API
Validation wächst in einem Projekt ganz natürlich: erst zwei Felder in TaskCreateRequest, dann Tags, dann TaskSearchCriteria, dann Kommentare, dann patch-like Requests. Und dann öffnen Sie das DTO – und da sind zwanzig Annotationen, drei benutzerdefinierte „magische“ Regeln und ein Validator mit tausend Zeilen. Es validiert zwar irgendwie, aber das kann man nur mit einem Schamanen und einer Trommel verstehen. In der REST API ist das besonders schmerzhaft, weil das DTO der externe Vertrag ist, und der Vertrag sollte so leicht zu lesen sein wie ein Kassenbon: was gekauft wurde, wie viel, wofür wir bezahlen.
Eine gesunde Validation-Schicht basiert auf dem einfachen Prinzip der Lesbarkeit: Die Regel sollte dort sichtbar sein, wo sie wichtig ist. Wenn die Regel zum Eingangsvertrag gehört, sucht man sie zuerst im DTO. Und wenn das DTO schweigt und die gesamte Logik im „geheimen Raum“ des Validators versteckt ist, erhalten Sie ein System, das selbst für Sie nach zwei Wochen schwer zu warten ist.
Unten sehen Sie die „ideale Leserichtung“ für einen Entwickler, der Ihren Code betritt:
flowchart TD
A[DTO: Beschränkungen sichtbar] --> B[Annotation: verständlicher Name]
B --> C[Validator: kurze Logik]
C --> D[Helper: ordentliches Utility]
Wenn die Kette genau so von oben nach unten lesbar ist, funktioniert Validation wie Dokumentation. Wenn die Reihenfolge gestört wird (z. B. DTO leer, die ganze Logik im Validator), machen Sie den Vertrag praktisch „versteckt“. Ein versteckter Vertrag führt fast immer zu zukünftigen Bugs und Misstrauen gegenüber dem Code.
2. Prinzip DTO → Annotation → Validator
Wenn wir sagen „Validation muss von oben nach unten lesbar sein“, ist das keine Philosophie, sondern eine sehr praktische Gewohnheit. Sie haben ein DTO – das ist der Vertragstext mit dem Kunden. Wenn der Kunde einen title mit einer Länge von 3 bis 120 senden muss, möchten wir nicht, dass dies „irgendwo im Service“, „irgendwo im Validator“, „irgendwo in der readme“ steht. Wir möchten, dass der Entwickler es sofort sieht, wenn er das DTO öffnet, und nicht die Quest „Finde die Regel im Projekt“ spielen muss.
In der Task Tracker API ist das besonders wichtig, weil wir viele ähnliche Modelle haben: create, put, patch, criteria. Wenn die Regeln versteckt sind, werden Sie sie garantiert duplizieren oder in Details auseinandergehen. Aber wenn die Regeln auf dem DTO liegen, wird selbst ein Neuling im Team schnell verstehen, was Teil des Vertrags ist.
Schauen wir uns ein Beispiel an, das „von oben nach unten“ lesbar ist und nicht versucht, klüger zu sein als nötig:
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
// Titel: erforderlich und hat Längenbeschränkungen direkt im Vertrag
@NotBlank
@Size(min = 3, max = 120)
String title,
// Beschreibung: optional, aber wir begrenzen die maximale Länge
@Size(max = 2000)
String description,
// Tags: begrenzen die Listengröße sowie Anforderungen an jedes Listenelement
@Size(max = 10)
List<@NotBlank @Size(max = 30) String> tags
) {}
Hier sieht man das Wesentliche: title ist erforderlich und in der Länge begrenzt, description kann leer/fehlend sein (wir haben nur eine Längenbeschränkung), und tags ist eine Liste mit bis zu 10 Elementen, wobei jedes Element nicht leer und bis zu 30 Zeichen lang ist. Das ist schon eine gute Basis. Aber es gibt eine Regel, die sich so nicht ausdrücken lässt: Tags müssen ohne Berücksichtigung der Groß-/Kleinschreibung eindeutig sein. Und hier kommt eine benutzerdefinierte Prüfung ins Spiel – aber so, dass sie die Lesbarkeit nicht beeinträchtigt.
Der richtige Stil ist, eine benutzerdefinierte Annotation dort hinzuzufügen, wo die Regel logisch lebt: an die Tag-Liste. Und der Annotation einen Namen zu geben, der die Bedeutung vermittelt, nicht die interne Implementierung:
import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
// Titel: erforderlich und in der Länge begrenzt
@NotBlank
@Size(min = 3, max = 120)
String title,
// Tags: (1) Prüfung auf Eindeutigkeit ohne Berücksichtigung der Groß-/Kleinschreibung,
// (2) Listengröße, (3) Beschränkungen für jedes Listenelement
@UniqueTagsIgnoreCase
@Size(max = 10)
List<@NotBlank @Size(max = 30) String> tags
) {}
DTO liest sich immer noch wie ein Vertrag. Und der Validator wird zu „Implementierungsdetails“, die Sie sich nur ansehen, wenn Sie die Mechanik verstehen müssen.
3. Eine Annotation – ein Sinn
Es gibt eine sehr verständliche Versuchung: „Lassen Sie uns eine Annotation @ValidTaskPayload machen, sie an das DTO hängen, und alles wird schön.“ Ja, schön. Wie eine Schachtel ohne Beschriftung. Drinnen kann ein Geschenk sein, aber auch Kabel von einem alten Router.
Das Problem universeller Annotationen ist, dass sie dem Leser nicht helfen, den Vertrag zu verstehen. Sie helfen dem Autor, „Komplexität zu verstecken“. Aber in der Realität verschwindet Komplexität nicht – sie zieht einfach an einen anderen Ort. Und wenn Sie einen Monat später eine neue Regel hinzufügen, gehen Sie zum selben Validator, fügen eine weitere Prüfung hinzu – und verwandeln ihn in einen „Megavalidator“, der für alles auf der Welt zuständig ist.
Hier ist ein Beispiel, wie eine schlechte Idee aussieht (Anti-Beispiel, machen wir nicht so):
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target(ElementType.TYPE) // Annotation wird auf das gesamte DTO gehängt
@Retention(RetentionPolicy.RUNTIME) // Zur Laufzeit für Bean Validation verfügbar
@Constraint(validatedBy = ValidTaskPayloadValidator.class) // Ein Validator „für alles“
public @interface ValidTaskPayload {
// Fehlermeldung: maximal vage (und das ist Teil des Problems)
String message() default "invalid task payload";
// Standardfelder von Bean Validation
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Aus Sicht des DTO erklärt das überhaupt nichts. Was genau ist falsch? Eindeutige Tags? Datumsbereich? Darf nicht ARCHIVED sein? (Ach ja, das ist schon gar keine Input-Validation mehr.)
Viel nützlicher ist es, an der Regel „Eine Annotation – ein Sinn“ festzuhalten. Dann steigt die Lesbarkeit direkt vor den Augen. Zum Beispiel erstellen Sie statt @ValidTaskPayload separate kleine Regeln: @UniqueTagsIgnoreCase, @ValidTaskDueDateRange, möglicherweise @TrimmedString (wenn Sie das überhaupt validieren möchten) und so weiter. Der Entwickler öffnet das DTO und sieht: welche Teile des Vertrags wichtig sind.
Validation in diesem Stil beginnt, an gut beschriftete Kippschalter in einem Schaltschrank zu erinnern. Ja, es gibt viele. Dafür schalten Sie nicht versehentlich das ganze Haus aus, wenn Sie nur das Licht im Flur ausschalten wollten.
4. Komposition von Standard- und benutzerdefinierten Einschränkungen
Benutzerdefinierte Regeln werden benötigt, wenn die Standardregeln nicht mehr ausreichen. Aber ein sehr häufiger Fehler ist es, einen benutzerdefinierten Validator zu schreiben, der alles macht: null prüfen, Größe prüfen, Format prüfen, Eindeutigkeit prüfen, und auch noch normalisieren. Ergebnis: Das DTO sieht einfach aus, aber der Validator wird zum Monster. Das ist die Kehrseite der „Magie“: Sie haben scheinbar Annotationen gespart, aber das Verständnis erschwert.
Viel besser ist es, Regeln zu komponieren. Lassen Sie die Standard-Annotationen die Standardarbeit erledigen, und die benutzerdefinierte schließt genau die Lücke, die übrig blieb.
An Tags ist das besonders gut zu sehen. Wir wollen gleichzeitig:
1) die Anzahl der Tags begrenzen (das ist @Size(max = 10)),
2) die Länge jedes Tags begrenzen (das ist List<@Size(max = 30) String>),
3) leere/Whitespace-Tags verbieten (das ist List<@NotBlank ...>),
4) Duplikate ohne Berücksichtigung der Groß-/Kleinschreibung verbieten (das ist unser benutzerdefiniertes Constraint).
Die Komposition im DTO zeigt genau den gesamten Vertrag:
import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskPutRequest(
// Titel: erforderliches Feld mit klaren Grenzen
@NotBlank
@Size(min = 3, max = 120)
String title,
// Tags: Vertrag aus „normalen“ und benutzerdefinierten Einschränkungen zusammenstellen
@UniqueTagsIgnoreCase // Regel der fachlichen Lesbarkeit: „eindeutig ohne Berücksichtigung der Groß-/Kleinschreibung“
@Size(max = 10) // Begrenzung der Anzahl der Tags
List<@NotBlank @Size(max = 30) String> tags // Einschränkungen für jedes Element
) {}
Beachten Sie den angenehmen Effekt: Sie können dieselbe Regel (z. B. Eindeutigkeit von Tags) in mehreren DTOs (TaskCreateRequest, TaskPutRequest, vielleicht auch anderswo) wiederverwenden, und sie sieht jedes Mal gleich aus. Das ist schon der halbe Sieg über die Duplizierung.
Und so sieht der Validator selbst aus – kurz, ohne „überflüssige Macht“, er ist nur für die Eindeutigkeit zuständig:
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.List;
public class UniqueTagsIgnoreCaseValidator
implements ConstraintValidator<UniqueTagsIgnoreCase, List<String>> {
@Override
public boolean isValid(List<String> tags, ConstraintValidatorContext context) {
// In Bean Validation ist null gültig, wenn es nicht separat durch @NotNull verboten ist
return tags == null || !TagUniqueness.hasDuplicatesIgnoreCase(tags);
}
}
Ja, hier haben wir einen Teil der Logik an einen Helper (TagUniqueness) delegiert. Und das ist in Ordnung – solange der Helper nicht den Sinn versteckt, sondern den Code kürzer und wiederverwendbar macht. Über Helper sprechen wir später.
5. Helper-Utilities für die Validation-Schicht
Helper der Validation sind wie Gewürze in der Küche. Ein bisschen – lecker, klar, schnell. Zu viel – Sie wissen nicht mehr, was Sie kochen, und warum es nach „schwarzem Pfeffer mit schwarzem Pfeffer“ riecht. Der Zweck einer Helper-Methode in der Validation-Schicht ist einfach: den wiederholbaren „technischen“ Teil so auszulagern, dass der Validator kurz und offensichtlich bleibt. Aber wenn der Helper anfängt, Geschäftslogik zu beschreiben oder den Validator „ohne Dateisprünge unlesbar“ macht, fängt er an zu stören.
Für die Task Tracker API ist ein gutes Beispiel für einen Helper die Prüfung auf Eindeutigkeit von Tags unter Ignorierung der Groß-/Kleinschreibung. Das ist eine reine Funktion der Eingabeliste, ohne Zugriff auf Services, ohne Kenntnis des Systemzustands. Ein idealer Kandidat für ein Utility:
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public final class TagUniqueness {
private TagUniqueness() {}
public static boolean hasDuplicatesIgnoreCase(List<String> tags) {
// TreeSet mit CASE_INSENSITIVE_ORDER sorgt für Eindeutigkeit ohne Berücksichtigung der Groß-/Kleinschreibung
Set<String> unique = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (String tag : tags) {
// null-Elemente fangen wir hier nicht: das ist der Bereich der element constraints wie @NotBlank
if (tag == null) continue;
// trim() verwenden wir nur zum Vergleich; die ursprüngliche Liste ändern wir nicht
if (!unique.add(tag.trim())) {
return true;
}
}
return false;
}
}
Es gibt eine wichtige Nuance: Wir führen trim() nur zum Vergleich durch und modifizieren die ursprüngliche Liste nicht. null/blank-Elemente betrachten wir auch nicht als Verantwortungsbereich des Helpers: diese sollten element constraints wie @NotBlank abfangen. In unserem Beispiel ist der Helper nur für die uniqueness zuständig.
Jetzt wird der Validator fast zu einem „englischen Satz“:
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.List;
public class UniqueTagsIgnoreCaseValidator
implements ConstraintValidator<UniqueTagsIgnoreCase, List<String>> {
@Override
public boolean isValid(List<String> tags, ConstraintValidatorContext context) {
// null überspringen wir: die Erforderlichkeit der Sammlung muss separat festgelegt werden (z.B. @NotNull)
if (tags == null) return true;
// Die einzige Verantwortung des Validators ist es, auf Duplikate gemäß der Annotationsregel zu prüfen
return !TagUniqueness.hasDuplicatesIgnoreCase(tags);
}
}
Wenn am Code sichtbar ist, was passiert, hat der Helper gute Arbeit geleistet. Wenn man nach dem Helper noch drei weitere Dateien öffnen muss, um die Regel zu verstehen, hat der Helper schlechte Arbeit geleistet (oder Sie haben ihn zu abstrakt benannt).
6. Struktur des Validation-Codes im Projekt
Validation scheitert oft nicht an der Logik, sondern am Chaos in der Projektstruktur. Heute legen Sie den Validator neben das DTO, morgen in den Controller, übermorgen in den Service, weil „es dort bequemer ist“. In einem Monat haben Sie drei verschiedene Implementierungen derselben Regel. Daher ist es wichtig, im Voraus zu vereinbaren, wo der Validation-Code lebt und wer von wem abhängt.
In unserem Projekt ist die Paketstruktur vorab festgelegt, und für benutzerdefinierte Validation haben wir einen Platz: com.example.tasktracker.domain.validation. Das ist ein guter Kompromiss: Die Validatoren sind domänenorientiert (z. B. Tags, Daten, Konsistenz des Payloads), aber es ist keine Geschäftsvalidierung, die vom Zustand der Ressourcen abhängt. Es ist genau die Schicht der Regeln des Eingangsvertrags, die auf das DTO angewendet werden kann.
Schematisch kann man das so darstellen:
flowchart TD
apiDto["api.dto.request DTO + Annotationen"] --> domainVal["domain.validation Annotationen + Validatoren + Helper"]
domainVal -->|"KENNT NICHT"| services["domain.service Services"]
services --> repo["infrastructure.repository.inmemory"]
Der entscheidende Punkt: Ein benutzerdefinierter Validator sollte nichts von Services und Repositories wissen. Wenn er es weiß, ist das fast immer eine Verletzung der Grenze zwischen Input- und Geschäftsvalidierung. Ja, technisch gesehen kann Spring alles überall einfügen. Aber „kann“ heißt nicht „soll“. Wir bauen ein verständliches System, keine Demonstration von Zirkustricks mit Spring DI.
Praktisch bedeutet das drei einfache Disziplinregeln (und es ist besser, sie im Kopf zu behalten, wenn die Hand sich ausstreckt, um „es ein kleines bisschen zu vereinfachen“):
Sie legen Annotationen und Validatoren in domain.validation, damit sie leicht zu finden sind und nicht im Code verstreut werden. Sie machen die Validatoren maximal „sauber“ – ohne Abhängigkeit vom Anwendungszustand. Und Sie überlassen die Geschäftsregeln dem Service, denn Geschäftsregeln sind keine „Anfrageform“, sondern „kann die Operation ausgeführt werden“.
Mit einer solchen Anordnung ist es später einfacher, Regeln zu warten und ihre Verletzungen bestimmten Feldern zuzuordnen: Die Stelle der Regel muss nicht im gesamten Projekt ausgegraben werden.
7. Anti-Patterns in der Validation-Schicht
Anti-Patterns sehen selten wie ein böser Plan aus. Meistens ist es so etwas wie „Ich füge schnell eine Prüfung hinzu, später kümmern wir uns darum“. Infolgedessen funktioniert die Validation-Schicht formal, wird aber schlecht für die Wiederverwendung: Die Regel ist schwer zu finden, schwer zu lesen und leicht versehentlich zu beschädigen.
Das erste Anti-Pattern ist die „universelle Annotation“. Sie haben bereits gesehen, wie @ValidTaskPayload aussieht: Es sieht schön aus, aber am DTO ist nicht klar, was genau sie prüft. Eine solche Annotation versteckt den Vertrag, anstatt ihn sichtbar zu machen.
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Target(ElementType.TYPE) // Annotation wird auf das gesamte DTO gehängt
@Retention(RetentionPolicy.RUNTIME) // Zur Laufzeit für Bean Validation verfügbar
@Constraint(validatedBy = ValidTaskPayloadValidator.class) // Ein Validator „für alles“
public @interface ValidTaskPayload {
// Fehlermeldung: maximal vage (und das ist Teil des Problems)
String message() default "invalid task payload";
// Standardfelder von Bean Validation
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
Aus Sicht des DTO erklärt das überhaupt nichts. Der Name sagt nur „irgendwas stimmt nicht“, zeigt aber nicht, was genau als Teil des Vertrags gilt.
Das zweite Anti-Pattern ist die versteckte Normalisierung innerhalb der Validation. Wenn der Validator nicht nur true/false zurückgibt, sondern leise trim()-t, lower-caset oder die Daten sogar umschreibt, erhält der Vertrag einen Seiteneffekt. Der Kunde hat etwas gesendet, und im Inneren des Systems befindet sich plötzlich etwas anderes – und zu verstehen, wo das passiert ist, ist später unangenehm.
Das dritte Anti-Pattern ist ein Helper, der den Sinn versteckt statt der Routine. TagUniqueness.hasDuplicatesIgnoreCase(...) liest sich noch wie ein menschlicher Gedanke. ValidationUtils.v2(...) ist schon eine Blackbox. Ein Helper sollte den Validator kürzer machen, nicht dazu zwingen, durch das Projekt zu springen, um die grundlegende Regel zu verstehen.
8. Typische Fehler in der Validation-Schicht
Fehler Nr. 1: vage Namen von Annotationen.
Wenn eine Annotation @ValidTaskPayload, @CheckRequest oder @Ok heißt, hilft sie nicht, den Vertrag zu verstehen. Sie zwingen den Leser, den Validator, dann den Helper und dann noch etwas zu öffnen. Eine gute Annotation sollte fast wie eine Dokumentationszeile sein: @UniqueTagsIgnoreCase, @ValidTaskDueDateRange, @StartsWithLetter – auch wenn es etwas länger ist, dafür klar.
Fehler Nr. 2: „Megavalidator“, der alles Mögliche prüft.
Normalerweise sieht das so aus: Ein ConstraintValidator enthält Längenprüfungen, Format, feldübergreifende Vergleiche und die Hälfte der Geschäftsregeln. Am Ende verlieren Sie das ursprüngliche Ziel der Validierung – den Vertrag explizit zu machen. Es ist viel gesünder, viele kleine Regeln zu haben und sie auf dem DTO zu komponieren, als einen riesigen Validator mit unvorhersehbarem Verhalten.
Fehler Nr. 3: Versuch, Geschäftsvalidierung in die Input-Validation zu stopfen.
Prüfungen wie „Eine archivierte Aufgabe darf nicht geändert werden“ oder „Statusübergang verboten“ hängen vom aktuellen Zustand der Ressource ab. Das ist keine Frage der Anfrageform mehr. Wenn Sie solche Regeln in einen ConstraintValidator ziehen, zerstören Sie die Schichtgrenze und mischen gleichzeitig komplexe Anwendungsszenarien in die einfache Mechanik von Bean Validation.
Fehler Nr. 4: Duplizieren derselben Regel an verschiedenen Stellen.
Wenn die Regel zur Eindeutigkeit von Tags bereits als Annotation und Validator ausgedrückt ist, muss man keinen zweiten solchen if im Service „für alle Fälle“ behalten. Validation ist gerade gut, weil sie die Prüfung systematisiert. Aber das funktioniert nur, wenn die Regel ein einziges „Zuhause“ und eine einzige Quelle der Wahrheit hat.
Fehler Nr. 5: Zu kluge Helper, die den Sinn verstecken.
Ein Helper ist nötig, um die Routine zu entfernen und den Validator kürzer zu machen, aber er sollte das Lesen nicht in eine Quest verwandeln. Wenn TagUniqueness.hasDuplicatesIgnoreCase(...) verständlich lesbar ist – großartig. Wenn der Helper ValidationUtils.v2(...) heißt und innen unklar macht – haben Sie einfach das Chaos in ein „Utility“ umbenannt.
GO TO FULL VERSION