Bean Validation API — Jakarta Validation
Bir bankanın ATM'sini düşünün. Para çekmek istiyorsunuz. ATM, işlemi gerçekleştirmeden önce bir dizi kontrol yapar: Kart geçerli mi? PIN doğru mu? Bakiye yeterli mi? Çekilecek miktar limitlerin içinde mi? Bu kontrollerin hiçbiri para çekme işleminin kendisi değildir — ama işlem yapılmadan önce zorunlu güvenlik adımlarıdır.
Yazılımda validation (doğrulama) tam olarak budur. Kullanıcıdan gelen veriyi işlemeden önce kontrol etmek: email formatı doğru mu, yaş negatif mi, isim boş mu? Bu kontroller olmadan veritabanına bozuk veri girer, güvenlik açıkları oluşur ve uygulamanız beklenmedik şekillerde çöker.
Bean Validation API, Java dünyasında bu doğrulama işlemini standart, deklaratif ve tekrar kullanılabilir bir şekilde yapmanızı sağlar. Annotation'ları sınıf alanlarına koyarsınız — gerisini framework halleder.
Bean Validation API Nedir?
Bean Validation, Java'da nesne doğrulamanın spesifikasyonudur (standart tanımı). Tarihçesi:
JSR 303 (2009): İlk Bean Validation standardı
JSR 349 (2013): Bean Validation 1.1
JSR 380 (2017): Bean Validation 2.0
Jakarta Bean Validation 3.0 (2020+): Jakarta EE altında devam
Bu bir spesifikasyondur — yani kuralları tanımlar ama kendi başına çalışmaz. Hibernate Validator, bu spesifikasyonun referans implementasyonudur (Hibernate ORM ile karıştırmayın — tamamen ayrı bir proje).
Spring Boot Entegrasyonu
<!-- pom.xml — tek dependency yeterli -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Bu starter şunları içerir:
Jakarta Bean Validation API (
jakarta.validation-api)Hibernate Validator (implementasyon)
Expression Language (hata mesajlarında expression desteği)
Neden Ayrı Bir Standart?
"Neden her yerde if-else ile kontrol etmiyoruz?" diye sorabilirsiniz:
// ❌ Imperative (zorunlu) validation — her yerde tekrar
public User createUser(String name, String email, int age) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("İsim boş olamaz");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Email geçersiz");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Yaş 0-150 arasında olmalı");
}
// Aynı kontroller update, import, batch işlemlerinde de tekrar...
}
// ✅ Declarative (bildirimsel) validation — bir kez yaz, her yerde kullan
public class CreateUserRequest {
@NotBlank(message = "İsim boş olamaz")
private String name;
@Email(message = "Email geçersiz")
@NotBlank
private String email;
@Min(0) @Max(150)
private int age;
}Deklaratif yaklaşımın avantajları:
DRY: Kurallar bir kez tanımlanır, her yerde geçerlidir
Okunabilirlik: Kurallar alanın hemen yanında, kodu okumak kolay
Bakım: Kural değişikliği tek yerde yapılır
Framework entegrasyonu: Spring, otomatik olarak doğrulama yapıp hata mesajları döner
Null Kontrol Annotation'ları — @NotNull, @NotEmpty, @NotBlank
Bu üç annotation en sık karıştırılan ve en sık kullanılan annotation'lardır. Aralarındaki farkı kesin olarak bilmek kritiktir:
public class UserRequest {
@NotNull(message = "ID boş olamaz")
private Long id;
@NotEmpty(message = "Roller boş olamaz")
private List<String> roles;
@NotBlank(message = "İsim boş olamaz")
private String name;
}Detaylı Karşılaştırma
| Değer | @NotNull | @NotEmpty | @NotBlank |
|---|---|---|---|
null | ❌ INVALID | ❌ INVALID | ❌ INVALID |
"" (boş string) | ✅ valid | ❌ INVALID | ❌ INVALID |
" " (sadece boşluk) | ✅ valid | ✅ valid | ❌ INVALID |
"John" | ✅ valid | ✅ valid | ✅ valid |
[] (boş liste) | ✅ valid | ❌ INVALID | N/A |
Hangi Durumda Hangisi?
public class OrderRequest {
// Sayısal alanlar → @NotNull
// (Sayılar boş string olamaz, sadece null olabilir)
@NotNull(message = "Fiyat zorunludur")
private BigDecimal price;
@NotNull(message = "Miktar zorunludur")
private Integer quantity;
// String alanlar → @NotBlank (en güvenli)
// (null, "", " " hepsini reddeder)
@NotBlank(message = "Ürün adı zorunludur")
private String productName;
@NotBlank(message = "Açıklama zorunludur")
private String description;
// Koleksiyonlar → @NotEmpty
// (null ve boş listeyi reddeder)
@NotEmpty(message = "En az bir ürün seçilmeli")
private List<OrderItemDTO> items;
// Boolean → @NotNull
@NotNull(message = "Onay durumu belirtilmeli")
private Boolean confirmed;
// Enum → @NotNull
@NotNull(message = "Sipariş tipi seçilmeli")
private OrderType type;
// Tarih → @NotNull
@NotNull(message = "Tarih zorunludur")
private LocalDate deliveryDate;
}⚠️ Dikkat: @NotBlank sadece String için çalışır. Sayısal alana @NotBlank koyarsanız doğrulama beklenmedik davranabilir. Sayılar için @NotNull kullanın.
Boyut ve Uzunluk Kısıtlamaları — @Size
@Size, String, Collection, Map ve Array'ler için minimum/maksimum eleman sayısını kontrol eder:
public class ProfileRequest {
@Size(min = 2, max = 50, message = "İsim {min}-{max} karakter olmalı")
private String name;
@Size(min = 10, max = 500, message = "Biyografi {min}-{max} karakter olmalı")
private String bio;
@Size(min = 1, max = 5, message = "En az {min}, en fazla {max} hobi seçilebilir")
private List<String> hobbies;
@Size(max = 10, message = "En fazla {max} tag eklenebilir")
private Set<String> tags;
}@Size vs @Length vs @Column(length)
| Annotation | Kaynak | Çalışma Zamanı | Kullanım |
|---|---|---|---|
@Size | Bean Validation | Runtime (validation) | DTO alanları |
@Length | Hibernate Validator | Runtime (validation) | Hibernate-spesifik |
@Column(length=50) | JPA | DDL generation | Entity sınıfları |
💡 İpucu: DTO'larda @Size kullanın. Entity'lerde @Column(length) kullanın. @Length Hibernate-spesifiktir ve taşınabilirlik açısından önerilmez.
Sayısal Kısıtlamalar
@Min, @Max — Tam Sayı Sınırları
public class ProductRequest {
@Min(value = 0, message = "Fiyat negatif olamaz")
@Max(value = 1_000_000, message = "Fiyat {value}'ı aşamaz")
private BigDecimal price;
@Min(value = 1, message = "Minimum 1 adet sipariş edilebilir")
@Max(value = 1000, message = "Maksimum 1000 adet")
private Integer quantity;
}@DecimalMin, @DecimalMax — Ondalıklı Sınırlar
public class InterestRateRequest {
@DecimalMin(value = "0.01", inclusive = true, message = "Faiz oranı en az %0.01 olmalı")
@DecimalMax(value = "99.99", inclusive = true, message = "Faiz oranı en fazla %99.99 olabilir")
private BigDecimal interestRate;
@DecimalMin(value = "0.00", inclusive = false, message = "Tutar 0'dan büyük olmalı")
// inclusive = false → 0.00 geçersiz, 0.01 geçerli
private BigDecimal amount;
}@Positive, @Negative ve Varyantları
public class AccountRequest {
@Positive(message = "Tutar pozitif olmalı")
// > 0 (0 dahil değil)
private BigDecimal depositAmount;
@PositiveOrZero(message = "Bakiye negatif olamaz")
// >= 0
private BigDecimal balance;
@Negative(message = "İndirim negatif bir değer olmalı")
// < 0
private BigDecimal discount;
@NegativeOrZero
// <= 0
private BigDecimal penalty;
}@Digits — Basamak Kontrolü
public class InvoiceRequest {
@Digits(integer = 8, fraction = 2, message = "En fazla 8 tam, 2 ondalık basamak")
// Geçerli: 12345678.99
// Geçersiz: 123456789.99 (9 tam basamak)
// Geçersiz: 1234.999 (3 ondalık basamak)
private BigDecimal totalAmount;
}String Pattern Doğrulama — @Email, @Pattern
public class ContactRequest {
@Email(message = "Geçerli bir email adresi giriniz")
private String email;
// Daha kısıtlayıcı email doğrulama
@Email(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$",
message = "Kurumsal email adresi giriniz")
private String corporateEmail;
}⚠️ Dikkat: @Email varsayılan olarak oldukça gevşektir — "a@b" bile geçerli sayılır. Production'da daha kısıtlayıcı regex veya custom validator kullanmanızı öneririm.
@Pattern — Regex ile Doğrulama
public class UserRegistrationRequest {
@Pattern(regexp = "^[a-zA-Z0-9_]{3,20}$",
message = "Kullanıcı adı 3-20 karakter, sadece harf, rakam ve alt çizgi")
private String username;
@Pattern(regexp = "^\\+?[1-9]\\d{9,14}$",
message = "Geçerli telefon formatı: +905551234567")
private String phone;
@Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&]).{8,}$",
message = "Şifre: en az 8 karakter, 1 büyük harf, 1 küçük harf, 1 rakam, 1 özel karakter")
private String password;
@Pattern(regexp = "^[1-9]\\d{10}$",
message = "Geçerli TC Kimlik numarası giriniz")
private String tcKimlik;
@Pattern(regexp = "^(TR\\d{2}\\s?)(\\d{4}\\s?){5}\\d{2}$",
message = "Geçerli IBAN formatı giriniz")
private String iban;
}Tarih Doğrulama — @Past, @Future
public class EventRequest {
@Past(message = "Doğum tarihi geçmişte olmalı")
private LocalDate birthDate;
@PastOrPresent(message = "Kayıt tarihi gelecekte olamaz")
private LocalDateTime registrationDate;
@Future(message = "Etkinlik tarihi gelecekte olmalı")
private LocalDate eventDate;
@FutureOrPresent(message = "Planlanan tarih geçmişte olamaz")
private LocalDateTime scheduledAt;
}| Annotation | null | Geçmiş | Şimdi | Gelecek |
|---|---|---|---|---|
@Past | ✅ | ✅ | ❌ | ❌ |
@PastOrPresent | ✅ | ✅ | ✅ | ❌ |
@Future | ✅ | ❌ | ❌ | ✅ |
@FutureOrPresent | ✅ | ❌ | ✅ | ✅ |
💡 İpucu: Tüm tarih annotation'ları null değeri geçerli kabul eder. Tarih alanının zorunlu olmasını istiyorsanız @NotNull ile birlikte kullanın.
Validation Groups — Senaryoya Göre Kurallar
Aynı DTO'yu farklı işlemler için farklı kurallarla doğrulamak isteyebilirsiniz. Örneğin, oluşturma sırasında ID gerekmez ama güncelleme sırasında zorunludur:
Gerçek Dünya Analojisi
Bir otelin check-in ve check-out süreçlerini düşünün:
Check-in: İsim, TC, telefon → zorunlu. Oda tercihi → opsiyonel
Check-out: Oda numarası → zorunlu. Fatura adresi → zorunlu. TC → gerekmez
Aynı "Misafir Formu" farklı senaryolarda farklı kurallarla doğrulanır.
Implementasyon
// Adım 1: Marker interface'ler tanımla
public interface OnCreate {}
public interface OnUpdate {}
public interface OnPatch {}
// Adım 2: DTO'da groups belirt
public class UserRequest {
@Null(groups = OnCreate.class, message = "Oluşturmada ID belirtilmemeli")
@NotNull(groups = OnUpdate.class, message = "Güncelleme için ID zorunlu")
private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String name;
@Email
@NotBlank(groups = OnCreate.class) // Oluşturmada zorunlu
// OnUpdate'te belirtilmemiş → güncelleme sırasında doğrulanmaz
private String email;
@Size(min = 8, groups = OnCreate.class)
@NotBlank(groups = OnCreate.class)
// Güncelleme sırasında şifre gerekmez
private String password;
@NotNull(groups = {OnCreate.class, OnUpdate.class})
private Boolean active;
}
// Adım 3: Controller'da @Validated ile group belirt
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> create(
@Validated(OnCreate.class) @RequestBody UserRequest request) {
// Sadece OnCreate grubundaki kurallar çalışır
// → id: null olmalı, name: zorunlu, email: zorunlu, password: zorunlu
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@Validated(OnUpdate.class) @RequestBody UserRequest request) {
// Sadece OnUpdate grubundaki kurallar çalışır
// → id: zorunlu, name: zorunlu, email: doğrulanmaz, password: doğrulanmaz
}
}⚠️ Dikkat: @Valid annotation'ı groups desteklemez. Groups kullanmak için `@Validated` kullanmanız gerekir. Bu, @Valid ve @Validated arasındaki en önemli farktır.
Default Group
Hiçbir group belirtilmemiş annotation'lar Default grubuna aittir:
public class ProductRequest {
@NotBlank // groups belirtilmemiş → Default grubunda
private String name;
@NotNull(groups = OnCreate.class) // Sadece OnCreate'te
private Long categoryId;
}
// @Validated(OnCreate.class) kullanıldığında:
// → name: DOĞRULANMAZ (Default grubunda, OnCreate'te değil!)
// → categoryId: doğrulanır
// Çözüm: Default grubunu da dahil et
@PostMapping
public ResponseEntity<?> create(
@Validated({Default.class, OnCreate.class}) @RequestBody ProductRequest request) {
// Hem Default hem OnCreate kuralları çalışır
}Validation Messages Özelleştirme
1. Doğrudan Annotation'da
@NotBlank(message = "Kullanıcı adı boş bırakılamaz")
private String username;2. ValidationMessages.properties ile
# src/main/resources/ValidationMessages.properties
user.name.required=Kullanıcı adı zorunludur
user.email.invalid=Geçerli bir email adresi giriniz
user.password.weak=Şifre en az {min} karakter olmalıdır
# Türkçe
jakarta.validation.constraints.NotBlank.message=Bu alan boş bırakılamaz
jakarta.validation.constraints.Size.message={min} ile {max} karakter arasında olmalı
jakarta.validation.constraints.Email.message=Geçerli bir email adresi giriniz@NotBlank(message = "{user.name.required}")
private String name;
@Size(min = 8, max = 64, message = "{user.password.weak}")
private String password;
// → "Şifre en az 8 karakter olmalıdır"3. Parametreli Mesajlar
Bean Validation, mesajlarda annotation parametrelerine otomatik erişim sağlar:
@Size(min = 3, max = 20, message = "Kullanıcı adı {min}-{max} karakter olmalı")
private String username;
// → "Kullanıcı adı 3-20 karakter olmalı"
@DecimalMin(value = "0.01", message = "Minimum değer: {value}")
private BigDecimal price;
// → "Minimum değer: 0.01"
@Digits(integer = 5, fraction = 2, message = "Format: max {integer} tam, {fraction} ondalık")
private BigDecimal amount;
// → "Format: max 5 tam, 2 ondalık"Tüm Annotation'lar — Cheatsheet
| Annotation | Uygulanabilir Tip | Açıklama |
|---|---|---|
@NotNull | Herhangi bir tip | null olamaz |
@NotEmpty | String, Collection, Map, Array | null ve boş olamaz |
@NotBlank | Sadece String | null, boş, sadece boşluk olamaz |
@Size(min, max) | String, Collection, Map, Array | Boyut kontrolü |
@Min(value) | Sayısal tipler | Minimum değer |
@Max(value) | Sayısal tipler | Maksimum değer |
@DecimalMin | Sayısal tipler | Ondalıklı minimum |
@DecimalMax | Sayısal tipler | Ondalıklı maksimum |
@Positive | Sayısal tipler | > 0 |
@PositiveOrZero | Sayısal tipler | >= 0 |
@Negative | Sayısal tipler | < 0 |
@NegativeOrZero | Sayısal tipler | <= 0 |
@Digits(integer, fraction) | Sayısal tipler | Basamak kontrolü |
@Email | String | Email formatı |
@Pattern(regexp) | String | Regex pattern |
@Past | Tarih tipleri | Geçmiş tarih |
@PastOrPresent | Tarih tipleri | Geçmiş veya bugün |
@Future | Tarih tipleri | Gelecek tarih |
@FutureOrPresent | Tarih tipleri | Bugün veya gelecek |
@AssertTrue | Boolean | true olmalı |
@AssertFalse | Boolean | false olmalı |
Gerçek Dünya Örneği: E-Ticaret Kayıt Formu
public class CustomerRegistrationRequest {
@NotBlank(message = "Ad zorunludur")
@Size(min = 2, max = 50, message = "Ad {min}-{max} karakter olmalı")
private String firstName;
@NotBlank(message = "Soyad zorunludur")
@Size(min = 2, max = 50, message = "Soyad {min}-{max} karakter olmalı")
private String lastName;
@NotBlank(message = "Email zorunludur")
@Email(message = "Geçerli bir email giriniz")
private String email;
@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 ve 1 rakam gerekli"
)
private String password;
@NotBlank(message = "Telefon zorunludur")
@Pattern(regexp = "^\\+90[0-9]{10}$", message = "Format: +905XXXXXXXXX")
private String phone;
@NotNull(message = "Doğum tarihi zorunludur")
@Past(message = "Doğum tarihi geçmişte olmalı")
private LocalDate birthDate;
@NotNull(message = "Sözleşme onayı zorunludur")
@AssertTrue(message = "Kullanıcı sözleşmesini kabul etmelisiniz")
private Boolean termsAccepted;
@Size(max = 5, message = "En fazla 5 adres ekleyebilirsiniz")
private List<@Valid AddressDTO> addresses;
}
public class AddressDTO {
@NotBlank(message = "İl zorunludur")
private String city;
@NotBlank(message = "İlçe zorunludur")
private String district;
@NotBlank(message = "Adres satırı zorunludur")
@Size(max = 200, message = "Adres en fazla 200 karakter olabilir")
private String addressLine;
@Pattern(regexp = "^\\d{5}$", message = "Posta kodu 5 haneli olmalı")
private String zipCode;
}Yaygın Hatalar ve Best Practice'ler
1. ❌ Entity'de Validation — DTO'da Kullanın
// ❌ YANLIŞ — Entity'de validation
@Entity
public class User {
@NotBlank
private String name;
// Entity hem JPA hem validation sorumluluğu taşıyor
}
// ✅ DOĞRU — DTO'da validation
public class CreateUserRequest {
@NotBlank
private String name;
}
@Entity
public class User {
@Column(nullable = false, length = 50)
private String name;
// Entity sadece persistence ile ilgilenir
}2. ❌ @NotNull ile @NotBlank Karıştırmak
// ❌ YANLIŞ — String alanında @NotNull
@NotNull
private String name; // "" (boş string) geçerli → veritabanına boş string girer
// ✅ DOĞRU — String alanında @NotBlank
@NotBlank
private String name; // null, "", " " hepsi reddedilir3. ❌ Hata Mesajı Yazmamak
// ❌ YANLIŞ — varsayılan mesaj anlamsız
@NotBlank
private String name;
// Mesaj: "must not be blank" (İngilizce, son kullanıcıya gösterilemez)
// ✅ DOĞRU — anlamlı Türkçe mesaj
@NotBlank(message = "İsim alanı zorunludur")
private String name;4. ❌ Primitive Tiplerde @NotNull
// ❌ YANLIŞ — int null olamaz (varsayılan 0)
@NotNull
private int age; // Hiçbir zaman null olmaz, @NotNull anlamsız
// ✅ DOĞRU — wrapper tip kullanın
@NotNull(message = "Yaş zorunludur")
@Min(value = 0, message = "Yaş negatif olamaz")
private Integer age; // null olabilir → @NotNull anlamlıÖzet
Bean Validation API, Java'da nesne doğrulamanın standardıdır — Hibernate Validator referans implementasyonu
`@NotNull` sadece null'ı reddeder, `@NotEmpty` null ve boşu reddeder, `@NotBlank` null, boş ve sadece boşluk olanı reddeder
String alanlarında `@NotBlank`, sayısal alanlarda `@NotNull`, koleksiyonlarda `@NotEmpty` kullanın
Validation Groups ile aynı DTO'yu farklı senaryolarda farklı kurallarla doğrulayabilirsiniz
Tüm annotation'lar null'ı geçerli kabul eder (convention) — zorunlu alanlar için
@NotNullveya@NotBlankeklemeyi unutmayınValidation annotation'larını DTO sınıflarında kullanın, entity'lerde değil
Her zaman anlamlı, Türkçe hata mesajları yazın — varsayılan İngilizce mesajlar kullanıcıya gösterilemez
AI Asistan
Sorularını yanıtlamaya hazır