Eğitim Portalı/Spring Boot/Validation ve Global Exception Handling
Spring Boot03-spring-boot/03-validation-ve-exception-handling

Validation ve Global Exception Handling

Bir API'nin dış dünyaya bakan iki kritik sorumluluğu vardır: gelen veriyi doğrulamak ve hata olduğunda istemciye anlamlı, tutarlı bir cevap dönmek. Spring Framework bölümünde Bean Validation'ı öğrendik; şimdi onu bir…

Validation ve Global Exception Handling

Bir API'nin dış dünyaya bakan iki kritik sorumluluğu vardır: gelen veriyi doğrulamak ve hata olduğunda istemciye anlamlı, tutarlı bir cevap dönmek. Spring Framework bölümünde Bean Validation'ı öğrendik; şimdi onu bir web API'sinde @Valid ile otomatik çalıştırmayı ve tüm hataları tek yerden, zarif JSON gövdeleriyle yönetmeyi (@RestControllerAdvice) görüyoruz.

@Valid ile otomatik doğrulama

Spring MVC'de gelen JSON gövdesini bir DTO'ya bağlarken @Valid eklersen, Spring controller metodunu çalıştırmadan önce kuralları kontrol eder. İhlal varsa MethodArgumentNotValidException fırlar ve Spring Boot otomatik olarak 400 Bad Request döner:

record KullaniciDto(@NotBlank String ad, @Email String eposta, @Min(18) int yas) {}

@PostMapping("/api/kullanicilar")
public String kaydet(@Valid @RequestBody KullaniciDto dto) { ... } // geçersizse buraya HİÇ girilmez

Örnek 1 (./Ornek1.java) geçerli bir isteğin 200, geçersiz bir isteğin (boş ad, kötü e-posta, yaş<18) 400 döndüğünü canlı gösterir. Geçersiz veri controller'a hiç ulaşmaz — ama Boot'un varsayılan hata gövdesi kabadır ve API'ne özel değildir. Bunu düzeltmek için global bir hata yöneticisi gerekir.

@RestControllerAdvice ile global hata yönetimi

@RestControllerAdvice, tüm controller'lardaki istisnaları tek bir yerde yakalamanı sağlar. Her istisna tipi için bir @ExceptionHandler yazar, istediğin durum kodu ve gövdeyi dönersin. Böylece hata yanıtların tutarlı ve anlamlı olur:

@RestControllerAdvice
class GlobalHataYonetimi {
    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<?> dogrulama(MethodArgumentNotValidException ex) {
        // alan bazlı temiz mesajlar -> 400
    }
    @ExceptionHandler(KaynakBulunamadiException.class)
    ResponseEntity<?> bulunamadi(KaynakBulunamadiException ex) {
        // anlamlı gövde -> 404
    }
}

Örnek 2 (./Ornek2.java) doğrulama hatalarını alan bazlı temiz bir JSON'a (400) ve özel bir "kaynak bulunamadı" istisnasını anlamlı bir gövdeye (404) çevirir. Artık API genelinde hatalar aynı yapıda döner — istemci için öngörülebilir bir sözleşme.

Kısa yol ve standart: @ResponseStatus ve ProblemDetail

İki ek teknik:

  • @ResponseStatus: Bir istisna sınıfına doğrudan HTTP kodu iliştirirsin; o istisna fırladığında Boot o kodu döner — advice yazmana bile gerek kalmaz (basit durumlar için hızlı).
    @ResponseStatus(HttpStatus.CONFLICT) class StokYetersizException extends RuntimeException {}
  • ProblemDetail (RFC 7807, Spring 6+): Hata gövdeleri için standart, makine-okunur bir biçim (type, title, status, detail + özel alanlar). Mikroservislerde tutarlı hata sözleşmesi için idealdir.
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, mesaj);
    pd.setTitle("Sipariş bulunamadı");

Örnek 3 (./Ornek3.java) her ikisini de gösterir.

İyi uygulama notları

  • DTO'ları doğrula, entity'leri değil: İstek gövdesi için ayrı DTO kullan; entity'leri doğrudan dışarı açma.
  • İç içe nesneler: @Valid'i iç içe alanlara da koyarsan derinlemesine doğrulanır.
  • Tutarlı hata sözleşmesi: Tüm endpoint'ler aynı hata yapısını dönsün (advice veya ProblemDetail ile). İstemci tek bir formatı işler.
  • Hassas bilgi sızdırma: Hata gövdesinde stack trace / iç ayrıntı döndürme (üretimde).

