Custom Validator — Özel Doğrulama Kuralları
Bir e-ticaret sitesinde promosyon kodu sistemi var. Promosyon kodu: tam 8 karakter, büyük harfle başlamalı, sadece harf ve rakam içermeli ve veritabanında daha önce kullanılmamış olmalı. Bu kuralların bir kısmını @Pattern ile yapabilirsiniz ama "veritabanında kullanılmamış olmalı" kontrolünü standart annotation'larla yapamazsınız. İşte burada custom validator devreye girer.
Bean Validation API, @NotBlank, @Email, @Size gibi standart annotation'ların ötesinde, kendi doğrulama kurallarınızı oluşturmanıza izin verir. TC kimlik numarası doğrulama, IBAN formatı kontrolü, benzersiz email kontrolü, şifre eşleşme doğrulaması — bunların hepsi custom validator ile yapılır.
Custom Validator Ne Zaman Gerekir?
| Durum | Standart Annotation | Custom Validator |
|---|---|---|
| Boş kontrolü | @NotBlank ✅ | Gereksiz |
| Email formatı | @Email ✅ | Gereksiz |
| Regex pattern | @Pattern ✅ | Gereksiz |
| Min/max değer | @Min, @Max ✅ | Gereksiz |
| TC Kimlik No doğrulama | ❌ | ✅ Gerekli |
| IBAN doğrulama | ❌ (basit regex yetmez) | ✅ Gerekli |
| Benzersiz email | ❌ (DB sorgusu gerekli) | ✅ Gerekli |
| Şifre onay eşleşmesi | ❌ (cross-field) | ✅ Gerekli |
| Tarih aralığı kontrolü | ❌ (iki alan karşılaştırma) | ✅ Gerekli |
| Enum değer kontrolü | ❌ | ✅ Gerekli |
Kural: Standart annotation'larla yapabiliyorsanız, custom validator yazmayın. Custom validator sadece iş kuralları ve karmaşık doğrulamalar için kullanılmalıdır.
İki Bileşen: Annotation + Validator
Custom validator iki parçadan oluşur:
Annotation (@interface): Kuralın "tabelası" — nereye uygulanacağını belirtir
Validator (ConstraintValidator): Kuralın "mantığı" — doğrulamayı yapan kod
Gerçek Dünya Analojisi
Bir restoranın "18 yaş altı giremez" kuralını düşünün:
Tabela (Annotation): Kapıda asılı, kuralı ilan eder
Bouncer (Validator): Kapıda duran ve yaşı kontrol eden kişi
Tabela tek başına kimseyi engellemez. Bouncer tek başına neyi kontrol edeceğini bilmez. İkisi birlikte çalışır.
Adım Adım: Field-Level Custom Validator
Örnek 1: Türk Telefon Numarası Doğrulama
Adım 1 — Annotation Tanımlama
@Documented
@Constraint(validatedBy = TurkishPhoneValidator.class) // Hangi validator kullanacak
@Target({ElementType.FIELD, ElementType.PARAMETER}) // Nereye konulabilir
@Retention(RetentionPolicy.RUNTIME) // AOP için RUNTIME zorunlu
public @interface ValidTurkishPhone {
// ─── Zorunlu 3 eleman (Bean Validation spec) ───
String message() default "Geçersiz telefon numarası. Format: +905XXXXXXXXX";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}Zorunlu 3 eleman açıklaması:
message(): Doğrulama başarısız olduğunda gösterilecek varsayılan hata mesajıgroups(): Validation Groups desteği için (boş bırakılırsa Default grubuna ait)payload(): Metadata taşımak için (pratikte nadiren kullanılır, ama spec gereği zorunlu)
Bu üç eleman her custom constraint annotation'ında olmalıdır. Yoksa ConstraintDefinitionException alırsınız.
Adım 2 — Validator Implementasyonu
public class TurkishPhoneValidator
implements ConstraintValidator<ValidTurkishPhone, String> {
// Türk telefon formatları:
// +905551234567
// 05551234567
// 5551234567
private static final Pattern TURKISH_PHONE =
Pattern.compile("^(?:\\+90|0)?5[0-9]{9}$");
@Override
public void initialize(ValidTurkishPhone annotation) {
// Annotation parametrelerini okumak için kullanılabilir
// Bu örnekte gerek yok — boş bırakılabilir
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
// ⚠️ CONVENTION: null → true döndür
// null kontrolü @NotNull ile yapılmalı
if (value == null) {
return true;
}
// Boşluk ve tireleri temizle
String cleaned = value.replaceAll("[\\s-]", "");
return TURKISH_PHONE.matcher(cleaned).matches();
}
}Kullanım
public class ContactRequest {
@NotBlank(message = "Telefon zorunludur")
@ValidTurkishPhone
private String phone;
}null Convention — Neden null'a true Dönüyoruz?
Bean Validation'da resmi convention şudur: Custom validator'lar null değeri geçerli kabul etmelidir. Null kontrolü ayrı bir @NotNull (veya @NotBlank) annotation'ı ile yapılır.
Neden?
Opsiyonel alanlar: Telefon alanı opsiyonel olabilir.
null→ doğrulama atla, değer varsa → formatı kontrol etSorumluluk ayrımı: Her annotation tek bir iş yapar.
@NotNull= "null olmasın",@ValidPhone= "format doğru olsun"Birleştirilebilirlik:
@NotNull @ValidPhone= zorunlu + format kontrolü. Sadece@ValidPhone= opsiyonel ama format kontrolü
// Zorunlu telefon alanı
@NotBlank
@ValidTurkishPhone
private String phone;
// Opsiyonel telefon alanı — girilirse formatı doğru olmalı
@ValidTurkishPhone
private String alternativePhone;Örnek 2: TC Kimlik Numarası Doğrulama
TC Kimlik numarasının sadece 11 haneli bir sayı değil, matematiksel bir algoritması vardır:
@Documented
@Constraint(validatedBy = TcKimlikValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidTcKimlik {
String message() default "Geçersiz TC Kimlik numarası";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class TcKimlikValidator implements ConstraintValidator<ValidTcKimlik, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true;
// 11 haneli, rakamlardan oluşan, 0 ile başlamayan
if (!value.matches("^[1-9]\\d{10}$")) {
return false;
}
int[] digits = value.chars().map(c -> c - '0').toArray();
// Kural 1: İlk 10 hanenin toplamının mod 10'u = 11. hane
int sumFirst10 = 0;
for (int i = 0; i < 10; i++) {
sumFirst10 += digits[i];
}
if (sumFirst10 % 10 != digits[10]) {
return false;
}
// Kural 2: Tek sıradaki hanelerin toplamının 7 katı eksi
// çift sıradaki hanelerin toplamının mod 10'u = 10. hane
int oddSum = digits[0] + digits[2] + digits[4] + digits[6] + digits[8];
int evenSum = digits[1] + digits[3] + digits[5] + digits[7];
return (oddSum * 7 - evenSum) % 10 == digits[9];
}
}Kullanım:
public class CitizenRequest {
@NotBlank(message = "TC Kimlik numarası zorunludur")
@ValidTcKimlik
private String tcKimlikNo;
}Örnek 3: Veritabanı Sorgusu Gerektiren Validator
Custom validator'lar Spring bean'i olarak yönetildiği için dependency injection kullanabilirsiniz. Bu, veritabanı kontrolü yapmanızı sağlar:
// ─── Annotation ───
@Documented
@Constraint(validatedBy = UniqueEmailValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface UniqueEmail {
String message() default "Bu email adresi zaten kayıtlı";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// ─── Validator (Spring Bean) ───
@Component // Spring bean olarak kaydet — DI kullanabilmek için
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
// Constructor injection — Spring otomatik inject eder
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null || email.isBlank()) {
return true; // null/blank kontrolü @NotBlank'e bırak
}
return !userRepository.existsByEmailIgnoreCase(email);
}
}Kullanım:
public class RegisterRequest {
@NotBlank(message = "Email zorunludur")
@Email(message = "Geçerli bir email giriniz")
@UniqueEmail // Veritabanında benzersizlik kontrolü!
private String email;
}⚠️ Dikkat: Validator'da veritabanı sorgusu yapmak iki şeye dikkat gerektirir:
Race condition: İki istek aynı anda aynı email ile gelirse, ikisi de "benzersiz" geçebilir. Bu nedenle veritabanında da
UNIQUEconstraint olmalıdırPerformance: Her validation'da DB sorgusu yapılır. Çok sık çağrılan endpoint'lerde cache düşünün
Cross-Field Validation — Sınıf Düzeyinde Doğrulama
Bazen birden fazla alanı birlikte doğrulamanız gerekir. Örneğin: şifre ve şifre onayının eşleşmesi, başlangıç tarihinin bitiş tarihinden önce olması. Bu durumda annotation sınıf düzeyine (TYPE) konur:
Örnek 4: Şifre Eşleşme Kontrolü
// ─── Annotation (Class-Level) ───
@Documented
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target(ElementType.TYPE) // ← TYPE — sınıf düzeyinde
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatch {
String message() default "Şifreler eşleşmiyor";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
// Hangi alanları karşılaştıracağını belirt
String password() default "password";
String confirmPassword() default "confirmPassword";
}
// ─── Validator ───
public class PasswordMatchValidator
implements ConstraintValidator<PasswordMatch, Object> {
private String passwordField;
private String confirmField;
@Override
public void initialize(PasswordMatch annotation) {
this.passwordField = annotation.password();
this.confirmField = annotation.confirmPassword();
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
try {
Object password = getFieldValue(obj, passwordField);
Object confirm = getFieldValue(obj, confirmField);
boolean valid = password != null && password.equals(confirm);
if (!valid) {
// Varsayılan constraint violation'ı devre dışı bırak
context.disableDefaultConstraintViolation();
// Hatayı belirli bir field'a bağla
// (Böylece error response'ta "confirmPassword" alanında görünür)
context.buildConstraintViolationWithTemplate(
context.getDefaultConstraintMessageTemplate())
.addPropertyNode(confirmField) // confirmPassword alanına bağla
.addConstraintViolation();
}
return valid;
} catch (Exception e) {
return false;
}
}
private Object getFieldValue(Object obj, String fieldName) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(obj);
}
}
// ─── Kullanım ───
@PasswordMatch // Sınıf düzeyinde
public class RegisterRequest {
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 8, message = "Şifre en az 8 karakter olmalı")
private String password;
@NotBlank(message = "Şifre onayı zorunludur")
private String confirmPassword;
}Örnek 5: Tarih Aralığı Kontrolü
@Documented
@Constraint(validatedBy = DateRangeValidator.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ValidDateRange {
String message() default "Başlangıç tarihi bitiş tarihinden önce olmalı";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String startDate();
String endDate();
}
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, Object> {
private String startField;
private String endField;
@Override
public void initialize(ValidDateRange annotation) {
this.startField = annotation.startDate();
this.endField = annotation.endDate();
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
try {
LocalDate start = (LocalDate) getFieldValue(obj, startField);
LocalDate end = (LocalDate) getFieldValue(obj, endField);
// Her ikisi de null ise geçerli (opsiyonel tarihler)
if (start == null || end == null) return true;
boolean valid = start.isBefore(end);
if (!valid) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(
"Bitiş tarihi başlangıç tarihinden sonra olmalı")
.addPropertyNode(endField)
.addConstraintViolation();
}
return valid;
} catch (Exception e) {
return false;
}
}
private Object getFieldValue(Object obj, String fieldName) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(obj);
}
}
// Kullanım
@ValidDateRange(startDate = "checkIn", endDate = "checkOut")
public class ReservationRequest {
@NotNull @FutureOrPresent
private LocalDate checkIn;
@NotNull @Future
private LocalDate checkOut;
@NotNull @Positive
private Integer guestCount;
}Composable Constraints — Annotation'ları Birleştirme
Birden fazla constraint'i tek bir annotation altında birleştirebilirsiniz. Bu meta-annotation yaklaşımıdır:
@NotBlank
@Size(min = 3, max = 20)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Sadece harf, rakam ve alt çizgi")
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = {}) // Ayrı validator yok — bileşenler doğrular
public @interface ValidUsername {
String message() default "Geçersiz kullanıcı adı";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// Kullanım — tek annotation, 3 kural
public class RegisterRequest {
@ValidUsername
private String username;
}Avantajı: Aynı kombinasyonu her yerde tekrarlamamak. Kural değiştiğinde tek yerde güncelleme yeterli.
Daha Karmaşık Composable
// Güçlü şifre — composed constraint
@NotBlank(message = "Şifre zorunludur")
@Size(min = 8, max = 64, message = "Şifre {min}-{max} karakter olmalı")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&]).+$",
message = "En az 1 büyük harf, 1 küçük harf, 1 rakam ve 1 özel karakter gerekli"
)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {})
public @interface StrongPassword {
String message() default "Şifre güvenlik gereksinimlerini karşılamıyor";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}ConstraintValidatorContext — Gelişmiş Hata Mesajları
ConstraintValidatorContext, hata mesajlarını özelleştirmenin güçlü bir yoludur:
public class PasswordStrengthValidator
implements ConstraintValidator<StrongPassword, String> {
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if (password == null) return true;
List<String> violations = new ArrayList<>();
if (password.length() < 8) {
violations.add("En az 8 karakter olmalı (mevcut: " + password.length() + ")");
}
if (!password.matches(".*[A-Z].*")) {
violations.add("En az bir büyük harf içermeli");
}
if (!password.matches(".*[a-z].*")) {
violations.add("En az bir küçük harf içermeli");
}
if (!password.matches(".*\\d.*")) {
violations.add("En az bir rakam içermeli");
}
if (!password.matches(".*[@$!%*?&].*")) {
violations.add("En az bir özel karakter içermeli (@$!%*?&)");
}
if (!violations.isEmpty()) {
// Varsayılan mesajı devre dışı bırak
context.disableDefaultConstraintViolation();
// Her ihlal için ayrı constraint violation oluştur
for (String violation : violations) {
context.buildConstraintViolationWithTemplate(violation)
.addConstraintViolation();
}
return false;
}
return true;
}
}Bu durumda birden fazla hata mesajı dönecektir:
{
"validationErrors": {
"password": [
"En az bir büyük harf içermeli",
"En az bir özel karakter içermeli (@$!%*?&)"
]
}
}Birden Fazla Tip Desteği
Aynı annotation'ın farklı tiplerle çalışmasını sağlayabilirsiniz:
@Constraint(validatedBy = {
NotEmptyStringValidator.class,
NotEmptyListValidator.class,
NotEmptyMapValidator.class
})
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NotEmptyCustom {
String message() default "Bu alan boş olamaz";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// String için
public class NotEmptyStringValidator
implements ConstraintValidator<NotEmptyCustom, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value != null && !value.trim().isEmpty();
}
}
// List için
public class NotEmptyListValidator
implements ConstraintValidator<NotEmptyCustom, List<?>> {
@Override
public boolean isValid(List<?> value, ConstraintValidatorContext ctx) {
return value != null && !value.isEmpty();
}
}
// Map için
public class NotEmptyMapValidator
implements ConstraintValidator<NotEmptyCustom, Map<?, ?>> {
@Override
public boolean isValid(Map<?, ?> value, ConstraintValidatorContext ctx) {
return value != null && !value.isEmpty();
}
}Bean Validation, alanın tipine göre otomatik olarak uygun validator'ı seçer.
Yaygın Hatalar
1. ❌ null → false Döndürmek
// ❌ YANLIŞ — opsiyonel alanlar için sorun çıkarır
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return false; // ❌ null her zaman invalid
return PATTERN.matcher(value).matches();
}
// ✅ DOĞRU — convention: null → true
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // ✅ null kontrolü @NotNull'a bırak
return PATTERN.matcher(value).matches();
}2. ❌ Zorunlu 3 Elemanı Unutmak
// ❌ YANLIŞ — groups ve payload eksik
@Constraint(validatedBy = MyValidator.class)
public @interface MyConstraint {
String message() default "Invalid";
// groups() YOK → ConstraintDefinitionException!
// payload() YOK → ConstraintDefinitionException!
}
// ✅ DOĞRU
@Constraint(validatedBy = MyValidator.class)
public @interface MyConstraint {
String message() default "Invalid";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}3. ❌ @Retention(RUNTIME) Unutmak
// ❌ YANLIŞ — varsayılan CLASS, runtime'da erişilemez
@Constraint(validatedBy = MyValidator.class)
@Target(ElementType.FIELD)
public @interface MyConstraint { ... }
// ✅ DOĞRU
@Constraint(validatedBy = MyValidator.class)
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME) // ZORUNLU
public @interface MyConstraint { ... }4. ❌ Cross-Field Validator'da Hatayı Doğru Alana Bağlamamak
// ❌ YANLIŞ — hata global seviyede, hangi alan sorunlu belli değil
@Override
public boolean isValid(Object obj, ConstraintValidatorContext ctx) {
// ... kontrol
return false; // Hata: "registerRequest: Şifreler eşleşmiyor"
}
// ✅ DOĞRU — hata spesifik alana bağlanmış
@Override
public boolean isValid(Object obj, ConstraintValidatorContext ctx) {
// ... kontrol
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate("Şifreler eşleşmiyor")
.addPropertyNode("confirmPassword") // confirmPassword alanına bağla
.addConstraintViolation();
return false; // Hata: "confirmPassword: Şifreler eşleşmiyor"
}Test Etme
class TurkishPhoneValidatorTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void shouldAcceptValidPhoneNumbers() {
assertValid("+905551234567");
assertValid("05551234567");
assertValid("5551234567");
}
@Test
void shouldRejectInvalidPhoneNumbers() {
assertInvalid("1234567890"); // 5 ile başlamıyor
assertInvalid("+901234567890"); // 5 ile başlamıyor
assertInvalid("+905"); // çok kısa
assertInvalid("abc"); // sayı değil
}
@Test
void shouldAcceptNull() {
// Convention: null geçerli
var dto = new TestDTO(null);
var violations = validator.validate(dto);
assertTrue(violations.isEmpty());
}
private void assertValid(String phone) {
var violations = validator.validate(new TestDTO(phone));
assertTrue(violations.isEmpty(), "Expected valid: " + phone);
}
private void assertInvalid(String phone) {
var violations = validator.validate(new TestDTO(phone));
assertFalse(violations.isEmpty(), "Expected invalid: " + phone);
}
private record TestDTO(@ValidTurkishPhone String phone) {}
}Özet
Custom validator iki bileşenden oluşur: Annotation (
@interface) + Validator (ConstraintValidator)Her annotation'da 3 zorunlu eleman:
message(),groups(),payload()@Retention(RUNTIME)ve@Constraint(validatedBy = ...)zorunludurnull → true convention'ı: Custom validator null'ı geçerli kabul etmeli, null kontrolü
@NotNull'a bırakılmalıCross-field validation için annotation
@Target(TYPE)olmalı — sınıf düzeyine konurValidator'lar Spring bean'idir — dependency injection ile repository, service inject edilebilir
Composable constraints ile birden fazla annotation'ı tek annotation altında birleştirebilirsiniz
ConstraintValidatorContextile birden fazla, alan-spesifik hata mesajı dönebilirsiniz
AI Asistan
Sorularını yanıtlamaya hazır