İçeriğe geç

Spring Boot'ta Exception Handling: @ControllerAdvice ile Profesyonel Hata Yönetimi

T
Tolgahan
· · 13 dk okuma · 85 görüntülenme

Spring Boot'ta Exception Handling: @ControllerAdvice ile Profesyonel Hata Yönetimi

Bir REST API yazıyorsun. Kullanıcı olmayan bir ID ile istek atıyor — ve karşısına Spring Boot'un varsayılan Whitelabel Error Page'i ya da 500 Internal Server Error çıkıyor. Hata mesajı belirsiz, HTTP status kodu yanlış, response body'de stack trace var. Kullanıcı ne olduğunu anlamıyor, frontend geliştiricisi hangi hatayı nasıl handle edeceğini bilmiyor, ve sen production'da hangi hatanın nerede oluştuğunu loglardan çözmeye çalışıyorsun.

Tanıdık geldi mi? Hemen her Spring Boot projesinde bu senaryo yaşanır — çünkü hata yönetimi genelde "sonra hallederiz" deyip atlanan bir konu. Ama gerçek şu: iyi bir hata yönetimi, iyi bir API'nin olmazsa olmazı. Kullanıcıya anlamlı mesajlar vermek, doğru HTTP status kodlarını döndürmek ve hataları merkezi bir noktadan yönetmek — bunlar profesyonel bir uygulamanın temel taşları.

Bu yazıda Spring Boot'ta exception handling'in nasıl doğru yapılacağını sıfırdan inşa edeceğiz. @ControllerAdvice ve @ExceptionHandler ile merkezi hata yönetimi kuracak, kendi custom exception sınıflarımızı yazacak, RFC 7807 Problem Details standardını uygulayacak ve production-ready bir error response yapısı oluşturacağız.

Neden Merkezi Hata Yönetimi?

Merkezi hata yönetimi olmadan ne olur? Her controller'da ayrı ayrı try-catch blokları yazarsın. Aynı hata tipi için farklı controller'larda farklı response formatları dönersin. Bir yerde 404 dönerken, aynı durum için başka yerde 500 döner. Kod tekrarı artar, tutarsızlık büyür, bakım kabusa döner.

🎯 Analoji: Merkezi hata yönetimini bir hastanenin acil servisi gibi düşün. Hastalar (hatalar) farklı bölümlerden (controller'lardan) gelebilir — ama hepsi aynı acil serviste (ControllerAdvice), aynı protokollerle karşılanır. Her bölümün kendi mini acil servisi olsaydı, her yerde farklı standartlar uygulanırdı.

Spring Boot bu problemi @ControllerAdvice ile çözer. Bu anotasyon, tüm controller'lar için geçerli olan merkezi bir hata yakalama mekanizması sunar. Herhangi bir controller'da exception fırlatıldığında, Spring bu exception'ı ilgili handler'a yönlendirir — senin try-catch yazmanı bile gerektirmez.

Temel Mekanizma: @ExceptionHandler

Önce en basit haliyle başlayalım. @ExceptionHandler, belirli bir exception türü fırlatıldığında çalışacak bir metot tanımlar. Bunu tek bir controller içinde de kullanabilirsin:

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        // Kullanıcı bulunamazsa exception fırlat
        return userService.findById(id)
                .orElseThrow(() -> new RuntimeException("Kullanıcı bulunamadı: " + id));
    }

    // Bu sadece UserController içindeki hatalar için çalışır
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<String> handleRuntimeException(RuntimeException ex) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ex.getMessage());
    }
}

Bu çalışır — ama sorunlu. @ExceptionHandler sadece tanımlandığı controller'da geçerli. 20 controller'ın varsa, 20 yerde aynı kodu tekrarlamak zorundasın. Üstelik RuntimeException gibi geniş bir exception türünü yakalamak tehlikeli: gerçek bug'ları da yutabilirsin.

İşte burada @ControllerAdvice devreye girer.

@ControllerAdvice: Merkezi Hata Yönetim Merkezi