Özet

@Valid ile gelen veriyi otomatik doğrulamayı ve geçersiz isteklerin 400 döndüğünü (Örnek 1), @RestControllerAdvice ile tüm hataları tek yerden tutarlı JSON'a çevirmeyi (Örnek 2) ve @ResponseStatus/ProblemDetail ile kısa yol ve standart hata gövdelerini (Örnek 3) canlı bir API üzerinde gördük. API'nin giriş ve çıkış kapıları artık sağlam. Sırada, uygulamayı yetkisiz erişime karşı koruyan kritik konu: Spring Security.

Kod Örnekleri(3)

Ornek1

ortam gerekir
Ornek1.java
1// Ornek1: @Valid ile istek gövdesini doğrulama — geçersiz veri otomatik 400 Bad Request olur.
2// Çalıştırma: portal derleyip gömülü Tomcat ile başlatır.
3package com.egitim.springboot.hata;
4
5import jakarta.validation.Valid;
6import jakarta.validation.constraints.Email;
7import jakarta.validation.constraints.Min;
8import jakarta.validation.constraints.NotBlank;
9import org.springframework.boot.CommandLineRunner;
10import org.springframework.boot.SpringApplication;
11import org.springframework.boot.autoconfigure.SpringBootApplication;
12import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
13import org.springframework.context.annotation.Bean;
14import org.springframework.http.MediaType;
15import org.springframework.web.bind.annotation.PostMapping;
16import org.springframework.web.bind.annotation.RequestBody;
17import org.springframework.web.bind.annotation.RestController;
18import org.springframework.web.client.RestClient;
19
20@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
21public class Ornek1 {
22
23    public static void main(String[] args) { SpringApplication.run(Ornek1.class, args); }
24
25    // Gelen JSON'un uyması gereken kurallar DTO üzerinde tanımlı.
26    static class KullaniciDto {
27        @NotBlank(message = "ad zorunludur")
28        public String ad;
29        @Email(message = "geçerli bir e-posta girin")
30        public String eposta;
31        @Min(value = 18, message = "yaş en az 18 olmalı")
32        public int yas;
33    }
34
35    @RestController
36    static class KullaniciController {
37        // @Valid: Spring, gövdeyi controller'a girmeden önce doğrular.
38        // İhlal varsa MethodArgumentNotValidException -> Boot otomatik 400 Bad Request döner.
39        @PostMapping("/api/kullanicilar")
40        public String kaydet(@Valid @RequestBody KullaniciDto dto) {
41            return "Kullanıcı kaydedildi: " + dto.ad;
42        }
43    }
44
45    @Bean
46    CommandLineRunner selfTest() {
47        return args -> {
48            RestClient c = RestClient.create("http://localhost:8080");
49            System.out.println("\n================ @Valid DOĞRULAMA ================");
50
51            // Geçerli istek -> 200
52            String body = "{\"ad\":\"Ada\",\"eposta\":\"ada@site.com\",\"yas\":30}";
53            var ok = c.post().uri("/api/kullanicilar").contentType(MediaType.APPLICATION_JSON)
54                    .body(body).retrieve().toEntity(String.class);
55            System.out.println("Geçerli  -> " + ok.getStatusCode() + " | " + ok.getBody());
56
57            // Geçersiz istek -> 400 (status + gövdeyi exchange ile okuyoruz, RestClient 4xx'te fırlatır)
58            String kotu = "{\"ad\":\"\",\"eposta\":\"gecersiz\",\"yas\":15}";
59            c.post().uri("/api/kullanicilar").contentType(MediaType.APPLICATION_JSON).body(kotu)
60                    .exchange((req, res) -> {
61                        System.out.println("Geçersiz -> " + res.getStatusCode());
62                        String govde = new String(res.getBody().readAllBytes());
63                        System.out.println("  gövde (Boot varsayılan hata): "
64                                + govde.substring(0, Math.min(140, govde.length())) + "...");
65                        return null;
66                    });
67            System.out.println("==================================================");
68            System.out.println("@Valid sayesinde geçersiz veri controller'a HİÇ ulaşmadı; Boot 400 döndü.");
69            System.out.println("Ama varsayılan hata gövdesi kabadır -> Örnek 2'de @RestControllerAdvice ile düzelteceğiz.");
70        };
71    }
72}
canlı çalıştırma için ortam gerekir

