1. Praktyka: custom constraint
Gdy słyszysz po raz pierwszy „niestandardowa walidacja", łatwo wyobrazić sobie coś przerażającego: pół strony adnotacji, zagadkowe typy generyczne i odczucie, że za chwilę przypadkowo napiszemy mini-Spring wewnątrz Springa. W rzeczywistości niestandardowy constraint to po prostu sposób, żeby uczciwie powiedzieć: „moje pole ma regułę, która nie mieści się w standardowych @Size i @Pattern".
Jeśli uprościć do codziennego poziomu, standardowe adnotacje to jak podstawowy zestaw narzędzi (śrubokręt, młotek, klucz). Dopóki składasz normalny mebel, wszystko dobrze. Ale czasem trafia się „szczególna" śruba lub reguła jak „wartości muszą być unikalne bez uwzględnienia wielkości liter" — i wtedy potrzebujesz własnego narzędzia, ale małego i wyspecjalizowanego, a nie spawarki i przemysłowej maszyny.
Ważne, żeby ustalić granicę: custom constraint to nadal input validation. Sprawdza to, co widoczne z wartości pola (lub wartości parametru), nie zaglądając do serwisów, repozytoriów i „bieżącego stanu zadania". Czyli żadnej „walidacji archiwalności" lub „czy zadanie istnieje w magazynie" wewnątrz walidatora — takie reguły już zależą od stanu systemu i tu nie pasują.
2. Skład niestandardowego constraint
Przed pisaniem kodu warto na chwilę zatrzymać się i zrozumieć architekturę mechanizmu. Niestandardowy constraint zawsze składa się z dwóch części: adnotacji, którą stawiasz na polu/parametrze, i walidatora, który realnie wykonuje sprawdzenie. Adnotacja to „etykieta reguły", a walidator — „wykonawca", który wie, jak tę regułę sprawdzić.
Schematycznie wygląda to tak:
flowchart TD
A["Pole DTO lub parametr"] --> B["@YourConstraint"]
B --> C["ConstraintValidator"]
C --> D{"isValid?"}
D -->|true| E["Walidacja przechodzi dalej"]
D -->|false| F["ConstraintViolation jest tworzony"]
Tutaj nie ma żadnej magii poziomu „Spring sam się domyśli". Wszystko jest dość bezpośrednie: Bean Validation widzi adnotację, rozumie, która klasa walidatora jej odpowiada, tworzy walidator i wywołuje u niego metodę isValid(...).
I tutaj ważna kwestia: walidator zwraca boolean, a nie rzuca wyjątki i nie robi System.out.println("Ojoj, błąd"). Zwracamy false — dostajemy constraint violation. Zwracamy true — sprawdzenie jest uznane za zaliczone.
3. Adnotacja @UniqueTagsIgnoreCase
Teraz zrobimy mały, ale przydatny niestandardowy constraint dla naszego projektu: sprawdzanie unikalności tagów bez uwzględnienia wielkości liter. Zgodnie z wymaganiami projektu tagi powinny być unikalne w ramach jednego zadania, ignorując wielkość liter. Tej reguły nie da się wyrazić standardowymi adnotacjami: @Size potrafi tylko „ile", a @Pattern — tylko „jak wygląda", ale nie „nie powtarzaj się".
Umieszczamy constraint w pakiecie przewidzianym do tego przez architekturę projektu: com.example.tasktracker.domain.validation
Adnotacja
package com.example.tasktracker.domain.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
// Łączymy adnotację z walidatorem: właśnie ta klasa będzie wywoływana przez Bean Validation
@Constraint(validatedBy = UniqueTagsIgnoreCaseValidator.class)
// Zezwalamy na stawianie adnotacji na polu, parametrze i komponencie rekordu (ważne dla DTO record)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
// Adnotacja musi być dostępna w czasie wykonania, inaczej walidator po prostu nie zostanie wywołany
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueTagsIgnoreCase {
// Domyślny komunikat dla błędu walidacji
String message() default "tags must be unique ignoring case";
// Pola pomocnicze Bean Validation: grupy (często niepotrzebne na początku, ale muszą być)
Class<?>[] groups() default {};
// Pola pomocnicze Bean Validation: payload (rozszerzenie metadanych, zazwyczaj nie używamy)
Class<? extends Payload>[] payload() default {};
}
Ważne jest rozumienie każdej części (i nie tylko „skopiować i zapomnieć").
@Constraint(validatedBy = ...) to główny „przewód", który łączy adnotację z walidatorem. Bez niego adnotacja będzie tylko ładną nalepką, którą nikt nie czyta.
@Target(...) mówi, gdzie w ogóle można stawiać adnotację. Dodaliśmy FIELD i PARAMETER, żeby używać constraintu zarówno na polach DTO, jak i na parametrach metod (jeśli kiedyś będziemy chcieli). Dodaliśmy RECORD_COMPONENT, bo w kursie aktywnie używamy record dla DTO i sprawia to, że stosowanie adnotacji jest bardziej przewidywalne.
@Retention(RetentionPolicy.RUNTIME) jest obowiązkowy z bardzo prostego powodu: Bean Validation działa w runtime. Jeśli retention byłoby np. CLASS, to w runtime adnotacji „jakby nie ma".
A te trzy elementy:
String message() default "...";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
— to standardowy „ogon pomocniczy" adnotacji constraint. Jest obowiązkowy. Można go traktować jak „techniczną umowę": każda adnotacja constraint musi umieć podać komunikat i obsługiwać grupowanie/paylody (nawet jeśli na razie z nich nie korzystamy).
4. Walidator UniqueTagsIgnoreCaseValidator
Adnotacja to deklaracja reguły, ale nie sprawdzanie. Sprawdzanie żyje w klasie, która implementuje ConstraintValidator. W tym miejscu u początkujących często pojawia się odczucie „zaraz zaczną się straszne typy generyczne", ale w rzeczywistości wszystko jest bardzo logiczne: walidator musi powiedzieć, jaką adnotację obsługuje i jaki typ wartości potrafi sprawdzać.
Walidator
package com.example.tasktracker.domain.validation;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public class UniqueTagsIgnoreCaseValidator
implements ConstraintValidator<UniqueTagsIgnoreCase, List<String>> {
@Override
public boolean isValid(List<String> tags, ConstraintValidatorContext ctx) {
// null tutaj nie uważamy za błąd: obowiązkowość zadawana jest przez @NotNull/@NotEmpty osobno
if (tags == null) return true;
// TreeSet z CASE_INSENSITIVE_ORDER da unikalność bez uwzględnienia wielkości liter
Set<String> unique = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (String tag : tags) {
// elementy null nie są przez ten walidator weryfikowane: to strefa odpowiedzialności adnotacji na elemencie listy
if (tag == null) continue;
// Normalizujemy spacje, żeby "bug" i " bug " były uważane za ten sam tag
if (!unique.add(tag.trim())) return false; // powtórzenie — oznacza naruszenie reguły
}
return true; // wszystkie tagi są unikalne (z uwzględnieniem wielkości liter i trim)
}
}
Rozłóżmy na części to, co tu się dzieje.
ConstraintValidator<UniqueTagsIgnoreCase, List<String>> oznacza: „Jestem walidatorem dla adnotacji @UniqueTagsIgnoreCase, i umiem sprawdzać wartość typu List<String>". Jeśli pomylisz drugi parametr i napiszesz np. String, adnotacja po prostu nie będzie mogła być poprawnie zastosowana do listy tagów (i dostaniesz bardzo „przyjazny" błąd… gdzieś w runtime, w najgorszym możliwym momencie).
Dalej — najważniejsza metoda: isValid(...). Dostaje wartość pola (tags) i kontekst (ctx). Na razie kontekstu używamy tylko jako obowiązkowego parametru, bez szczegółowej konfiguracji.
null nie musi być błędem
To ważna filozoficzna (i praktyczna) umowa: większość adnotacji constraint nie odpowiada za obowiązkowość pola. Obowiązkowość to @NotNull/@NotBlank/@NotEmpty. Jeśli pole tags jest u nas opcjonalne, to null — dopuszczalny, i walidator powinien spokojnie powiedzieć: „okej, nie ma co sprawdzać".
Gdybyśmy zwrócili false dla null, oznaczałoby to: „pole jest obowiązkowe", ale nie chcemy ukrywać tej decyzji wewnątrz walidatora. Lepiej trzymać obowiązkowość osobno i jawnie.
Normalizacja przez trim()
Sprawdzamy unikalność „według sensu". Jeśli klient przesłał "bug" i " bug " — formalnie to różne ciągi, ale dla człowieka to jeden i ten sam tag, tylko ze spacjami. trim() — mała normalizacja, która czyni regułę bardziej przydatną w realnym życiu.
I tak, to jest moment, gdy ważne jest, żeby nie przesadzić: walidator nie powinien zamieniać się w „normalizator całego świata". Po prostu przygotowuje wartość dla konkretnej reguły.
5. Zastosowanie w DTO
Pisanie niestandardowego constraintu dla samego faktu jego istnienia — to wątpliwa zabawa (mniej więcej jak zakładanie oddzielnego mikroserwisu dla jednej linii kodu). Ale gdy naprawdę jest potrzebny, najprzyjemniejszy efekt — że reguła jest widoczna bezpośrednio w DTO. Czyli kontrakt czyta się oczami: „oto pole, oto jego ograniczenia", bez skakania do warstwy serwisowej i „szukania prawdy" po projekcie.
TaskCreateRequest
package com.example.tasktracker.api.dto.request;
import com.example.tasktracker.domain.validation.UniqueTagsIgnoreCase;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
public record TaskCreateRequest(
// Prosta reguła dla ciągu: obowiązkowość + długość
@NotBlank
@Size(min = 3, max = 120)
String title,
// Pole opcjonalne, ograniczamy tylko rozmiar
@Size(max = 2000)
String description,
// Reguły dla listy jako całości (rozmiar + unikalność)
@Size(max = 10)
@UniqueTagsIgnoreCase
// Reguły dla każdego elementu listy (NotBlank + długość)
List<@NotBlank @Size(max = 30) String> tags
) {}
Zwróć uwagę na kompozycję. Tutaj są trzy „poziomy" sprawdzania, i każdy odpowiada za swój obszar:
@Size(max = 10) sprawdza rozmiar listy. To standardowa reguła, nie trzeba wymyślać koła.
@UniqueTagsIgnoreCase sprawdza listę jako całość pod kątem unikalności. To właśnie nasza niestandardowa część.
List<@NotBlank @Size(max = 30) String> tags sprawdza każdy element listy. Czyli jeśli klient prześle ["", "bug"], złapiemy pusty tag. Jeśli prześle ["bardzo-bardzo-bardzo-długi-tag-..."], złapiemy długość.
I dzięki temu dostajemy „warstwowe ciasto" walidacji: każda adnotacja robi swoje małe zadanie, a finalny kontrakt jest czytelny.
6. Niuans, który oszczędza nerwów
Gdy po raz pierwszy piszesz niestandardowy walidator, bardzo chce się zrobić go „najmądrzejszym" i wcisnąć do niego wszystko: i sprawdzenie null, i sprawdzenie długości, i trim(), i dopuszczalne znaki, i na wszelki wypadek, sprawdzenie istnienia tagu w bazie… (której, nawiasem mówiąc, nie mamy). Ręka ciągnie, bo „no już jestem w walidatorze".
Ale w normalnym projekcie (i w edukacyjnym też) działa spokojniejsze podejście: walidator odpowiada za jedną regułę, a wszystko inne pozostaje tam, gdzie powinno. Do tego warto pamiętać prostą tabelę:
| Pytanie | Gdzie zazwyczaj się rozwiązuje |
|---|---|
| Pole jest obowiązkowe? | @NotNull, @NotBlank, @NotEmpty |
| Rozmiar ciągu/listy? | @Size, @Min, @Max |
| Format ciągu? | @Pattern lub osobny mały niestandardowy constraint |
| Unikalność elementów w jednym polu? | niestandardowy constraint (jak dzisiaj) |
| Czy można wykonać operację z uwzględnieniem stanu zasobu? | warstwa serwisowa (nie walidator) |
Jeśli trzymać to w głowie, twoje niestandardowe sprawdzenia będą małe, zrozumiałe i nie zamienią się w „czarną skrzynkę, która robi wszystko".
7. Typowe błędy przy niestandardowych constraint
Gdy po raz pierwszy zaczynasz pisać własne adnotacje constraint, błędy są prawie nieuniknione. I to normalne: mózg jeszcze nie przyzwyczaił się do tego, że adnotacja to deklaracja, a walidator to wykonanie. Najważniejsze — nauczyć się szybko rozpoznawać te problemy po objawach, żeby nie siedzieć trzy godziny nad „dlaczego nie działa".
Błąd nr 1: zapomniano @Constraint(validatedBy = ...).
Z zewnątrz wszystko wygląda pięknie: adnotacja stoi na polu, projekt się kompiluje, a sprawdzenie jakby się nie uruchamia. To bardzo podstępna sytuacja, bo oczekujesz błędu, a go nie ma. Przyczyna prawie zawsze jedna: nie połączyłeś adnotacji z walidatorem i Bean Validation po prostu nie wie, kto powinien wykonywać regułę.
Błąd nr 2: nieprawidłowy @Target, szczególnie jeśli używasz record.
Jeśli constraintu nie można postawić tam, gdzie próbujesz (np. na komponencie rekordu), kompilator może narzekać lub adnotacja zostanie zastosowana „nie tam". Dodawaj ElementType.RECORD_COMPONENT, jeśli walidusjesz DTO record, i nie wahaj się być nieco bardziej jawnym — to jest przypadek, gdy „jawnie lepiej niż tajemniczo".
Błąd nr 3: walidator rzuca wyjątek zamiast zwracać false.
Czasem wewnątrz isValid() pisze się coś jak throw new IllegalArgumentException("duplicate"). W efekcie zamieniasz normalny „błąd walidacji" w „błąd wewnętrzny aplikacji", i dalej w łańcuchu wszystko zachowuje się dziwnie. Kontrakt Bean Validation jest prosty: chcesz powiedzieć „nie wolno" — zwróć false.
Błąd nr 4: null jest domyślnie uważany za błąd.
Jeśli walidator zwraca false dla null, niejawnie czynisz pole obowiązkowym. Czasem to jest dokładnie to, czego potrzebujesz, ale częściej to po prostu przypadkowy błąd. Dobra reguła na start: null → true, a obowiązkowość zadajemy jawnie osobną adnotacją. To sprawia, że DTO jest znacznie bardziej czytelne: od razu widać, czy pole jest obowiązkowe czy nie.
Błąd nr 5: walidator zaczyna chodzić do serwisu/repozytorium.
To prosta droga do mieszania warstw. Gdy tylko walidator wywołuje serwis, przestaje być input validation i zamienia się w „kawałek logiki biznesowej w nieoczekiwanym miejscu". W prawdziwym projekcie to też problem wydajnościowy i przewidywalności: walidacja może uruchamiać się częściej, niż myślisz. Dlatego trzymamy regułę: walidator sprawdza wartość, a nie stan systemu.
GO TO FULL VERSION