@ControllerAdvice, exception handler'larını tüm controller'lar için geçerli kılan bir sınıf seviyesi anotasyondur. REST API'lerde genellikle @RestControllerAdvice kullanılır — bu, @ControllerAdvice + @ResponseBody kombinasyonudur ve dönüş değerini otomatik olarak JSON'a çevirir.

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Tüm controller'lardaki ResourceNotFoundException'ları yakalar
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, WebRequest request) {

        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                LocalDateTime.now()
        );

        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // Validation hatalarını yakalar
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex) {

        List<String> details = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .toList();

        ErrorResponse error = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Doğrulama hatası",
                LocalDateTime.now(),
                details
        );

        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // Beklenmeyen tüm hataları yakalar — son savunma hattı
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex) {

        // Production'da stack trace'i kullanıcıya gösterme!
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Beklenmeyen bir hata oluştu",
                LocalDateTime.now()
        );

        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Bu GlobalExceptionHandler sınıfı, uygulamadaki tüm controller'lardan fırlayan exception'ları yakalar. Artık hiçbir controller'da try-catch yazmana gerek yok. Bir controller'da ResourceNotFoundException fırlattığında, Spring onu otomatik olarak bu handler'a yönlendirir ve 404 döner.

Burada dikkat edilmesi gereken önemli nokta: Spring, exception handler'ları en spesifikten en genele doğru eşleştirir. ResourceNotFoundException hem ResourceNotFoundException handler'ına hem de Exception handler'ına uyar — ama Spring en spesifik olanı seçer. Bu yüzden Exception.class handler'ını son savunma hattı olarak kullanıyoruz: sadece başka hiçbir handler eşleşmediğinde devreye girer.

Custom Exception Sınıfları Tasarlamak

RuntimeException veya Exception fırlatmak yerine, kendi exception sınıflarını oluşturmalısın. Bu hem kodun okunabilirliğini artırır, hem de her hata tipine farklı HTTP status kodu atamayı kolaylaştırır.

// Temel exception — tüm custom exception'lar bundan türer
public abstract class BaseException extends RuntimeException {

    private final HttpStatus status;
    private final String errorCode;

    protected BaseException(String message, HttpStatus status, String errorCode) {
        super(message);
        this.status = status;
        this.errorCode = errorCode;
    }

    public HttpStatus getStatus() {
        return status;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

// Kaynak bulunamadığında — 404
public class ResourceNotFoundException extends BaseException {

    public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
        super(
            String.format("%s bulunamadı: %s = '%s'", resourceName, fieldName, fieldValue),
            HttpStatus.NOT_FOUND,
            "RESOURCE_NOT_FOUND"
        );
    }
}

// İş kuralı ihlali — 409 Conflict
public class BusinessRuleException extends BaseException {

    public BusinessRuleException(String message) {
        super(message, HttpStatus.CONFLICT, "BUSINESS_RULE_VIOLATION");
    }
}

// Yetki hatası — 403
public class AccessDeniedException extends BaseException {

    public AccessDeniedException(String message) {
        super(message, HttpStatus.FORBIDDEN, "ACCESS_DENIED");
    }
}

Şimdi bu exception'ları service katmanında kullanmak çok temiz:

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User findById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
        // "User bulunamadı: id = '42'" mesajını otomatik üretir
    }

    public void deleteUser(Long id, User currentUser) {
        User user = findById(id);

        if (!user.getId().equals(currentUser.getId()) && !currentUser.isAdmin()) {
            throw new AccessDeniedException("Başka bir kullanıcıyı silme yetkiniz yok");
        }

        if (user.hasActiveOrders()) {
            throw new BusinessRuleException(
                "Aktif siparişi olan kullanıcı silinemez. Önce siparişleri tamamlayın."
            );
        }

        userRepository.delete(user);
    }
}

Koda bak: findById metodu Optional.orElseThrow() kullanarak boş sonuç durumunda ResourceNotFoundException fırlatıyor. deleteUser ise iş kurallarını kontrol edip, uygun exception'ları fırlatıyor. Controller'da tek bir try-catch yok — temiz, okunabilir, bakımı kolay.

Standart Error Response Yapısı

API'den dönen hata response'larının tutarlı bir yapısı olmalı. Frontend geliştiricisi her hatada aynı JSON yapısını bekleyebilmeli. İşte production-ready bir ErrorResponse sınıfı:

public class ErrorResponse {

    private int status;                   // HTTP status kodu: 404, 400, 500...
    private String message;               // İnsan okunabilir hata mesajı
    private String errorCode;             // Makine okunabilir hata kodu: RESOURCE_NOT_FOUND
    private LocalDateTime timestamp;      // Hatanın oluştuğu zaman
    private String path;                  // İstek URL'i
    private List<String> details;         // Ek detaylar (validation hataları vb.)