Bu örnek Spring / Spring Boot (Gradle) ortamı gerektirir; tek dosya olarak java Ornek1.java ile çalışmaz. Orijinal portal bunu gömülü Tomcat / Spring context ile koşuyordu. Beklenen davranış yukarıdaki anlatım ve kodda açıklanmıştır.

Ornek2

ortam gerekir
Ornek2.java
1// Ornek2: @RestControllerAdvice ile global hata yönetimi — tutarlı, anlamlı JSON hata gövdeleri.
2// Çalıştırma: portal derleyip gömülü Tomcat ile başlatır.
3package com.egitim.springboot.hata;
4
5import jakarta.validation.Valid;
6import jakarta.validation.constraints.NotBlank;
7import org.springframework.boot.CommandLineRunner;
8import org.springframework.boot.SpringApplication;
9import org.springframework.boot.autoconfigure.SpringBootApplication;
10import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
11import org.springframework.context.annotation.Bean;
12import org.springframework.http.HttpStatus;
13import org.springframework.http.MediaType;
14import org.springframework.http.ResponseEntity;
15import org.springframework.web.bind.MethodArgumentNotValidException;
16import org.springframework.web.bind.annotation.*;
17import org.springframework.web.client.RestClient;
18
19import java.util.HashMap;
20import java.util.Map;
21
22@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
23public class Ornek2 {
24
25    public static void main(String[] args) { SpringApplication.run(Ornek2.class, args); }
26
27    static class UrunDto {
28        @NotBlank(message = "ad zorunludur")
29        public String ad;
30    }
31
32    // Özel iş istisnası.
33    static class KaynakBulunamadiException extends RuntimeException {
34        KaynakBulunamadiException(String m) { super(m); }
35    }
36
37    @RestController
38    @RequestMapping("/api/urunler")
39    static class UrunController {
40        @PostMapping
41        public String olustur(@Valid @RequestBody UrunDto dto) { return "oluşturuldu: " + dto.ad; }
42
43        @GetMapping("/{id}")
44        public String bul(@PathVariable Long id) {
45            if (id > 100) throw new KaynakBulunamadiException("Ürün bulunamadı: id=" + id);
46            return "Ürün #" + id;
47        }
48    }
49
50    // @RestControllerAdvice: TÜM controller'lardaki istisnalar burada tek yerde yakalanır.
51    @RestControllerAdvice
52    static class GlobalHataYonetimi {
53
54        // Doğrulama hatası -> 400 + alan bazlı temiz mesajlar.
55        @ExceptionHandler(MethodArgumentNotValidException.class)
56        public ResponseEntity<Map<String, Object>> dogrulama(MethodArgumentNotValidException ex) {
57            Map<String, String> alanlar = new HashMap<>();
58            ex.getBindingResult().getFieldErrors()
59                    .forEach(fe -> alanlar.put(fe.getField(), fe.getDefaultMessage()));
60            return ResponseEntity.badRequest().body(Map.of(
61                    "durum", 400, "hata", "Doğrulama hatası", "alanlar", alanlar));
62        }
63
64        // Kaynak bulunamadı -> 404 + anlamlı gövde.
65        @ExceptionHandler(KaynakBulunamadiException.class)
66        public ResponseEntity<Map<String, Object>> bulunamadi(KaynakBulunamadiException ex) {
67            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of(
68                    "durum", 404, "hata", "Bulunamadı", "mesaj", ex.getMessage()));
69        }
70    }
71
72    @Bean
73    CommandLineRunner selfTest() {
74        return args -> {
75            RestClient c = RestClient.create("http://localhost:8080");
76            System.out.println("\n================ GLOBAL HATA YÖNETİMİ ================");
77
78            c.post().uri("/api/urunler").contentType(MediaType.APPLICATION_JSON).body("{\"ad\":\"\"}")
79                    .exchange((req, res) -> {
80                        System.out.println("Geçersiz POST -> " + res.getStatusCode());
81                        System.out.println("  " + new String(res.getBody().readAllBytes()));
82                        return null;
83                    });
84
85            c.get().uri("/api/urunler/999").exchange((req, res) -> {
86                System.out.println("GET /999      -> " + res.getStatusCode());
87                System.out.println("  " + new String(res.getBody().readAllBytes()));
88                return null;
89            });
90            System.out.println("=====================================================");
91            System.out.println("Artık tüm hatalar TUTARLI, anlamlı JSON gövdeleriyle dönüyor (tek yerde yönetiliyor).");
92        };
93    }
94}
canlı çalıştırma için ortam gerekir

