İçeriğe geç

Spring Boot 3'te Exception Handling: Kapsamlı Rehber

T
Tolgahan
· · 12 dk okuma · 675 görüntülenme

Neden Exception Handling Bu Kadar Önemli?

Bir API geliştirdiğinizi hayal edin. Kullanıcı olmayan bir ID ile istek yapıyor, geçersiz bir JSON body gönderiyor ya da sunucuda beklenmedik bir hata oluşuyor. Bu durumların hepsinde istemciye anlamlı, tutarlı ve standart bir hata yanıtı dönmeniz gerekiyor. Aksi halde frontend geliştiricisi hata mesajlarını parse edemez, mobil uygulama crash eder, ve siz gece 3'te production loglarına bakarsınız.

Spring Boot 3, exception handling konusunda ciddi iyileştirmeler getirdi. Özellikle RFC 7807 ProblemDetail desteği ile artık hata yanıtlarınız uluslararası bir standarda uygun hale geliyor. Bu yazıda sıfırdan başlayıp production-ready bir exception handling altyapısı kuracağız.

Spring Boot'ta Exception Handling Temelleri

Spring Boot'ta exception handling mekanizması birkaç katmandan oluşur. En temelinde, Controller metodlarından fırlatılan exception'lar Spring'in DispatcherServlet tarafından yakalanır ve uygun bir handler'a yönlendirilir.

Üç temel yaklaşım vardır:

  • @ExceptionHandler: Tek bir controller sınıfı içinde hata yakalama

  • @ControllerAdvice: Tüm controller'lar için global hata yakalama

  • ResponseStatusException: Direkt olarak HTTP status code ile exception fırlatma

Basit bir örnekle başlayalım:

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

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
        User user = userService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", id));
        return ResponseEntity.ok(UserDto.from(user));
    }

    // Controller seviyesinde exception handler
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
            HttpStatus.NOT_FOUND.value(),
            ex.getMessage(),
            LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

Bu yaklaşım işe yarar ama her controller'da aynı handler'ı tekrar yazmak zorunda kalırsınız. İşte burada @ControllerAdvice devreye giriyor.

@ControllerAdvice ile Global Exception Handling

@ControllerAdvice, tüm controller'lar için merkezi bir exception handler tanımlamanızı sağlar. Spring konteynerinde bir bean olarak yaşar ve herhangi bir controller'dan fırlatılan exception'ları yakalar.

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, WebRequest request) {

        log.warn("Resource not found: {}", ex.getMessage());

        ErrorResponse error = ErrorResponse.builder()
            .status(HttpStatus.NOT_FOUND.value())
            .message(ex.getMessage())
            .path(request.getDescription(false).replace("uri=", ""))
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException ex, WebRequest request) {

        log.error("Business error: {}", ex.getMessage());

        ErrorResponse error = ErrorResponse.builder()
            .status(HttpStatus.UNPROCESSABLE_ENTITY.value())
            .message(ex.getMessage())
            .path(request.getDescription(false).replace("uri=", ""))
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(
            Exception ex, WebRequest request) {

        log.error("Unexpected error occurred", ex);

        ErrorResponse error = ErrorResponse.builder()
            .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
            .message("Beklenmeyen bir hata olustu. Lutfen daha sonra tekrar deneyin.")
            .path(request.getDescription(false).replace("uri=", ""))
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

Dikkat: @RestControllerAdvice, hem @ControllerAdvice hem de @ResponseBody annotation'larını içerir. Bu sayede dönüş değeri otomatik olarak JSON'a serialize edilir.

Custom Exception Sınıfları Tasarlamak

İyi bir exception hiyerarşisi, kodunuzu okunabilir ve bakımı kolay hale getirir. Base exception sınıfı oluşturup, iş mantığına göre alt sınıflar türetmenizi öneririm:

// Base exception
public abstract class BaseException extends RuntimeException {
    private final String errorCode;
    private final HttpStatus httpStatus;

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

    public String getErrorCode() { return errorCode; }
    public HttpStatus getHttpStatus() { return httpStatus; }
}

// Resource bulunamadı
public class ResourceNotFoundException extends BaseException {
    public ResourceNotFoundException(String resourceName, Object id) {
        super(
            String.format("%s bulunamadi: %s", resourceName, id),
            "RESOURCE_NOT_FOUND",
            HttpStatus.NOT_FOUND
        );
    }
}

// Is kuralı ihlali
public class BusinessException extends BaseException {
    public BusinessException(String message) {
        super(message, "BUSINESS_ERROR", HttpStatus.UNPROCESSABLE_ENTITY);
    }
}

// Yetkilendirme hatası
public class UnauthorizedException extends BaseException {
    public UnauthorizedException(String message) {
        super(message, "UNAUTHORIZED", HttpStatus.UNAUTHORIZED);
    }
}

// Duplicate kayıt
public class DuplicateResourceException extends BaseException {
    public DuplicateResourceException(String resourceName, String field, Object value) {
        super(
            String.format("%s zaten mevcut: %s = %s", resourceName, field, value),
            "DUPLICATE_RESOURCE",
            HttpStatus.CONFLICT
        );
    }
}

Bu hiyerarşi ile global exception handler'ınızı basitleştirebilirsiniz:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ErrorResponse> handleBaseException(
            BaseException ex, WebRequest request) {

        ErrorResponse error = ErrorResponse.builder()
            .status(ex.getHttpStatus().value())
            .errorCode(ex.getErrorCode())
            .message(ex.getMessage())
            .path(extractPath(request))
            .timestamp(LocalDateTime.now())
            .build();

        return ResponseEntity.status(ex.getHttpStatus()).body(error);
    }

    private String extractPath(WebRequest request) {
        return request.getDescription(false).replace("uri=", "");
    }
}