    // Basit constructor — tek mesajlı hatalar için
    public ErrorResponse(int status, String message, LocalDateTime timestamp) {
        this.status = status;
        this.message = message;
        this.timestamp = timestamp;
    }

    // Detaylı constructor — validation hataları için
    public ErrorResponse(int status, String message, LocalDateTime timestamp,
                         List<String> details) {
        this(status, message, timestamp);
        this.details = details;
    }

    // Getter'lar
    public int getStatus() { return status; }
    public String getMessage() { return message; }
    public String getErrorCode() { return errorCode; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public String getPath() { return path; }
    public List<String> getDetails() { return details; }

    // Setter'lar
    public void setErrorCode(String errorCode) { this.errorCode = errorCode; }
    public void setPath(String path) { this.path = path; }
}

Bu yapıyla API'den dönen bir 404 hatası şöyle görünür:

{
    "status": 404,
    "message": "User bulunamadı: id = '42'",
    "errorCode": "RESOURCE_NOT_FOUND",
    "timestamp": "2026-03-04T14:30:00",
    "path": "/api/users/42",
    "details": null
}

Bir validation hatası ise şöyle:

{
    "status": 400,
    "message": "Doğrulama hatası",
    "errorCode": "VALIDATION_ERROR",
    "timestamp": "2026-03-04T14:31:00",
    "path": "/api/users",
    "details": [
        "email: Geçerli bir email adresi giriniz",
        "name: İsim boş olamaz",
        "age: 0'dan büyük olmalıdır"
    ]
}

Frontend geliştiricisi her zaman aynı yapıyı bekler: status ile hatanın türünü, message ile kullanıcıya göstereceği mesajı, errorCode ile programatik olarak hangi hatayı handle edeceğini, details ile varsa ek bilgileri alır.

GlobalExceptionHandler'ın Tam Hali

Şimdi custom exception'ları ve standart response yapısını birleştiren tam bir GlobalExceptionHandler yazalım:

@RestControllerAdvice
@Slf4j  // Lombok loglama — alternatif: private static final Logger log = ...
public class GlobalExceptionHandler {

    // Tüm custom exception'ları tek handler ile yakala
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ErrorResponse> handleBaseException(
            BaseException ex, WebRequest request) {

        log.warn("İş hatası: {} — {}", ex.getErrorCode(), ex.getMessage());

        ErrorResponse error = new ErrorResponse(
                ex.getStatus().value(),
                ex.getMessage(),
                LocalDateTime.now()
        );
        error.setErrorCode(ex.getErrorCode());
        error.setPath(extractPath(request));

        return new ResponseEntity<>(error, ex.getStatus());
    }

    // Bean validation hataları — @Valid ile tetiklenir
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex, WebRequest request) {

        List<String> details = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(err -> err.getField() + ": " + err.getDefaultMessage())
                .toList();

        log.warn("Validation hatası: {}", details);

        ErrorResponse error = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Doğrulama hatası",
                LocalDateTime.now(),
                details
        );
        error.setErrorCode("VALIDATION_ERROR");
        error.setPath(extractPath(request));

        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // JSON parse hataları — bozuk request body
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleBadJson(
            HttpMessageNotReadableException ex, WebRequest request) {

        log.warn("Bozuk JSON: {}", ex.getMessage());

        ErrorResponse error = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "İstek gövdesi okunamadı. JSON formatını kontrol edin.",
                LocalDateTime.now()
        );
        error.setErrorCode("MALFORMED_JSON");
        error.setPath(extractPath(request));

        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // Desteklenmeyen HTTP metodu — GET endpoint'ine POST atılması gibi
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotAllowed(
            HttpRequestMethodNotSupportedException ex, WebRequest request) {

        ErrorResponse error = new ErrorResponse(
                HttpStatus.METHOD_NOT_ALLOWED.value(),
                "HTTP metodu desteklenmiyor: " + ex.getMethod(),
                LocalDateTime.now()
        );
        error.setErrorCode("METHOD_NOT_ALLOWED");
        error.setPath(extractPath(request));

        return new ResponseEntity<>(error, HttpStatus.METHOD_NOT_ALLOWED);
    }

    // Son savunma hattı — beklenmeyen tüm hatalar
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGeneric(
            Exception ex, WebRequest request) {

        // Beklenmeyen hataları ERROR seviyesinde logla
        log.error("Beklenmeyen hata: ", ex);

        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
                LocalDateTime.now()
        );
        error.setErrorCode("INTERNAL_ERROR");
        error.setPath(extractPath(request));

