İçeriğe geç

Validation ve Hata Yönetimi — İstemci Ne Görüyor?

Orta 8 dk Çok sık karşılaşılır

Önce şunu oku: Exception Yönetimi — Hata Yukarı Çıkarken Ne Kaybolur

30 saniyede özet

Alanın üstüne @Positive yazmak yetmez; @Valid demezsen kimse kontrol etmez. Hataları da tek bir yerde doğru koda çevir: bozuk istek 400, iş kuralı ihlali 409 gibi 4xx, kod hatası sade bir 500.

Kapıya “yalnızca davetliler” yazan bir tabela asmak, kapıda kimseyi durdurmaz. Birinin davetiyelere bakması gerekir.

  1. Bayt: Adet alanının üstüne @Positive yazdım. Artık eksi adet giremez!

  2. Sen: Veritabanında eksi adetli bir sipariş duruyor ama.

  3. Bayt: Nasıl olur? Kural orada yazıyordu!

  4. Bayt: Kapıya tabela asmak, kapıya bekçi koymak değildir.

Alanın üstünde @Positive yazıyor. Yine de veritabanında -1 adetlik bir sipariş var ve stok yetmediğinde istemci 500 alıyor. İkisi de aynı yerden geliyor: kısıtları ve hataları kimin ele aldığı belli değil.

Kontrol kapıda başlar

Bean ValidationKısıtları alanlara anotasyonla yazma standardı (Jakarta Validation): @NotBlank, @Positive, @Email. Kısıtları kendisi çalıştırmaz; @Valid ya da bir validator tetikler.Sözlükte gör → anotasyonları (@NotBlank, @Positive, @Email) bir kuralı yalnızca yazar, kendi başına kontrol etmez. Kapıda kontrolü yapacak birini ayrıca çağırman gerekir.

OrderRequest.quantity alanında @Positive var ama controller parametresi yalnızca @RequestBody OrderRequest. -1 gelirse ne olur? Cevabı göster

Sipariş -1 adetle kaydedilir. Kısıtı tetikleyen @Valid yok; anotasyon sadece bir süs olarak kalır.

Tabela kuralı söyler, bekçi uygular.
Adım adım oku
  1. OrderRequest'in adet alanında @Positive yazıyor: kapıda bir tabela.
  2. Controller parametresinde @Valid yoksa kimse tabelaya bakmaz; -1 içeri girer ve veritabanına kadar ulaşır.
  3. @Valid kapıya bir bekçi koyar: kural ihlal edilince istek kapıda 400 ile geri çevrilir.
  4. Her hata kendi koduna çevrilir: bozuk istek 400, çakışma 409; 500 yalnızca sunucunun kendi hatasıdır.
OrderController.java
@PostMapping("/orders")
ResponseEntity<OrderResponse> place(@Valid @RequestBody OrderRequest request) { … }

Kapıdaki görevli @Valid’dir: kurala uymayan isteği controller’a varmadan yakalar ve MethodArgumentNotValidException fırlatır.

Kafam karıştı, daha basit anlat

@Positive gibi etiketler kapıya asılmış kurallardır. Kapıda bekçi yoksa kimse onları okumaz. @Valid, o bekçiyi kapıya koymaktır.

Hızlı kontrolBaşlangıç

`OrderRequest` sınıfındaki `quantity` alanında `@Positive` var. Controller metodu `place(@RequestBody OrderRequest req)`. -1 gelirse ne olur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Her hatanın bir kodu var

DurumKodKimin sorunu
Bozuk JSON, geçersiz alan400İstemci düzeltmeli
Kaynak yok404İstemci başka bir şey istemeli
Stok yok, e-posta kayıtlı409İstek geçerli, durum uygun değil
NullPointerException500Bizim hatamız

“Stok yetmedi” bir sunucu arızası değildir, bu yüzden 500 dönülmez. 500 alan istemci yeniden dener, alarm sistemi de boş yere çalar.

Kafam karıştı, daha basit anlat

Hata kodu, kimin hatası olduğunu söyler. 4xx “senin isteğinde bir sorun var” demektir, 5xx “bizde bir şey bozuldu”. Stok yetmediyse bu bir arıza değildir.

Hızlı kontrolOrta

Sipariş servisi stok yetersizse `OutOfStockException` fırlatıyor ve istemci 500 alıyor. Doğru durum kodu hangisi?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Her durumu en uygun HTTP durum koduna yerleştir.

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

400 Bad Request

