← Kursa Dön
📄 Text · 18 min

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?

DurumStandart AnnotationCustom Validator
Boş kontrolü@NotBlankGereksiz
Email formatı@EmailGereksiz
Regex pattern@PatternGereksiz
Min/max değer@Min, @MaxGereksiz
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:

  1. Annotation (@interface): Kuralın "tabelası" — nereye uygulanacağını belirtir

  2. 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?

  1. Opsiyonel alanlar: Telefon alanı opsiyonel olabilir. null → doğrulama atla, değer varsa → formatı kontrol et

  2. Sorumluluk ayrımı: Her annotation tek bir iş yapar. @NotNull = "null olmasın", @ValidPhone = "format doğru olsun"

  3. 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:

  1. Race condition: İki istek aynı anda aynı email ile gelirse, ikisi de "benzersiz" geçebilir. Bu nedenle veritabanında da UNIQUE constraint olmalıdır

  2. Performance: 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 = ...) zorunludur

  • null → 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 konur

  • Validator'lar Spring bean'idir — dependency injection ile repository, service inject edilebilir

  • Composable constraints ile birden fazla annotation'ı tek annotation altında birleştirebilirsiniz

  • ConstraintValidatorContext ile birden fazla, alan-spesifik hata mesajı dönebilirsiniz