Bu örnek Spring / Spring Boot (Gradle) ortamı gerektirir; tek dosya olarak java Ornek2.java ile çalışmaz. Orijinal portal bunu gömülü Tomcat / Spring context ile koşuyordu. Beklenen davranış yukarıdaki anlatım ve kodda açıklanmıştır.

Ornek3

ortam gerekir
Ornek3.java
1// Ornek3: @ResponseStatus ile kısa yol ve ProblemDetail (RFC 7807) ile standart hata gövdesi.
2// Çalıştırma: portal derleyip gömülü Tomcat ile başlatır.
3package com.egitim.springboot.hata;
4
5import org.springframework.boot.CommandLineRunner;
6import org.springframework.boot.SpringApplication;
7import org.springframework.boot.autoconfigure.SpringBootApplication;
8import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
9import org.springframework.context.annotation.Bean;
10import org.springframework.http.HttpStatus;
11import org.springframework.http.ProblemDetail;
12import org.springframework.web.bind.annotation.ExceptionHandler;
13import org.springframework.web.bind.annotation.GetMapping;
14import org.springframework.web.bind.annotation.PathVariable;
15import org.springframework.web.bind.annotation.RestController;
16import org.springframework.web.bind.annotation.RestControllerAdvice;
17import org.springframework.web.client.RestClient;
18
19@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
20public class Ornek3 {
21
22    public static void main(String[] args) { SpringApplication.run(Ornek3.class, args); }
23
24    // YOL 1: @ResponseStatus — istisnaya doğrudan HTTP kodu iliştir (advice'a gerek yok).
25    @org.springframework.web.bind.annotation.ResponseStatus(HttpStatus.CONFLICT)
26    static class StokYetersizException extends RuntimeException {
27        StokYetersizException(String m) { super(m); }
28    }
29
30    // YOL 2 için iş istisnası (handler ProblemDetail döndürecek).
31    static class SiparisBulunamadiException extends RuntimeException {
32        SiparisBulunamadiException(String m) { super(m); }
33    }
34
35    @RestController
36    static class SiparisController {
37        @GetMapping("/api/siparis/{id}")
38        public String bul(@PathVariable Long id) {
39            if (id == 0) throw new StokYetersizException("Stok yetersiz");
40            if (id > 100) throw new SiparisBulunamadiException("Sipariş bulunamadı: id=" + id);
41            return "Sipariş #" + id;
42        }
43    }
44
45    @RestControllerAdvice
46    static class HataYonetimi {
47        // ProblemDetail: hata gövdeleri için standart (RFC 7807) biçim. Spring 6+ ile gelir.
48        @ExceptionHandler(SiparisBulunamadiException.class)
49        public ProblemDetail bulunamadi(SiparisBulunamadiException ex) {
50            ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
51            pd.setTitle("Sipariş bulunamadı");
52            pd.setProperty("kategori", "siparis");
53            return pd;
54        }
55    }
56
57    @Bean
58    CommandLineRunner selfTest() {
59        return args -> {
60            RestClient c = RestClient.create("http://localhost:8080");
61            System.out.println("\n================ @ResponseStatus + ProblemDetail ================");
62
63            c.get().uri("/api/siparis/0").exchange((req, res) -> {
64                System.out.println("GET /0 (@ResponseStatus) -> " + res.getStatusCode());
65                return null;
66            });
67
68            c.get().uri("/api/siparis/999").exchange((req, res) -> {
69                System.out.println("GET /999 (ProblemDetail) -> " + res.getStatusCode());
70                System.out.println("  " + new String(res.getBody().readAllBytes()));
71                return null;
72            });
73            System.out.println("=================================================================");
74            System.out.println("@ResponseStatus: hızlı, basit. ProblemDetail: standart, makine-okunur, zengin hata gövdesi.");
75        };
76    }
77}
canlı çalıştırma için ortam gerekir

Bu örnek Spring / Spring Boot (Gradle) ortamı gerektirir; tek dosya olarak java Ornek3.java ile çalışmaz. Orijinal portal bunu gömülü Tomcat / Spring context ile koşuyordu. Beklenen davranış yukarıdaki anlatım ve kodda açıklanmıştır.