        // Stack trace'i kullanıcıya ASLA gösterme
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    // Yardımcı metot: request'ten path bilgisini çıkar
    private String extractPath(WebRequest request) {
        return request.getDescription(false).replace("uri=", "");
    }
}

Bu handler beş farklı senaryoyu ele alıyor:

  1. BaseException: Senin fırlattığın tüm custom exception'lar — ResourceNotFoundException, BusinessRuleException, AccessDeniedException gibi. Her biri kendi HTTP status kodunu ve error code'unu taşıyor.

  2. MethodArgumentNotValidException: @Valid anotasyonu ile yapılan bean validation başarısız olduğunda tetiklenir. Her bir field hatası ayrı ayrı listelenir.

  3. HttpMessageNotReadableException: Bozuk JSON gönderildiğinde — süslü parantez eksik, veri tipi uyumsuz gibi durumlar.

  4. HttpRequestMethodNotSupportedException: Yanlış HTTP metodu kullanıldığında — GET endpoint'ine POST atılması gibi.

  5. Exception: Hiçbir handler eşleşmezse devreye giren son savunma hattı. Bu asla tetiklenmemeli — tetikleniyorsa bir bug var demektir.

💡 İpucu: Loglama stratejisine dikkat et. İş hataları (kullanıcı bulunamadı, validation hatası) warn seviyesinde, beklenmeyen hatalar ise error seviyesinde loglanır. Bu ayrım, production'da gerçek bug'ları hızlıca bulmanı sağlar.

Yaygın Hatalar — Herkesin Düştüğü Tuzaklar

1. Stack Trace'i Response'a Koymak

Bu en tehlikeli hata. Production'da stack trace döndürmek, saldırganlara uygulamanın iç yapısını, kullandığın kütüphaneleri ve versiyonları ifşa eder.

// ❌ YANLIŞ — stack trace kullanıcıya gidiyor
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleError(Exception ex) {
    Map<String, Object> body = new HashMap<>();
    body.put("error", ex.getMessage());
    body.put("stackTrace", Arrays.toString(ex.getStackTrace())); // TEHLİKE!
    return ResponseEntity.status(500).body(body);
}

// ✅ DOĞRU — genel mesaj dön, detayı logla
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleError(Exception ex) {
    log.error("Beklenmeyen hata: ", ex);  // Detay loglarda kalır
    ErrorResponse error = new ErrorResponse(
            500, "Beklenmeyen bir hata oluştu", LocalDateTime.now()
    );
    return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}

2. Tüm Exception'ları Aynı Kefeye Koymak

Tek bir @ExceptionHandler(Exception.class) ile her şeyi yakalamak, farklı hataları ayırt edememen demektir.

// ❌ YANLIŞ — her şeye 500 dönüyor
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handleAll(Exception ex) {
    return ResponseEntity.status(500).body(ex.getMessage());
}

// ✅ DOĞRU — her hata tipine uygun status kodu
// ResourceNotFoundException → 404
// ValidationException → 400
// BusinessRuleException → 409
// Exception → 500 (son çare)

Kullanıcının olmayan bir kaydı sorgulaması (404) ile sunucudaki bir bug (500) aynı şey değil. Frontend'in bunu ayırt edebilmesi lazım.

3. Exception'ları Yutmak

Exception'ı yakalayıp loglamamak, debugging'i imkansız hale getirir:

// ❌ YANLIŞ — exception yutuluyor, loglanmıyor
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleError(Exception ex) {
    return ResponseEntity.status(500)
            .body(new ErrorResponse(500, "Hata oluştu", LocalDateTime.now()));
    // ex nereye gitti? Loglarda iz yok!
}

// ✅ DOĞRU — her zaman logla
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleError(Exception ex) {
    log.error("Beklenmeyen hata: ", ex);  // Stack trace loglarda
    return ResponseEntity.status(500)
            .body(new ErrorResponse(500, "Hata oluştu", LocalDateTime.now()));
}

4. Service Katmanında HTTP Kararı Almak

Service katmanında ResponseEntity döndürmek, katman mimarisini bozar. Service katmanı HTTP'den habersiz olmalı.

