@ControllerAdvice ile Global Exception Handling
Bir e-ticaret sitesi düşünün. Kullanıcı olmayan bir ürünü arıyor — 404. Geçersiz kredi kartı bilgisi giriyor — 400. Veritabanı bağlantısı kopuyor — 500. Her biri farklı controller'da, farklı endpoint'te gerçekleşiyor. Peki bu hataların hepsini her controller'da ayrı ayrı mı yöneteceksiniz?
Cevap: Kesinlikle hayır.
Spring Boot'un @ControllerAdvice mekanizması, tüm uygulamadaki exception'ları tek bir merkezi noktada yakalamanızı sağlar. Tıpkı bir binanın güvenlik merkezinin tüm katlardaki alarm sinyallerini tek bir odadan izlemesi gibi — her kata ayrı bir güvenlik görevlisi koymak yerine, merkezi bir kontrol odası kurarsınız.
Bu derste @ControllerAdvice ve @RestControllerAdvice ile profesyonel düzeyde hata yönetimi kurmayı, farklı exception türlerini yakalamayı ve tutarlı error response'lar dönmeyi öğreneceksiniz.
Exception Handling Olmadan Ne Olur?
Spring Boot'un varsayılan hata davranışı, production için kesinlikle yeterli değildir:
// Controller'da hiçbir hata yönetimi yok
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
// Kullanıcı bulunamazsa ne olur?
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found"));
}
}Bu durumda Spring Boot şu yanıtı döner:
{
"timestamp": "2024-01-15T10:30:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/users/999"
}Sorunlar:
Kullanıcı bulunamadı (404 olmalı) ama 500 dönüyor
Hata mesajı anlamsız — frontend ne yapacağını bilmiyor
Stack trace production'da loglara yazılıyor ama client'a hiç bilgi gitmiyor
Her endpoint'te aynı sorun tekrar edecek
Try-Catch Yaklaşımı — Neden Kötü?
İlk akla gelen çözüm, her endpoint'e try-catch eklemek:
// ❌ YANLIŞ — Her endpoint'te try-catch tekrarı
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (ResourceNotFoundException ex) {
return ResponseEntity.status(404).body(
Map.of("error", ex.getMessage())
);
} catch (Exception ex) {
return ResponseEntity.status(500).body(
Map.of("error", "Beklenmeyen hata")
);
}
}
@PostMapping
public ResponseEntity<?> createUser(@Valid @RequestBody CreateUserRequest request) {
try {
User user = userService.create(request);
return ResponseEntity.status(201).body(user);
} catch (DuplicateEmailException ex) {
return ResponseEntity.status(409).body(
Map.of("error", ex.getMessage())
);
} catch (Exception ex) {
return ResponseEntity.status(500).body(
Map.of("error", "Beklenmeyen hata")
);
}
}
// 10 endpoint daha... hepsinde aynı try-catch 😫
}Bu yaklaşımın sorunları:
DRY ihlali: Aynı catch blokları her endpoint'te tekrarlanıyor
Tutarsızlık: Bir geliştirici
"error"key'i kullanırken diğeri"message"kullanıyorBakım kabusu: Yeni bir exception türü eklediğinizde 50 endpoint'i güncellemeniz gerekiyor
Okunabilirlik: İş mantığı, hata yönetimi kodunun içinde kaybolmuş
@ControllerAdvice — Merkezi Hata Yönetimi
@ControllerAdvice, Spring'in AOP (Aspect-Oriented Programming) altyapısını kullanarak tüm controller'lardaki exception'ları tek bir sınıfta yakalamanızı sağlar.
Gerçek Dünya Analojisi
Bir hastanenin acil servisini düşünün:
Try-catch yaklaşımı = Her doktorun kendi acil müdahale ekipmanını taşıması
@ControllerAdvice yaklaşımı = Merkezi bir acil servis — hangi bölümde sorun olursa olsun, hasta buraya yönlendiriliyor
Doktorlar (controller'lar) kendi işlerine odaklanır. Acil durum (exception) oluştuğunda, merkezi acil servis (ControllerAdvice) devreye girer.
@ControllerAdvice vs @RestControllerAdvice
// @ControllerAdvice — View (HTML) dönen uygulamalar için
@ControllerAdvice
public class WebExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ModelAndView handleNotFound(ResourceNotFoundException ex) {
ModelAndView mav = new ModelAndView("error/404");
mav.addObject("message", ex.getMessage());
return mav;
}
}
// @RestControllerAdvice — REST API (JSON) dönen uygulamalar için
// @RestControllerAdvice = @ControllerAdvice + @ResponseBody
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
// Otomatik olarak JSON'a serialize edilir (@ResponseBody sayesinde)
return new ErrorResponse("Not Found", ex.getMessage());
}
}Modern Spring Boot projelerinin büyük çoğunluğu REST API geliştirdiği için `@RestControllerAdvice` kullanacağız.
Adım Adım: Global Exception Handler Oluşturma
Adım 1: ErrorResponse DTO Tanımlama
Önce tutarlı bir hata yanıt formatı belirlememiz gerekiyor. Tüm hata yanıtları aynı yapıda olmalı ki frontend geliştiriciler tek bir error handling mantığı yazsın:
public record ErrorResponse(
LocalDateTime timestamp,
int status,
String error,
String message,
String path,
Map<String, String> validationErrors
) {
// Basit hatalar için factory method
public static ErrorResponse of(HttpStatus status, String message, String path) {
return new ErrorResponse(
LocalDateTime.now(),
status.value(),
status.getReasonPhrase(),
message,
path,
null
);
}
// Validation hataları için factory method
public static ErrorResponse ofValidation(String message, String path,
Map<String, String> errors) {
return new ErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
"Validation Failed",
message,
path,
errors
);
}
}Adım 2: @RestControllerAdvice Sınıfı
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ──────────────────────────────────
// 1. İş Mantığı Exception'ları
// ──────────────────────────────────
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex,
WebRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return ErrorResponse.of(
HttpStatus.NOT_FOUND,
ex.getMessage(),
extractPath(request)
);
}
@ExceptionHandler(ResourceAlreadyExistsException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(ResourceAlreadyExistsException ex,
WebRequest request) {
log.warn("Resource conflict: {}", ex.getMessage());
return ErrorResponse.of(
HttpStatus.CONFLICT,
ex.getMessage(),
extractPath(request)
);
}
@ExceptionHandler(BusinessRuleException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponse handleBusinessRule(BusinessRuleException ex,
WebRequest request) {
log.warn("Business rule violation: {}", ex.getMessage());
return ErrorResponse.of(
HttpStatus.UNPROCESSABLE_ENTITY,
ex.getMessage(),
extractPath(request)
);
}
// ──────────────────────────────────
// 2. Validation Exception'ları
// ──────────────────────────────────
@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())
);
log.warn("Validation failed: {}", errors);
return ErrorResponse.ofValidation(
"Girdi doğrulama hataları",
extractPath(request),
errors
);
}
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleConstraintViolation(ConstraintViolationException ex,
WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getConstraintViolations().forEach(violation -> {
String field = violation.getPropertyPath().toString();
errors.put(field, violation.getMessage());
});
log.warn("Constraint violation: {}", errors);
return ErrorResponse.ofValidation(
"Parametre doğrulama hataları",
extractPath(request),
errors
);
}
// ──────────────────────────────────
// 3. Spring Framework Exception'ları
// ──────────────────────────────────
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ErrorResponse handleMethodNotAllowed(HttpRequestMethodNotSupportedException ex,
WebRequest request) {
String message = String.format("'%s' metodu desteklenmiyor. Desteklenen: %s",
ex.getMethod(), Arrays.toString(ex.getSupportedMethods()));
return ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED, message, extractPath(request));
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
public ErrorResponse handleMediaType(HttpMediaTypeNotSupportedException ex,
WebRequest request) {
String message = String.format("'%s' media type desteklenmiyor", ex.getContentType());
return ErrorResponse.of(HttpStatus.UNSUPPORTED_MEDIA_TYPE, message, extractPath(request));
}
@ExceptionHandler(MissingServletRequestParameterException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleMissingParam(MissingServletRequestParameterException ex,
WebRequest request) {
String message = String.format("'%s' parametresi zorunludur (%s)",
ex.getParameterName(), ex.getParameterType());
return ErrorResponse.of(HttpStatus.BAD_REQUEST, message, extractPath(request));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleNotReadable(HttpMessageNotReadableException ex,
WebRequest request) {
return ErrorResponse.of(
HttpStatus.BAD_REQUEST,
"Request body okunamadı — geçersiz JSON formatı",
extractPath(request)
);
}
// ──────────────────────────────────
// 4. Son Çare — Genel Hata
// ──────────────────────────────────
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception ex, WebRequest request) {
// ⚠️ Stack trace'i loglara yaz — client'a GÖNDERMEYİN
log.error("Unhandled exception at {}: ", extractPath(request), ex);
return ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR,
"Beklenmedik bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
extractPath(request)
);
}
// ──────────────────────────────────
// Yardımcı metot
// ──────────────────────────────────
private String extractPath(WebRequest request) {
return request.getDescription(false).replace("uri=", "");
}
}⚠️ Dikkat: Exception.class handler'ı en son tanımlanmalıdır. Spring, en spesifik exception'dan başlayarak eşleşme arar. Eğer Exception.class en üstte olsaydı, tüm hatalar 500 olarak dönecekti.
Adım 3: Controller'lar Temiz Kalır
Artık controller'larınızda hata yönetimi koduna gerek yok:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
// findById bulamazsa → ResourceNotFoundException → 404
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
// Validation hatası → MethodArgumentNotValidException → 400
// Email varsa → ResourceAlreadyExistsException → 409
UserResponse response = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
// Bulunamazsa → ResourceNotFoundException → 404
userService.delete(id);
return ResponseEntity.noContent().build();
}
}Gördüğünüz gibi sıfır try-catch. Controller sadece iş mantığına odaklanıyor.
@ExceptionHandler Detaylı İnceleme
Birden Fazla Exception Yakalama
Tek bir handler ile birden fazla exception yakalayabilirsiniz:
@ExceptionHandler({
ResourceNotFoundException.class,
EntityNotFoundException.class, // JPA
NoSuchElementException.class // Optional.orElseThrow()
})
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleAllNotFound(Exception ex, WebRequest request) {
return ErrorResponse.of(HttpStatus.NOT_FOUND, ex.getMessage(), extractPath(request));
}Handler'da Erişilebilen Parametreler
@ExceptionHandler metotları çeşitli parametreleri inject edebilir:
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(
BusinessException ex, // Yakalanan exception
WebRequest request, // Request bilgisi
HttpServletRequest servletReq, // Servlet request (daha detaylı)
Locale locale // İstemci locale'i (i18n için)
) {
log.warn("[{}] {} {} → {}",
servletReq.getMethod(),
servletReq.getRequestURI(),
ex.getErrorCode(),
ex.getMessage()
);
ErrorResponse body = ErrorResponse.of(
ex.getHttpStatus(),
ex.getMessage(),
servletReq.getRequestURI()
);
return ResponseEntity.status(ex.getHttpStatus()).body(body);
}ResponseEntity ile HTTP Status Kontrolü
@ResponseStatus annotation'ı yerine ResponseEntity dönerek HTTP status'u dinamik olarak belirleyebilirsiniz:
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex,
WebRequest request) {
// Exception'ın kendi HTTP status'u var
HttpStatus status = ex.getHttpStatus();
ErrorResponse body = ErrorResponse.of(status, ex.getMessage(), extractPath(request));
return ResponseEntity
.status(status)
.header("X-Error-Code", ex.getErrorCode()) // Custom header
.body(body);
}@ControllerAdvice Scope Kısıtlama
Varsayılan olarak @ControllerAdvice tüm controller'lara uygulanır. Bunu kısıtlayabilirsiniz:
// 1. Belirli bir pakete — sadece api controller'ları
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler { }
// 2. Belirli annotation'a — sadece @RestController
@RestControllerAdvice(annotations = RestController.class)
public class RestApiExceptionHandler { }
// 3. Belirli sınıflara
@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
public class SpecificExceptionHandler { }
// 4. Belirli bir base class'a — bu class'ı extend eden tüm controller'lar
@RestControllerAdvice(basePackageClasses = BaseApiController.class)
public class BaseApiExceptionHandler { }Birden Fazla ControllerAdvice
Büyük projelerde farklı modüller için ayrı ControllerAdvice tanımlayabilirsiniz:
// Genel hatalar — en düşük öncelik
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ErrorResponse handleGeneral(Exception ex) { /* ... */ }
}
// API-spesifik hatalar — daha yüksek öncelik
@RestControllerAdvice(basePackages = "com.example.api")
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ApiExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) { /* ... */ }
}@Order ile öncelik belirlersiniz. Daha düşük değer = daha yüksek öncelik. Spring, en spesifik ve en yüksek öncelikli handler'ı seçer.
Exception Handling Akışı — İç İçe Nasıl Çalışır?
Spring MVC'nin exception handling akışını anlamak önemlidir:
Client Request
↓
DispatcherServlet
↓
HandlerMapping → Controller bulur
↓
Controller metodu çalışır
↓ (Exception fırlatılırsa)
ExceptionHandlerExceptionResolver
↓
1. Controller'ın kendi @ExceptionHandler'ı var mı? → Evet → Kullan
↓ (Yoksa)
2. @ControllerAdvice'daki @ExceptionHandler eşleşiyor mu? → Evet → Kullan
↓ (Eşleşmiyorsa)
3. ResponseStatusExceptionResolver → @ResponseStatus varsa kullan
↓ (Yoksa)
4. DefaultHandlerExceptionResolver → Spring'in varsayılan dönüşümleri
↓ (Hiçbiri eşleşmezse)
5. Tomcat varsayılan hata sayfası (White Label Error Page)💡 İpucu: Controller-level @ExceptionHandler, @ControllerAdvice'tan önce çalışır. Bu sayede genel handler'ı override edebilirsiniz:
@RestController
@RequestMapping("/api/admin")
public class AdminController {
// Bu handler SADECE bu controller için geçerli
// GlobalExceptionHandler'daki aynı exception handler'ını override eder
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleForbidden(AccessDeniedException ex) {
return ErrorResponse.of(
HttpStatus.FORBIDDEN,
"Admin yetkisi gereklidir",
"/api/admin"
);
}
@GetMapping("/dashboard")
public DashboardResponse getDashboard() {
// AccessDeniedException → yukarıdaki handler yakalar
}
}Production-Ready Hata Yanıtı Örnekleri
İşte farklı senaryolar için dönen hata yanıtları:
404 — Resource Not Found
{
"timestamp": "2024-01-15T10:30:00",
"status": 404,
"error": "Not Found",
"message": "User not found with id: 999",
"path": "/api/users/999",
"validationErrors": null
}400 — Validation Error
{
"timestamp": "2024-01-15T10:30:00",
"status": 400,
"error": "Validation Failed",
"message": "Girdi doğrulama hataları",
"path": "/api/users",
"validationErrors": {
"name": "İsim boş olamaz",
"email": "Geçerli bir email adresi giriniz",
"password": "Şifre en az 8 karakter olmalı"
}
}409 — Conflict
{
"timestamp": "2024-01-15T10:30:00",
"status": 409,
"error": "Conflict",
"message": "User already exists with email: john@example.com",
"path": "/api/users",
"validationErrors": null
}500 — Internal Server Error
{
"timestamp": "2024-01-15T10:30:00",
"status": 500,
"error": "Internal Server Error",
"message": "Beklenmedik bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
"path": "/api/orders",
"validationErrors": null
}⚠️ Dikkat: 500 hatalarında asla teknik detay (stack trace, sınıf adı, SQL sorgusu) client'a göndermeyin. Bu bir güvenlik açığıdır. Detayları yalnızca sunucu loglarına yazın.
Gerçek Dünya Senaryosu: E-Ticaret Uygulaması
Bir e-ticaret uygulamasında exception handling'in tüm parçalarını birleştirelim:
// ─── Exception Sınıfları ───
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
private final HttpStatus httpStatus;
protected BusinessException(String errorCode, HttpStatus status, String message) {
super(message);
this.errorCode = errorCode;
this.httpStatus = status;
}
public String getErrorCode() { return errorCode; }
public HttpStatus getHttpStatus() { return httpStatus; }
}
public class InsufficientStockException extends BusinessException {
public InsufficientStockException(String productName, int requested, int available) {
super("INSUFFICIENT_STOCK", HttpStatus.UNPROCESSABLE_ENTITY,
String.format("'%s' için yetersiz stok. İstenen: %d, Mevcut: %d",
productName, requested, available));
}
}
public class OrderLimitExceededException extends BusinessException {
public OrderLimitExceededException(BigDecimal limit) {
super("ORDER_LIMIT_EXCEEDED", HttpStatus.UNPROCESSABLE_ENTITY,
String.format("Sipariş limiti aşıldı. Maksimum: %s TL", limit));
}
}
// ─── Service ───
@Service
@Transactional
public class OrderService {
public OrderResponse createOrder(CreateOrderRequest request) {
User user = userRepository.findById(request.userId())
.orElseThrow(() -> new ResourceNotFoundException("User", "id", request.userId()));
for (OrderItemRequest item : request.items()) {
Product product = productRepository.findById(item.productId())
.orElseThrow(() -> new ResourceNotFoundException(
"Product", "id", item.productId()));
if (product.getStock() < item.quantity()) {
throw new InsufficientStockException(
product.getName(), item.quantity(), product.getStock());
}
}
BigDecimal total = calculateTotal(request.items());
if (total.compareTo(MAX_ORDER_LIMIT) > 0) {
throw new OrderLimitExceededException(MAX_ORDER_LIMIT);
}
Order order = buildOrder(user, request);
return orderMapper.toResponse(orderRepository.save(order));
}
}
// ─── Global Exception Handler ───
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException ex,
WebRequest request) {
log.warn("[{}] {}: {}", ex.getErrorCode(), ex.getClass().getSimpleName(),
ex.getMessage());
ErrorResponse body = ErrorResponse.of(
ex.getHttpStatus(), ex.getMessage(), extractPath(request));
return ResponseEntity.status(ex.getHttpStatus()).body(body);
}
@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()));
return ErrorResponse.ofValidation(
"Doğrulama hataları", extractPath(request), errors);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleUnexpected(Exception ex, WebRequest request) {
log.error("Unexpected error: ", ex);
return ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR,
"Bir hata oluştu, lütfen tekrar deneyin.",
extractPath(request));
}
private String extractPath(WebRequest request) {
return request.getDescription(false).replace("uri=", "");
}
}
// ─── Controller — Temiz ve Kısa ───
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@Valid @RequestBody CreateOrderRequest request) {
// Exception fırlarsa → GlobalExceptionHandler yakalar
OrderResponse response = orderService.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}Yaygın Hatalar ve Çözümleri
1. ❌ Exception Handler'ın Çalışmaması
// ❌ YANLIŞ — @Component veya @RestControllerAdvice eksik
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex) { /* ... */ }
}
// ✅ DOĞRU
@RestControllerAdvice // Spring'e bu sınıfı tanıt
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex) { /* ... */ }
}2. ❌ Response Body Boş Dönme
// ❌ YANLIŞ — @ControllerAdvice kullanıp @ResponseBody unutmak
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
// @ResponseBody yok → Spring view resolver'a yönlendirir → 404
public ErrorResponse handle(Exception ex) { /* ... */ }
}
// ✅ DOĞRU — @RestControllerAdvice kullan veya @ResponseBody ekle
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex) { /* ... */ }
}3. ❌ BindingResult ile Handler'ın Çatışması
// ❌ YANLIŞ — BindingResult varsa MethodArgumentNotValidException FIRLATILMAZ
@PostMapping("/users")
public ResponseEntity<?> create(
@Valid @RequestBody CreateUserRequest request,
BindingResult result) { // Bu varsa exception handler çalışmaz!
// Hataları kendiniz yönetmek zorundasınız
}
// ✅ DOĞRU — BindingResult kaldırın, global handler yakalasın
@PostMapping("/users")
public ResponseEntity<UserResponse> create(
@Valid @RequestBody CreateUserRequest request) {
// Validation hatası → MethodArgumentNotValidException → GlobalExceptionHandler
return ResponseEntity.status(201).body(userService.create(request));
}4. ❌ catch (Exception e) ile Tüm Hataları Yutma
// ❌ YANLIŞ — Exception handler'da exception yutmak
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex) {
// Log yok! Hata nerede oluştu? Bilemezsiniz.
return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "Hata", "");
}
// ✅ DOĞRU — Her zaman logla
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex, WebRequest request) {
log.error("Unhandled exception at {}: ", extractPath(request), ex);
return ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR,
"Beklenmedik hata",
extractPath(request));
}5. ❌ Stack Trace'i Client'a Göndermek
// ❌ YANLIŞ — GÜVENLİK AÇIĞI
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex) {
return new ErrorResponse(
LocalDateTime.now(), 500, "Error",
ex.toString(), // Stack trace → SQL, sınıf isimleri sızar!
null, null
);
}
// ✅ DOĞRU — Genel mesaj dön, detayı logla
@ExceptionHandler(Exception.class)
public ErrorResponse handle(Exception ex) {
log.error("Unexpected: ", ex); // Detay logda
return ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR,
"Sunucu hatası oluştu", "/api/..."); // Client'a genel mesaj
}Test Etme
Exception handler'larınızı mutlaka test edin:
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.findById(999L))
.thenThrow(new ResourceNotFoundException("User", "id", 999L));
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.message").value("User not found with id: 999"));
}
@Test
void shouldReturn400WhenValidationFails() throws Exception {
String invalidJson = """
{
"name": "",
"email": "invalid"
}
""";
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.validationErrors.name").exists())
.andExpect(jsonPath("$.validationErrors.email").exists());
}
@Test
void shouldReturn409WhenDuplicateEmail() throws Exception {
String validJson = """
{
"name": "John",
"email": "john@example.com",
"password": "Secret123"
}
""";
when(userService.create(any()))
.thenThrow(new ResourceAlreadyExistsException("User", "email", "john@example.com"));
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(validJson))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value(
"User already exists with email: john@example.com"));
}
}Özet
`@RestControllerAdvice`, tüm controller'lardaki exception'ları tek bir yerde yakalamanın Spring yoludur — DRY prensibi
`@ExceptionHandler`, belirli exception türlerini yakalayan metotları işaretler
Tutarlı `ErrorResponse` formatı kullanın — frontend'in her endpoint için farklı error handling yazmasına gerek kalmaz
Exception hiyerarşisi kurun:
BusinessException→ResourceNotFoundException,ResourceAlreadyExistsException...500 hatalarında asla teknik detay client'a göndermeyin — güvenlik açığıdır
Controller'lar temiz kalır — sıfır try-catch, sadece iş mantığı
Handler'larınızı `@WebMvcTest` ile test edin
AI Asistan
Sorularını yanıtlamaya hazır