@Valid vs @Validated — Controller Validation
Bir havalimanının güvenlik kontrol noktasını düşünün. Yolcu uçağa binmeden önce farklı kontrol aşamalarından geçer: pasaport kontrolü, bagaj taraması, biniş kartı doğrulaması. Her aşama farklı bir kontrol noktasında, farklı kurallarla yapılır — ama hepsi yolcunun güvenli bir şekilde uçağa binmesini sağlar.
Spring MVC'de controller'lar da benzer bir güvenlik noktasıdır. Kullanıcıdan gelen verinin işlenmeden önce doğrulanması gerekir. Bu doğrulamayı tetiklemek için controller metot parametrelerine @Valid veya @Validated annotation'ı eklenir. Bu iki annotation benzer görünse de kritik farkları vardır ve hangisini ne zaman kullanacağınızı bilmek, doğru çalışan bir validation katmanı kurmanın temelidir.
Doğrulama Nasıl Tetiklenir?
Bean Validation annotation'larını (@NotBlank, @Email, @Size...) DTO alanlarına koymak tek başına yetmez. Bu annotation'lar kuralları tanımlar ama çalıştırmaz. Doğrulamanın tetiklenmesi için controller parametresinde @Valid veya @Validated kullanmanız gerekir:
// ❌ Validation tetiklenmez — annotation'lar sadece dekorasyon
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@RequestBody CreateUserRequest request) {
// request.getName() boş olsa bile buraya gelir
// Veritabanına bozuk veri girer!
}
// ✅ Validation tetiklenir
@PostMapping("/users")
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
// Validation hatası varsa → MethodArgumentNotValidException
// Bu metoda ancak tüm kurallar geçerse ulaşılır
}Gerçek Dünya Analojisi
Düşünün ki bir binanın girişinde güvenlik kuralları yazılı bir tabela var: "Ziyaretçi kartı zorunlu, 18 yaş altı giremez, kapalı alan sigara yasağı." Ama girişte güvenlik görevlisi yoksa bu kurallar uygulanmaz.
Annotation'lar (@NotBlank, @Email) = Kurallar tabelası
@Valid / @Validated = Güvenlik görevlisi (kuralları uygulayan)
@Valid — Jakarta Standard
@Valid, Jakarta Bean Validation (eski adıyla javax.validation) spesifikasyonunun parçasıdır. Spring'e özel değildir — herhangi bir Bean Validation uyumlu framework'te çalışır.
Temel Kullanım
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
// Validation başarısızsa buraya ulaşılmaz
// MethodArgumentNotValidException otomatik fırlatılır
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.create(request));
}
}Cascaded (Zincirleme) Validation — @Valid'in Süper Gücü
@Valid'in en önemli özelliği iç içe nesneleri de doğrulayabilmesidir. DTO içindeki başka bir DTO'nun da doğrulanmasını istiyorsanız, o alana @Valid eklemelisiniz:
public class OrderRequest {
@NotBlank(message = "Sipariş numarası zorunludur")
private String orderNumber;
@Valid // ← Bu olmadan AddressDTO içindeki @NotBlank'ler çalışmaz!
@NotNull(message = "Teslimat adresi zorunludur")
private AddressDTO deliveryAddress;
@Valid // ← Listedeki HER elemanı doğrula
@NotEmpty(message = "En az bir ürün eklenmelidir")
private List<OrderItemDTO> items;
}
public class AddressDTO {
@NotBlank(message = "Şehir zorunludur")
private String city;
@NotBlank(message = "Cadde/Sokak zorunludur")
private String street;
@Pattern(regexp = "\\d{5}", message = "Posta kodu 5 haneli olmalı")
private String zipCode;
}
public class OrderItemDTO {
@NotNull(message = "Ürün ID zorunludur")
private Long productId;
@Positive(message = "Miktar pozitif olmalı")
private Integer quantity;
}⚠️ Kritik: @Valid olmadan iç nesnelerdeki annotation'lar sessizce yok sayılır! Bu, en sık yapılan validation hatalarından biridir:
// ❌ @Valid eksik — AddressDTO doğrulanmaz
public class OrderRequest {
@NotNull
private AddressDTO deliveryAddress; // İçindeki @NotBlank'ler çalışmaz!
}
// ✅ @Valid ile — AddressDTO de doğrulanır
public class OrderRequest {
@Valid
@NotNull
private AddressDTO deliveryAddress; // İçindeki @NotBlank'ler çalışır!
}Çok Katmanlı Cascaded Validation
Cascaded validation birden fazla seviye derinliğe inebilir:
public class CompanyRequest {
@NotBlank
private String name;
@Valid @NotNull
private AddressDTO headquarters; // 2. seviye
@Valid
private List<DepartmentDTO> departments;
}
public class DepartmentDTO {
@NotBlank
private String name;
@Valid @NotNull
private ManagerDTO manager; // 3. seviye
@Valid
private List<EmployeeDTO> employees; // 3. seviye (liste)
}
public class ManagerDTO {
@NotBlank
private String firstName;
@Email
private String email;
@Valid
private AddressDTO homeAddress; // 4. seviye
}Hata mesajlarında field path'ler nokta notasyonu ile gösterilir:
{
"validationErrors": {
"headquarters.city": "Şehir zorunludur",
"departments[0].name": "Departman adı zorunludur",
"departments[0].manager.email": "Geçerli email giriniz",
"departments[1].employees[2].firstName": "İsim zorunludur",
"departments[0].manager.homeAddress.zipCode": "Posta kodu 5 haneli olmalı"
}
}@Validated — Spring Extension
@Validated, Spring Framework'ün sunduğu genişletilmiş annotation'dır. @Valid'in tüm özelliklerini destekler ve üzerine iki kritik ek özellik ekler:
1. Validation Groups Desteği
@Valid ile validation groups kullanılamaz. Bu, @Validated'ın en önemli farkıdır:
// Marker interface'ler
public interface OnCreate {}
public interface OnUpdate {}
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(groups = {OnCreate.class, OnUpdate.class})
@NotBlank(groups = OnCreate.class)
private String email;
@NotBlank(groups = OnCreate.class)
@Size(min = 8, groups = OnCreate.class)
private String password; // Güncelleme sırasında şifre zorunlu değil
}
// Controller
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<UserResponse> create(
@Validated(OnCreate.class) @RequestBody UserRequest request) {
// → id: null olmalı, name: zorunlu, email: zorunlu, password: zorunlu
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.create(request));
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> update(
@PathVariable Long id,
@Validated(OnUpdate.class) @RequestBody UserRequest request) {
// → id: zorunlu, name: zorunlu, email: zorunlu, password: doğrulanmaz
return ResponseEntity.ok(userService.update(id, request));
}
}// ❌ @Valid ile groups kullanılamaz — DERLEME HATASI VERMEZnama etkisiz kalır
@PostMapping
public ResponseEntity<?> create(@Valid(OnCreate.class) @RequestBody UserRequest req) { }
// @Valid parametre almaz!
// ✅ Groups için @Validated zorunlu
@PostMapping
public ResponseEntity<?> create(@Validated(OnCreate.class) @RequestBody UserRequest req) { }2. Sınıf Düzeyinde Validation — Method Parameter Doğrulama
@RequestParam ve @PathVariable doğrulaması için controller sınıfına @Validated eklenir:
@RestController
@RequestMapping("/api/products")
@Validated // ← Sınıf düzeyinde — @RequestParam/@PathVariable doğrulaması için
public class ProductController {
@GetMapping("/{id}")
public ProductResponse getProduct(
@PathVariable @Min(value = 1, message = "ID 1'den büyük olmalı") Long id) {
// id = 0 veya -1 gönderilirse → ConstraintViolationException
return productService.findById(id);
}
@GetMapping
public Page<ProductResponse> searchProducts(
@RequestParam @NotBlank(message = "Arama terimi zorunlu") String query,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(required = false) @Pattern(
regexp = "^(price|name|createdAt)$",
message = "Sıralama: price, name veya createdAt") String sortBy) {
return productService.search(query, page, size, sortBy);
}
@GetMapping("/price-range")
public List<ProductResponse> getByPriceRange(
@RequestParam @DecimalMin("0.01") BigDecimal minPrice,
@RequestParam @DecimalMax("1000000") BigDecimal maxPrice) {
return productService.findByPriceRange(minPrice, maxPrice);
}
}⚠️ Dikkat: Sınıf düzeyinde @Validated olmadan @PathVariable ve @RequestParam üzerindeki validation annotation'ları çalışmaz!
İki Farklı Exception
@Valid/@Validated kullanım şekline göre farklı exception'lar fırlatılır:
| Durum | Exception | Tetikleyen |
|---|---|---|
@RequestBody validation hatası | MethodArgumentNotValidException | @Valid veya @Validated |
@RequestParam, @PathVariable hatası | ConstraintViolationException | Sınıf düzeyinde @Validated |
Bu farkı @ControllerAdvice'ta ele almanız gerekir:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// @RequestBody validation hataları (400)
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex,
WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
// Object-level hatalar (class-level annotation'lar, örn: @PasswordMatch)
ex.getBindingResult().getGlobalErrors().forEach(error ->
errors.put(error.getObjectName(), error.getDefaultMessage())
);
return ErrorResponse.ofValidation("Doğrulama hataları",
extractPath(request), errors);
}
// @RequestParam, @PathVariable validation hataları (400)
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolation(ConstraintViolationException ex,
WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getConstraintViolations().forEach(violation -> {
// Path: "searchProducts.query" → sadece "query" kısmını al
String path = violation.getPropertyPath().toString();
String field = path.contains(".")
? path.substring(path.lastIndexOf('.') + 1)
: path;
errors.put(field, violation.getMessage());
});
return ErrorResponse.ofValidation("Parametre doğrulama hataları",
extractPath(request), errors);
}
}BindingResult — Manuel Hata Yönetimi
BindingResult, validation hatalarını yakalamak yerine kendiniz yönetmenizi sağlar. @Valid'den hemen sonra parametre olarak eklenir:
@PostMapping("/users")
public ResponseEntity<?> createUser(
@Valid @RequestBody CreateUserRequest request,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
Map<String, String> errors = new LinkedHashMap<>();
// Field-level hatalar
bindingResult.getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
// Global (class-level) hatalar
bindingResult.getGlobalErrors().forEach(error ->
errors.put(error.getObjectName(), error.getDefaultMessage())
);
return ResponseEntity.badRequest().body(Map.of(
"message", "Doğrulama hataları",
"errors", errors
));
}
return ResponseEntity.status(HttpStatus.CREATED)
.body(userService.create(request));
}BindingResult Kuralları
⚠️ Kural 1: BindingResult, @Valid parametresinden hemen sonra gelmelidir:
// ❌ YANLIŞ — araya parametre girmiş
public ResponseEntity<?> create(
@Valid @RequestBody CreateUserRequest request,
@RequestHeader String token, // ← araya girdi
BindingResult bindingResult) { } // → Spring eşleştiremez → HATA
// ✅ DOĞRU — hemen ardından
public ResponseEntity<?> create(
@Valid @RequestBody CreateUserRequest request,
BindingResult bindingResult,
@RequestHeader String token) { }⚠️ Kural 2: BindingResult varsa MethodArgumentNotValidException fırlatılmaz:
// BindingResult YOKSA:
// Validation hatası → MethodArgumentNotValidException → @ControllerAdvice yakalar
// BindingResult VARSA:
// Validation hatası → BindingResult'a yazılır → Exception FIRLATİLMAZ
// Kendiniz kontrol etmelisiniz!BindingResult vs @ControllerAdvice — Hangisi?
| Kriter | BindingResult | @ControllerAdvice |
|---|---|---|
| Scope | Tek endpoint | Tüm uygulama |
| DRY | ❌ Her endpoint'te tekrar | ✅ Tek yerde tanımla |
| Esneklik | Endpoint-spesifik mantık | Genel hata formatı |
| Önerilen | Nadiren (özel durumlar) | Her zaman (varsayılan) |
💡 Best Practice: BindingResult yerine @ControllerAdvice ile merkezi hata yönetimini kullanın. BindingResult sadece belirli bir endpoint'te özel hata işleme gerektiğinde tercih edilir (örn: kısmi kaydetme, hatalı alanları UI'da vurgulama).
Karşılaştırma Tablosu — @Valid vs @Validated
| Özellik | @Valid | @Validated |
|---|---|---|
| Kaynak | Jakarta Bean Validation (standart) | Spring Framework (genişletme) |
| Paket | jakarta.validation | org.springframework.validation.annotation |
| @RequestBody doğrulama | ✅ | ✅ |
| Cascaded (iç içe) validation | ✅ | ✅ |
| Validation Groups | ❌ | ✅ |
| @RequestParam / @PathVariable | ❌ | ✅ (sınıf düzeyinde) |
| Kullanım yeri | Parametre, field | Parametre, sınıf |
| Exception (RequestBody) | MethodArgumentNotValidException | MethodArgumentNotValidException |
| Exception (Param/Path) | — | ConstraintViolationException |
Karar Rehberi
Validation Groups gerekiyor mu?
├─ Evet → @Validated(GroupX.class)
└─ Hayır → @Valid (yeterli ve standart)
@RequestParam / @PathVariable doğrulaması var mı?
├─ Evet → Sınıf düzeyinde @Validated
└─ Hayır → @Valid yeterliService Katmanında Validation
Controller dışında, service katmanında da validation yapabilirsiniz:
@Service
@Validated // ← Service'te de validation aktif
public class UserService {
public UserResponse findByEmail(@Email @NotBlank String email) {
// email geçersizse → ConstraintViolationException
return userRepository.findByEmail(email)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", "email", email));
}
public UserResponse create(@Valid CreateUserRequest request) {
// @Valid ile DTO doğrulaması (cascaded dahil)
// ...
}
}💡 İpucu: Service katmanında validation, özellikle birden fazla entry point olduğunda (controller + message listener + scheduled task) faydalıdır. Ama controller'da zaten doğrulama yapıyorsanız, çift doğrulama gereksiz olabilir.
Gerçek Dünya Örneği: E-Ticaret API
// ─── DTO'lar ───
public record CreateProductRequest(
@NotBlank(message = "Ürün adı zorunludur")
@Size(min = 3, max = 100, message = "Ürün adı {min}-{max} karakter olmalı")
String name,
@NotBlank(message = "Açıklama zorunludur")
@Size(max = 2000, message = "Açıklama en fazla {max} karakter olabilir")
String description,
@NotNull(message = "Fiyat zorunludur")
@DecimalMin(value = "0.01", message = "Fiyat en az 0.01 olmalı")
BigDecimal price,
@NotNull(message = "Stok zorunludur")
@Min(value = 0, message = "Stok negatif olamaz")
Integer stock,
@NotNull(message = "Kategori zorunludur")
Long categoryId,
@Size(max = 10, message = "En fazla {max} tag eklenebilir")
List<@NotBlank(message = "Tag boş olamaz") String> tags,
@Valid
List<ProductVariantDTO> variants
) {}
public record ProductVariantDTO(
@NotBlank(message = "Varyant adı zorunludur")
String name,
@NotNull(message = "Varyant fiyat farkı zorunludur")
@DecimalMin(value = "0.00", inclusive = true)
BigDecimal priceAdjustment,
@NotNull @Min(0)
Integer stock
) {}
// ─── Controller ───
@RestController
@RequestMapping("/api/products")
@Validated
public class ProductController {
private final ProductService productService;
// POST — Oluşturma
@PostMapping
public ResponseEntity<ProductResponse> create(
@Valid @RequestBody CreateProductRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(productService.create(request));
}
// GET — Tekil (PathVariable doğrulaması)
@GetMapping("/{id}")
public ProductResponse getById(
@PathVariable @Min(1) Long id) {
return productService.findById(id);
}
// GET — Arama (RequestParam doğrulaması)
@GetMapping("/search")
public Page<ProductResponse> search(
@RequestParam @NotBlank String query,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@RequestParam(required = false) @DecimalMin("0") BigDecimal minPrice,
@RequestParam(required = false) @DecimalMax("1000000") BigDecimal maxPrice) {
return productService.search(query, page, size, minPrice, maxPrice);
}
}Yaygın Hatalar
1. ❌ @Valid Olmadan İç Nesne Doğrulanmaz
// ❌ YANLIŞ
public class OrderRequest {
@NotNull
private AddressDTO address; // İçindeki @NotBlank'ler yok sayılır!
}
// ✅ DOĞRU
public class OrderRequest {
@Valid @NotNull
private AddressDTO address; // İç doğrulama da yapılır
}2. ❌ Sınıf Düzeyinde @Validated Unutmak
// ❌ YANLIŞ — @PathVariable doğrulaması çalışmaz
@RestController
public class ProductController {
@GetMapping("/{id}")
public Product get(@PathVariable @Min(1) Long id) { } // @Min etkisiz!
}
// ✅ DOĞRU
@RestController
@Validated // ← Bu gerekli
public class ProductController {
@GetMapping("/{id}")
public Product get(@PathVariable @Min(1) Long id) { } // @Min çalışır
}3. ❌ Groups Kullanırken Default Grubunu Unutmak
public class ProductRequest {
@NotBlank // Default grubunda
private String name;
@NotNull(groups = OnCreate.class)
private Long categoryId;
}
// @Validated(OnCreate.class) → name doğrulanmaz! (Default grubunda)
// @Validated({Default.class, OnCreate.class}) → ikisi de doğrulanır ✅Özet
`@Valid` Jakarta standardıdır —
@RequestBodydoğrulaması ve cascaded validation için kullanılır`@Validated` Spring genişletmesidir —
@Valid'in her şeyini yapar + groups ve sınıf düzeyinde validation desteğiİç içe DTO'ların doğrulanması için
@Validannotation'ı ilgili alana mutlaka eklenmelidir@RequestParam/@PathVariabledoğrulaması için controller sınıfına@Validatedeklenmelidir@RequestBodyhatası →MethodArgumentNotValidException|@RequestParamhatası →ConstraintViolationExceptionBindingResult yerine `@ControllerAdvice` ile merkezi hata yönetimini tercih edin
Groups kullanırken Default grubunu da dahil etmeyi unutmayın
AI Asistan
Sorularını yanıtlamaya hazır