// ❌ YANLIŞ — service HTTP'yi biliyor
@Service
public class UserService {
    public ResponseEntity<User> findById(Long id) {
        // Service neden ResponseEntity dönüyor?
        return userRepository.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

// ✅ DOĞRU — service exception fırlatır, handler HTTP'yi belirler
@Service
public class UserService {
    public User findById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
    }
}

Service katmanı sadece iş mantığını bilir ve hata durumunda exception fırlatır. HTTP status koduna kim karar verir? @ControllerAdvice. Bu ayrım, aynı service'i REST controller'dan da, GraphQL resolver'dan da, message listener'dan da çağırabilmeni sağlar.

RFC 7807 Problem Details — Endüstri Standardı

Spring Boot 3 ile birlikte RFC 7807 (Problem Details for HTTP APIs) desteği geldi. Bu standart, hata response'ları için evrensel bir format tanımlar. Kendi ErrorResponse sınıfını yazmak yerine, Spring'in ProblemDetail sınıfını kullanabilirsin:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFound(ResourceNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND,
                ex.getMessage()
        );
        problem.setTitle("Kaynak Bulunamadı");
        problem.setType(URI.create("https://api.example.com/errors/not-found"));
        problem.setProperty("errorCode", ex.getErrorCode());
        problem.setProperty("timestamp", LocalDateTime.now());

        return problem;
    }
}

Bu handler şöyle bir response döner:

{
    "type": "https://api.example.com/errors/not-found",
    "title": "Kaynak Bulunamadı",
    "status": 404,
    "detail": "User bulunamadı: id = '42'",
    "instance": "/api/users/42",
    "errorCode": "RESOURCE_NOT_FOUND",
    "timestamp": "2026-03-04T14:30:00"
}

ProblemDetail kullanmanın avantajı: type, title, status, detail, instance gibi alanlar standart — her API tüketicisi bu yapıyı tanır. setProperty() ile kendi custom alanlarını da ekleyebilirsin. Eğer yeni bir proje başlıyorsan, kendi ErrorResponse sınıfın yerine ProblemDetail kullanmanı öneririm.

⚠️ Dikkat: ProblemDetail kullanıyorsan, application.properties dosyasına spring.mvc.problemdetails.enabled=true eklemeyi unutma. Bu ayar olmadan Spring, ProblemDetail dönsen bile onu düz JSON olarak serialize eder ve instance alanını otomatik doldurmaz.

Best Practices — Profesyonel İpuçları

1. Exception hiyerarşisi kur. Tüm custom exception'ların ortak bir BaseException'dan türemesi, handler'da tek bir @ExceptionHandler(BaseException.class) ile hepsini yakalamana olanak tanır. Her exception kendi HTTP status kodunu ve error code'unu taşır.

2. Error code'ları standartlaştır. RESOURCE_NOT_FOUND, VALIDATION_ERROR, BUSINESS_RULE_VIOLATION gibi sabit string'ler kullan. Frontend bu kodlara göre UI kararı verir — mesela RESOURCE_NOT_FOUND geldiğinde "Aradığınız kayıt bulunamadı" sayfası gösterir.

3. Loglama stratejini belirle. 4xx hataları warn, 5xx hataları error seviyesinde logla. 4xx, kullanıcının hatasıdır (yanlış istek, eksik parametre) — bunlar alarm tetiklememeli. 5xx ise senin hatandır (bug, altyapı sorunu) — bunlar anında alarm tetiklemeli.

4. Hata mesajlarında hassas bilgi verme. Veritabanı bağlantı string'i, tablo adları, iç servis URL'leri gibi bilgileri kullanıcıya döndürme. Genel bir mesaj yaz, detayı loglara bırak.

5. Validation ve business exception'ları ayır. @Valid ile yapılan field-level doğrulama başka şey, iş kuralı kontrolü başka şey. Mesela "email formatı yanlış" bir validation hatasıdır (400), "bu email adresi zaten kayıtlı" bir business rule ihlalidir (409).

6. Test yaz. Exception handler'larını @WebMvcTest ile test et. Doğru status kodu, doğru error code, doğru mesaj dönüyor mu? Bu testler regression'ları yakalar:

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void olmayan_kullanici_404_donmeli() throws Exception {
        // Service'in exception fırlatmasını ayarla
        when(userService.findById(999L))
                .thenThrow(new ResourceNotFoundException("User", "id", 999L));

        // İsteği at ve sonucu doğrula
        mockMvc.perform(get("/api/users/999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.errorCode").value("RESOURCE_NOT_FOUND"))
                .andExpect(jsonPath("$.message").value("User bulunamadı: id = '999'"));
    }

    @Test
    void gecersiz_body_400_donmeli() throws Exception {
        // Email alanı eksik olan JSON
        String invalidJson = """
                {
                    "name": "",
                    "email": "gecersiz-email"
                }
                """;

        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(invalidJson))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errorCode").value("VALIDATION_ERROR"))
                .andExpect(jsonPath("$.details").isArray());
    }
}