İstek biçim ya da içerik olarak hatalı

    404 Not Found

    Kaynak yok

      409 Conflict

      İstek geçerli ama mevcut durumla çelişiyor

        500 Internal Server Error

        Sunucunun kendi hatası

          Kendin gör

          Doğrulama ve hata yönetimi — istemci ne görüyor?

          Tohum 194483

          POST /orders

          { "sku": "SKU-42", "quantity": -1 }
          1. JSON → OrderRequestbekliyor
          2. @Validbekliyor
          3. OrderService.placebekliyor
          4. Exception handlingbekliyor
          Hız
          Adım 0

          Şu an ne oldu?

          POST /orders

          Gövde: { "sku": "SKU-42", "quantity": -1 }

          Görevler0/4

          • Geçersiz bir siparişi 201 ile kaydettiraçık

            İpucu

            Kısıt anotasyonları yerinde. Onları kim tetikliyor?

          • Stok yetersizliğini 409 Conflict olarak döndüraçık

            İpucu

            Boot varsayılanı bu istisnayı tanımıyor ve 500 dönüyor.

          • quantity = -1 için istemciye hangi alanın hatalı olduğunu söyleaçık

            İpucu

            Doğrulama açık olmalı ve hata gövdesinde alan listesi olmalı.

          • Bir hatanın iç ayrıntılarını istemciye sızdıraçık

            İpucu

            Beklenmeyen bir hata ve istisnayı olduğu gibi yazan bir handler.

          Olay günlüğü (0)

          Henüz olay yok. Oynat veya adımla.

          1. Varsayılanla oynat. 400 geldi, ama gövde hangi alanın hatalı olduğunu söylemiyor.
          2. Hata yönetimini advice yap. Aynı 400, şimdi alan listesiyle.
          3. @Valid’i kapat. 201 — ve -1 adetlik bir sipariş.
          4. “Stoktan fazla adet” seç. Varsayılanda 500, advice ile 409.
          5. “Kodda bir hata” ve ex.toString() seç. Paket ve alan adları istemcide.
          Hızlı kontrolOrta

          Bu endpoint'e geçersiz siparişler kaydediliyor ve hatalarda istemci her zaman 200 alıyor. Hangi satırlar sorunlu?

          Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

          Hatalı satıra dokun, sonra kontrol et.

          OrderController.java
          Java 21UTF-8LF

          Satır satır: tek bir advice

          Hangi hatanın hangi koda dönüşeceğine tek bir yerde, bir @RestControllerAdviceBütün controller'lardan çıkan istisnaları tek yerde HTTP cevabına çeviren sınıf. İçindeki @ExceptionHandler metotları hangi istisnanın hangi koda eşleneceğini söyler.Sözlükte gör → içinde karar verilir. Cevabın gövdesi için standart bir biçim olan ProblemDetailHTTP API hataları için RFC 9457 standardındaki gövde: type, title, status, detail, instance. Spring 6'da hazır bir sınıf olarak gelir.Sözlükte gör → (RFC 9457) kullanılır.

          Hangi istisna hangi cevaba

          ApiErrors.java
          1@RestControllerAdvice
          şu an çalışan satırclass ApiErrors extends ResponseEntityExceptionHandler {
          3
          4 @ExceptionHandler(OutOfStockException.class)
          5 ProblemDetail outOfStock(OutOfStockException ex) {
          6 return ProblemDetail.forStatusAndDetail(CONFLICT, ex.userMessage());
          7 }
          8
          9 @ExceptionHandler(Exception.class)
          10 ProblemDetail unexpected(Exception ex) {
          11 var ref = UUID.randomUUID().toString().substring(0, 8);
          12 log.error("unexpected error ref={}", ref, ex);
          13 return ProblemDetail.forStatusAndDetail(INTERNAL_SERVER_ERROR, "Beklenmeyen hata. Referans: " + ref);
          14 }
          15}

          Debug

          Adım 1/5

          Spring ResponseEntityExceptionHandler'dan türemek: bozuk JSON ve doğrulama gibi framework istisnaları zaten doğru 4xx ve ProblemDetail'e çevrilir.

          Java 21UTF-8LF2:1

          Sol/sağ ok tuşlarıyla da gezebilirsin.

          İstemciye anlamı, log’a ayrıntıyı ver. İkisini bir referans kimliği bağlar.

          Kafam karıştı, daha basit anlat

          Bütün hatalar tek bir masaya gelir ve orada karşılığı olan koda çevrilir. Kullanıcıya kısa bir açıklama gider, ayrıntılar log’da kalır.

          Hızlı kontrolOrta

          Spring 6'daki `ProblemDetail` nedir?

          Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

          Spring Boot 3'te `spring.mvc.problemdetails.enabled=true` ayarı ne yapar?

          Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

          Tuzaklar

          ex.getMessage()’ı cevaba yazmak. Hata mesajındaki sınıf, tablo ve alan adları kullanıcıya kadar gider; sistemin içi dışarıdan görünür.

          Her katmanda loglamak. Aynı hata service’te ve advice’ta loglanırsa bir hata iki alarm olur. Kenarda bir kez logla.

          Controller’da catch (Exception). Hatalar “her şey yolunda” (200) cevabına dönüşür ve asıl sebep kaybolur. Controller yalnızca her şeyin yolunda gittiği durumu anlatsın.

          @Valid’in iç içe nesnelere inmemesi. OrderRequest içindeki Address alanının kısıtları için o alanın da @Valid taşıması gerekir.

          Hızlı kontrolOrta

          Bir @ExceptionHandler(Exception.class) metodu `ex.getMessage()`'ı cevap gövdesine yazıyor. Bunun asıl riski ne?

          Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

          Aşağıdaki örnek bir bankanın havale API’sinden: talimat kaydında kurallar, IBAN kontrol hanesini doğrulayan özel bir kısıt, tek bir @RestControllerAdvice ve her hatada aynı biçimde ProblemDetail.

          Derinleş · Havale talimatı: doğrulama ve RFC 9457 hata cevabı 6 dosya · ~132 satır · ilk okumada atlayabilirsin
          Proje dosyaları

          src/main/java/com/bank/transfer/api/ TransferRequest.java İsteğin sözleşmesi: kurallar alanın yanında. Tutar BigDecimal, en fazla iki kuruş hanesi; toplu talimatta iç içe liste @Valid ile doğrulanır.

          src/main/java/com/bank/transfer/api/TransferRequest.java
          public record TransferRequest(
          @NotBlank @Iban String fromIban,
          @NotEmpty @Size(max = 100) List<@Valid Payee> payees, // one debit, up to 100 credits
          @Size(max = 140) String description) {
          public record Payee(
          @NotBlank @Iban String iban,
          @NotBlank @Size(max = 70) String name,
          @NotNull @Positive @Digits(integer = 12, fraction = 2) BigDecimal amount) {
          }
          }

          src/main/java/com/bank/transfer/api/ Iban.java Özel kısıt: IBAN'ın mod 97 kontrol hanesi (ISO 13616) tek yerde, her yerde aynı mesaj. Yanlış yazılmış bir IBAN bankaya hiç gitmez.

          src/main/java/com/bank/transfer/api/Iban.java
          @Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT, ElementType.TYPE_USE})
          @Retention(RetentionPolicy.RUNTIME)
          @Constraint(validatedBy = Iban.Validator.class)
          public @interface Iban {
          String message() default "Geçerli bir IBAN değil";
          Class<?>[] groups() default {};
          Class<? extends Payload>[] payload() default {};
          // ISO 13616: move the first four characters to the end, turn letters into
          // numbers (A=10 … Z=35) and the whole number mod 97 must be 1.
          class Validator implements ConstraintValidator<Iban, String> {
          private static final Pattern SHAPE = Pattern.compile("[A-Z]{2}\\d{2}[A-Z0-9]{11,30}");
          @Override
          public boolean isValid(String value, ConstraintValidatorContext context) {
          if (value == null) return true; // null is @NotBlank's job
          String iban = value.replace(" ", "").toUpperCase(Locale.ROOT);
          if (!SHAPE.matcher(iban).matches()) return false;
          if (iban.startsWith("TR") && iban.length() != 26) return false;
          String rearranged = iban.substring(4) + iban.substring(0, 4);
          int remainder = 0;
          for (char c : rearranged.toCharArray()) {
          int digit = Character.getNumericValue(c); // '7' -> 7, 'T' -> 29
          remainder = (digit > 9 ? remainder * 100 : remainder * 10) + digit;
          remainder %= 97; // stays small: no BigInteger needed
          }
          return remainder == 1;
          }
          }
          }

          src/main/java/com/bank/transfer/api/ TransferController.java Controller yalnızca @Valid der; hata yönetimi burada değil.

          src/main/java/com/bank/transfer/api/TransferController.java
          @RestController
          @RequestMapping("/transfers")
          class TransferController {
          private final TransferService transfers;
          TransferController(TransferService transfers) {
          this.transfers = transfers;
          }
          @PostMapping
          ResponseEntity<TransferView> create(@Valid @RequestBody TransferRequest request) {
          TransferView created = transfers.create(request);
          return ResponseEntity.created(URI.create("/transfers/" + created.id())).body(created);
          }
          // Constraints on a plain parameter: Spring 6.1+ validates them without @Validated
          // and reports failures as HandlerMethodValidationException.
          @GetMapping("/{id}")
          TransferView get(@PathVariable @Min(1) long id) {
          return transfers.get(id);
          }
          }

          src/main/java/com/bank/web/ ApiExceptionHandler.java Bütün hatalar tek yerde ve aynı biçimde: 400 alan hataları, 404, 409 yetersiz bakiye, 500 iç ayrıntısız.

          src/main/java/com/bank/web/ApiExceptionHandler.java
          @RestControllerAdvice
          class ApiExceptionHandler extends ResponseEntityExceptionHandler {
          private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
          // 400: which field, which rule. The body is already a ProblemDetail; we add the details.
          @Override
          protected ResponseEntity<Object> handleMethodArgumentNotValid(
          MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
          ProblemDetail body = ex.getBody();
          body.setTitle("Geçersiz talimat");
          body.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
          .map(error -> Map.of("field", error.getField(), "message", String.valueOf(error.getDefaultMessage())))
          .toList());
          return handleExceptionInternal(ex, body, headers, status, request);
          }
          @ExceptionHandler(TransferNotFoundException.class)
          ProblemDetail notFound(TransferNotFoundException ex) {
          ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
          problem.setType(URI.create("https://docs.bank.example/problems/transfer-not-found"));
          return problem;
          }
          // A business rule, not a malformed request. Never echo the balance back:
          // the response may end up in a log or on a shared screen.
          @ExceptionHandler(InsufficientFundsException.class)
          ProblemDetail insufficientFunds(InsufficientFundsException ex) {
          ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Bakiye bu işlem için yetersiz.");
          problem.setType(URI.create("https://docs.bank.example/problems/insufficient-funds"));
          return problem;
          }
          // 500: log everything, reveal nothing.
          @ExceptionHandler(Exception.class)
          ProblemDetail unexpected(Exception ex) {
          log.error("unhandled", ex);
          return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Beklenmeyen bir hata oluştu.");
          }
          }

          src/main/resources/ application.yml ProblemDetail'i açan ayar.

          src/main/resources/application.yml
          spring:
          mvc:
          problemdetails:
          enabled: true # Spring's own errors (405, 415, …) also answer as application/problem+json

          src/test/java/com/bank/transfer/api/ TransferControllerValidationTest.java Sözleşmenin testi: geçersiz talimat hangi alanlarla 400 dönüyor?

          src/test/java/com/bank/transfer/api/TransferControllerValidationTest.java
          @WebMvcTest(TransferController.class)
          @Import(ApiExceptionHandler.class)
          class TransferControllerValidationTest {
          @Autowired MockMvc mvc;
          @MockitoBean TransferService transfers;
          @Test
          void rejectsATypoInTheIbanAndAThirdDecimalWithFieldErrors() throws Exception {
          // The last digit of a valid IBAN changed from 6 to 7: the check digits catch it.
          mvc.perform(post("/transfers").contentType(APPLICATION_JSON).content("""
          {"fromIban": "TR330006100519786457841326",
          "payees": [{"iban": "TR330006100519786457841327", "name": "Ayşe Yılmaz", "amount": 10.005}]}
          """))
          .andExpect(status().isBadRequest())
          .andExpect(content().contentType(APPLICATION_PROBLEM_JSON))
          .andExpect(jsonPath("$.errors[*].field",
          containsInAnyOrder("payees[0].iban", "payees[0].amount")));
          verifyNoInteractions(transfers); // invalid input never reaches the service
          }
          }

          Kendini sına

          Şimşek turu1/5

          @Positive gibi anotasyonlar kendi başlarına kontrol yapar.

          Soru 1/2İleri

          Hata yönetimini tek bir @RestControllerAdvice'ta topladın. Hangi hatalar ERROR seviyesinde loglanmalı?

          Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

          Aklında kalacak üç şey

          1. 1 Kısıt anotasyonları kendiliğinden çalışmaz. @Valid @RequestBody, isteği controller'a ulaşmadan kontrol eder.
          2. 2 İş kuralı ihlali bir sunucu hatası değildir. Stok yetmiyorsa cevap 500 değil, 409 gibi bir 4xx olmalıdır.
          3. 3 Kullanıcıya ne olduğunu, log'a ayrıntıyı ver. Hata mesajını olduğu gibi cevaba yazmak sistemin içini dışarı gösterir.
          Sonraki kapı save() çağırmadın ama değişiklik yine de veritabanına yazıldı. Kim kaydetti? Persistence Context — Hibernate'in Hafızası · 9 dk

          5 kart sonraki derste seni bekliyor

          0/5 kart bu dersten toplandı