RFC 7807 ProblemDetail ile Standart Hata Yanıtları

Spring Boot 3, RFC 7807 standardını native olarak destekler. Bu standart, HTTP API hata yanıtları için evrensel bir format tanımlar. ProblemDetail sınıfı bu standardın Java implementasyonudur.

Bir ProblemDetail yanıtı şu alanları içerir:

  • type: Hata türünü tanımlayan URI

  • title: İnsan tarafından okunabilir hata başlığı

  • status: HTTP status code

  • detail: Hatanın detaylı açıklaması

  • instance: Hatanın oluştuğu endpoint

@RestControllerAdvice
public class ProblemDetailExceptionHandler {

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

    @ExceptionHandler(BusinessException.class)
    public ProblemDetail handleBusinessError(BusinessException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.UNPROCESSABLE_ENTITY,
            ex.getMessage()
        );
        problem.setTitle("Is Kurali Ihlali");
        problem.setType(URI.create("https://api.example.com/errors/business-error"));
        problem.setProperty("errorCode", ex.getErrorCode());
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

ProblemDetail kullanmak için application.properties dosyanıza şunu ekleyin:

spring.mvc.problemdetails.enabled=true

Bu ayar aktif olduğunda, Spring Boot varsayılan hata yanıtlarını da ProblemDetail formatında döner. Örneğin 404 yanıtı şu şekilde olur:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "User bulunamadi: 42",
  "instance": "/api/users/42",
  "errorCode": "RESOURCE_NOT_FOUND",
  "timestamp": "2026-02-08T10:30:00Z"
}

Validation Error Handling

Spring Boot'ta Bean Validation (@Valid, @NotBlank, @Size vb.) kullandığınızda, validation hataları MethodArgumentNotValidException olarak fırlatılır. Bu exception'ı yakalayıp kullanıcı dostu bir yanıt dönmeniz gerekir:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Validasyon hatasi"
    );
    problem.setTitle("Gecersiz Istek");
    problem.setType(URI.create("https://api.example.com/errors/validation"));

    // Alan bazlı hataları topla
    Map<String, String> fieldErrors = new HashMap<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
        fieldErrors.put(error.getField(), error.getDefaultMessage());
    }
    problem.setProperty("fieldErrors", fieldErrors);
    problem.setProperty("timestamp", Instant.now());

    return problem;
}

Bir DTO üzerinde validation annotation'ları şöyle kullanılır:

public record CreateUserRequest(
    @NotBlank(message = "Isim bos olamaz")
    @Size(min = 2, max = 100, message = "Isim 2 ile 100 karakter arasinda olmali")
    String name,

    @NotBlank(message = "Email bos olamaz")
    @Email(message = "Gecerli bir email adresi giriniz")
    String email,

    @NotBlank(message = "Sifre bos olamaz")
    @Size(min = 8, message = "Sifre en az 8 karakter olmali")
    String password
) {}

Controller'da @Valid annotation'ı ile validation tetiklenir:

@PostMapping
public ResponseEntity<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
    UserDto user = userService.createUser(request);
    return ResponseEntity.status(HttpStatus.CREATED).body(user);
}

Validation hatası durumunda API şuna benzer bir yanıt döner:

{
  "type": "https://api.example.com/errors/validation",
  "title": "Gecersiz Istek",
  "status": 400,
  "detail": "Validasyon hatasi",
  "fieldErrors": {
    "email": "Gecerli bir email adresi giriniz",
    "password": "Sifre en az 8 karakter olmali"
  }
}

RequestParam ve PathVariable Validation

@PathVariable ve @RequestParam validation'ları farklı bir exception üretir: ConstraintViolationException. Bunu da yakalamanız gerekir:

@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraintViolation(ConstraintViolationException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.BAD_REQUEST,
        "Parametre validasyon hatasi"
    );
    problem.setTitle("Gecersiz Parametre");

    Map<String, String> violations = new HashMap<>();
    for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
        String path = violation.getPropertyPath().toString();
        String field = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path;
        violations.put(field, violation.getMessage());
    }
    problem.setProperty("violations", violations);
    return problem;
}

Controller sınıfına @Validated eklemeyi unutmayın:

@RestController
@RequestMapping("/api/users")
@Validated  // Bu olmadan PathVariable/RequestParam validation calismaz
public class UserController {

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUser(
            @PathVariable @Min(value = 1, message = "ID pozitif olmali") Long id) {
        // ...
    }
}

Production Best Practices

Production ortamında exception handling için birkaç önemli kural vardır:

  1. Asla stack trace'i istemciye göndermeyin. Internal server error'larda generic bir mesaj dönün, detayları loglayın.

  2. Her exception'ı loglayın. Ama log seviyelerini doğru kullanın: business error'lar WARN, unexpected error'lar ERROR.

  3. Error code kullanın. String mesajlar dile bağımlıdır. "INSUFFICIENT_BALANCE" gibi kodlar frontend tarafında i18n'e çevrilebilir.

  4. Correlation ID ekleyin. Her request'e unique bir ID atayıp hata yanıtına dahil edin. Bu sayede loglardan hata takibi kolaylaşır.

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpectedError(Exception ex, HttpServletRequest request) {
    String correlationId = UUID.randomUUID().toString();
    log.error("Unexpected error [correlationId={}]: {}", correlationId, ex.getMessage(), ex);

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "Beklenmeyen bir hata olustu"
    );
    problem.setTitle("Sunucu Hatasi");
    problem.setProperty("correlationId", correlationId);
    problem.setProperty("timestamp", Instant.now());
    return problem;
}
  1. Exception handler sırasını kontrol edin. Spring, en spesifik exception handler'ı seçer. Exception.class handler'ı her zaman en sonda olmalı.

Tamamlanmış ErrorResponse Sınıfı

İşte production-ready bir ErrorResponse record sınıfı:

public record ErrorResponse(
    int status,
    String errorCode,
    String message,
    String path,
    LocalDateTime timestamp,
    Map<String, String> fieldErrors
) {
    public static ErrorResponseBuilder builder() {
        return new ErrorResponseBuilder();
    }

    public static class ErrorResponseBuilder {
        private int status;
        private String errorCode;
        private String message;
        private String path;
        private LocalDateTime timestamp;
        private Map<String, String> fieldErrors;

        public ErrorResponseBuilder status(int status) {
            this.status = status; return this;
        }
        public ErrorResponseBuilder errorCode(String errorCode) {
            this.errorCode = errorCode; return this;
        }
        public ErrorResponseBuilder message(String message) {
            this.message = message; return this;
        }
        public ErrorResponseBuilder path(String path) {
            this.path = path; return this;
        }
        public ErrorResponseBuilder timestamp(LocalDateTime timestamp) {
            this.timestamp = timestamp; return this;
        }
        public ErrorResponseBuilder fieldErrors(Map<String, String> fieldErrors) {
            this.fieldErrors = fieldErrors; return this;
        }
        public ErrorResponse build() {
            return new ErrorResponse(status, errorCode, message, path, timestamp, fieldErrors);
        }
    }
}

Özet

  • @ControllerAdvice ile tüm controller'lar için merkezi exception handling yapın

  • Custom exception hiyerarşisi oluşturun: BaseException altında iş mantığına uygun sınıflar

  • RFC 7807 ProblemDetail kullanarak standart hata yanıtları dönün

  • Validation hatalarını MethodArgumentNotValidException ve ConstraintViolationException ile yakalayın

  • Production'da stack trace göndermeyin, correlation ID kullanın, log seviyelerini doğru ayarlayın

  • Her zaman error code döndürün — frontend i18n desteği için kritik

Paylaş:
Son güncelleme: Jul 19, 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