Gerçek Dünya Senaryosu: E-Ticaret Sipariş API'si

Tüm kavramları birleştiren gerçekçi bir senaryo kuralım. Bir e-ticaret uygulamasında sipariş oluşturma endpoint'i düşün:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }
}

@Service
public class OrderService {

    private final UserRepository userRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    // Constructor injection...

    public Order createOrder(CreateOrderRequest request) {
        // 1. Kullanıcıyı bul — yoksa 404
        User user = userRepository.findById(request.getUserId())
                .orElseThrow(() -> new ResourceNotFoundException(
                        "User", "id", request.getUserId()));

        // 2. Ürünü bul — yoksa 404
        Product product = productRepository.findById(request.getProductId())
                .orElseThrow(() -> new ResourceNotFoundException(
                        "Product", "id", request.getProductId()));

        // 3. Stok kontrolü — yetersizse 409
        if (product.getStock() < request.getQuantity()) {
            throw new BusinessRuleException(
                    String.format("Yetersiz stok. İstenen: %d, Mevcut: %d",
                            request.getQuantity(), product.getStock()));
        }

        // 4. Kullanıcının borç limiti kontrolü — aşılırsa 409
        double totalPrice = product.getPrice() * request.getQuantity();
        if (user.getDebt() + totalPrice > user.getCreditLimit()) {
            throw new BusinessRuleException(
                    "Kredi limiti aşılıyor. Mevcut borç: " + user.getDebt()
                    + ", Limit: " + user.getCreditLimit());
        }

        // 5. Her şey tamam — siparişi oluştur
        Order order = new Order();
        order.setUser(user);
        order.setProduct(product);
        order.setQuantity(request.getQuantity());
        order.setTotalPrice(totalPrice);
        order.setStatus(OrderStatus.CREATED);

        product.setStock(product.getStock() - request.getQuantity());
        productRepository.save(product);

        return orderRepository.save(order);
    }
}

Bu kodda dört farklı hata senaryosu var:

  • Validation hatası (400): @Valid ile CreateOrderRequest'teki @NotNull, @Min gibi anotasyonlar tetiklenir.

  • Kullanıcı bulunamadı (404): ResourceNotFoundException fırlatılır.

  • Ürün bulunamadı (404): Aynı şekilde ResourceNotFoundException.

  • İş kuralı ihlali (409): Stok yetersiz veya kredi limiti aşılmış — BusinessRuleException.

Ve hiçbirinde try-catch yok. Controller ince, service temiz, hata yönetimi merkezi. GlobalExceptionHandler her birini yakalar, doğru HTTP status kodunu ve anlamlı mesajı döner. Frontend geliştiricisi errorCode'a bakarak kullanıcıya uygun mesajı gösterir.

Özet

  • @ControllerAdvice ile tüm hataları merkezi bir noktadan yönet — her controller'da ayrı ayrı try-catch yazma

  • Custom exception hiyerarşisi kur — ResourceNotFoundException, BusinessRuleException gibi anlamlı sınıflar fırlat, çıplak RuntimeException fırlatma

  • Standart error response yapısı kullan — status, message, errorCode, timestamp, path alanlarıyla tutarlı JSON döndür

  • Stack trace'i asla kullanıcıya gösterme — güvenlik açığı yaratır, detayı logla

  • 4xx ve 5xx hatalarını ayır — kullanıcı hatası (warn) ve sistem hatası (error) farklı loglama seviyelerinde olmalı

  • Spring Boot 3+ kullanıyorsan RFC 7807 ProblemDetail'i değerlendir — endüstri standardı, her API tüketicisi bu formatı tanır

  • Exception handler'larını test et@WebMvcTest ile doğru status, doğru mesaj, doğru error code döndüğünü garanti et

Paylaş:
Son güncelleme: Apr 16, 2026

Yorumlar

Giriş yapın ve yorum bırakın.

Henüz yorum yok

Düşüncelerinizi paylaşan ilk siz olun!

Bu yazıyı beğendiniz mi?

Bültene abone olun ve yeni yazılardan ilk siz haberdar olun. Spam yok, söz.

İlgili Yazılar