← Kursa Dön
📄 Text · 15 min

Error Handling Best Practices

Giriş

Bir API'nin kalitesini anlamak istiyorsanız, happy path'e değil error handling'e bakın. Kullanıcı doğru veri gönderdiğinde her şey güzeldir — asıl sınav, yanlış veri geldiğinde, veritabanı çöktüğünde veya bağlı servis cevap vermediğinde başlar.

Düşünün: bir hastaneye gidiyorsunuz. Her şey yolundayken doktorlar gülümsüyor, hemşireler nazik. Ama acil bir durum olduğunda nasıl tepki veriyorlar? İşte o an hastanenin gerçek kalitesini görürsünüz. API'ler de aynıdır — krizde ne yaptığınız, sizi profesyonel ya da amatör yapar.

Kötü error handling nasıl görünür?

  • Client'a 500 Internal Server Error ve Java stack trace döner → güvenlik açığı

  • Kullanıcı "bir şeyler yanlış gitti" dışında bilgi alamaz → kötü UX

  • Her endpoint farklı formatta hata döner → entegrasyon kabusu

  • Log dosyaları ya bomboş ya da gereksiz noise ile dolu → debug imkansız

Bu derste production-grade bir error handling sistemi kuracağız: tutarlı exception hiyerarşisi, standardize edilmiş error response formatı, global exception handler ve en iyi pratikler.

Exception Hiyerarşisi Tasarımı

İyi bir hata yönetim sistemi, iyi bir exception hiyerarşisi ile başlar. Bu hiyerarşi, farklı hata türlerini sınıflandırır ve her biri için uygun HTTP status code'u belirler.

Base Exception

/**
 * Tüm business exception'ların temel sınıfı.
 * RuntimeException (unchecked) kullanıyoruz çünkü:
 * 1. Spring @Transactional, unchecked exception'larda otomatik rollback yapar
 * 2. Her metoda throws eklemek gerekmez
 * 3. Functional interface'lerle uyumlu çalışır
 */
public abstract class AppException extends RuntimeException {

    private final String errorCode;
    private final HttpStatus httpStatus;
    private final Map<String, Object> details;

    protected AppException(String errorCode, HttpStatus httpStatus,
                          String message) {
        this(errorCode, httpStatus, message, null);
    }

    protected AppException(String errorCode, HttpStatus httpStatus,
                          String message, Map<String, Object> details) {
        super(message);
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.details = details != null ? details : Map.of();
    }

    public String getErrorCode() { return errorCode; }
    public HttpStatus getHttpStatus() { return httpStatus; }
    public Map<String, Object> getDetails() { return details; }
}

Spesifik Exception Sınıfları

// 404 — Kaynak bulunamadı
public class ResourceNotFoundException extends AppException {

    public ResourceNotFoundException(String resource, Object id) {
        super(
            "RESOURCE_NOT_FOUND",
            HttpStatus.NOT_FOUND,
            String.format("%s bulunamadı: %s", resource, id),
            Map.of("resource", resource, "id", id.toString())
        );
    }

    // Convenience factory methods
    public static ResourceNotFoundException user(Long id) {
        return new ResourceNotFoundException("User", id);
    }

    public static ResourceNotFoundException order(Long id) {
        return new ResourceNotFoundException("Order", id);
    }

    public static ResourceNotFoundException product(String sku) {
        return new ResourceNotFoundException("Product", sku);
    }
}

// 409 — Çakışma (duplicate kayıt, eş zamanlı güncelleme)
public class ConflictException extends AppException {

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

    public ConflictException(String message, Map<String, Object> details) {
        super("CONFLICT", HttpStatus.CONFLICT, message, details);
    }

    public static ConflictException duplicateEmail(String email) {
        return new ConflictException(
            "Bu e-posta adresi zaten kullanımda",
            Map.of("email", email)
        );
    }
}

// 422 — Validation hatası (iş kuralı ihlali)
public class BusinessValidationException extends AppException {

    public BusinessValidationException(String message) {
        super("BUSINESS_VALIDATION_ERROR",
              HttpStatus.UNPROCESSABLE_ENTITY, message);
    }

    public BusinessValidationException(String message,
                                       Map<String, Object> details) {
        super("BUSINESS_VALIDATION_ERROR",
              HttpStatus.UNPROCESSABLE_ENTITY, message, details);
    }

    public static BusinessValidationException insufficientStock(
            String productSku, int requested, int available) {
        return new BusinessValidationException(
            "Yetersiz stok",
            Map.of("productSku", productSku,
                   "requested", requested,
                   "available", available)
        );
    }
}

// 400 — Geçersiz istek
public class BadRequestException extends AppException {

    public BadRequestException(String message) {
        super("BAD_REQUEST", HttpStatus.BAD_REQUEST, message);
    }
}

// 403 — Yetkisiz erişim
public class ForbiddenException extends AppException {

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

    public static ForbiddenException notOwner(String resource, Long id) {
        return new ForbiddenException(
            String.format("Bu %s kaynağına erişim yetkiniz yok: %s",
                         resource, id)
        );
    }
}

// 503 — Harici servis hatası
public class ServiceUnavailableException extends AppException {

    public ServiceUnavailableException(String serviceName, Throwable cause) {
        super("SERVICE_UNAVAILABLE", HttpStatus.SERVICE_UNAVAILABLE,
              serviceName + " servisine ulaşılamıyor",
              Map.of("service", serviceName));
        initCause(cause);
    }
}

💡 Neden checked exception değil unchecked? Spring'in @Transactional annotation'ı, varsayılan olarak sadece unchecked exception'larda (RuntimeException) rollback yapar. Checked exception'da rollback olmaz — bu çok yaygın bir hata kaynağıdır. Ayrıca checked exception'lar functional interface'lerle (lambda, Stream) uyumsuz çalışır.

Tutarlı Error Response Formatı

Tüm API'ler aynı hata formatını döndürmelidir. Client geliştiricisi hangi endpoint'i çağırırsa çağırsın, hata yanıtının yapısını bilmelidir:

/**
 * Standart API hata yanıtı.
 * RFC 7807 (Problem Details) standardından esinlenilmiştir.
 */
public record ErrorResponse(
    String code,                       // Makine tarafından okunabilir kod
    String message,                    // İnsan tarafından okunabilir mesaj
    Map<String, Object> details,       // Ek detaylar (opsiyonel)
    List<FieldError> fieldErrors,      // Validasyon hataları (opsiyonel)
    String path,                       // İstek path'i
    Instant timestamp,                 // Zaman damgası
    String traceId                     // Distributed tracing ID
) {
    public record FieldError(
        String field,                  // Hatalı alan adı
        Object rejectedValue,         // Reddedilen değer
        String message                 // Hata mesajı
    ) {}

    // Factory method — AppException'dan oluştur
    public static ErrorResponse of(AppException ex, String path,
                                   String traceId) {
        return new ErrorResponse(
            ex.getErrorCode(),
            ex.getMessage(),
            ex.getDetails().isEmpty() ? null : ex.getDetails(),
            null,
            path,
            Instant.now(),
            traceId
        );
    }

    // Factory method — Validation hatalarından oluştur
    public static ErrorResponse ofValidation(
            List<FieldError> fieldErrors, String path, String traceId) {
        return new ErrorResponse(
            "VALIDATION_ERROR",
            "Girdi doğrulaması başarısız",
            null,
            fieldErrors,
            path,
            Instant.now(),
            traceId
        );
    }
}

Örnek Yanıtlar

// 404 — Kaynak bulunamadı
{
  "code": "RESOURCE_NOT_FOUND",
  "message": "User bulunamadı: 42",
  "details": {
    "resource": "User",
    "id": "42"
  },
  "fieldErrors": null,
  "path": "/api/v1/users/42",
  "timestamp": "2025-01-15T14:30:00Z",
  "traceId": "abc123def456"
}

// 422 — İş kuralı ihlali
{
  "code": "BUSINESS_VALIDATION_ERROR",
  "message": "Yetersiz stok",
  "details": {
    "productSku": "LAPTOP-001",
    "requested": 5,
    "available": 2
  },
  "fieldErrors": null,
  "path": "/api/v1/orders",
  "timestamp": "2025-01-15T14:31:00Z",
  "traceId": "xyz789ghi012"
}

// 400 — Validasyon hatası
{
  "code": "VALIDATION_ERROR",
  "message": "Girdi doğrulaması başarısız",
  "details": null,
  "fieldErrors": [
    {
      "field": "email",
      "rejectedValue": "not-an-email",
      "message": "Geçerli bir e-posta adresi giriniz"
    },
    {
      "field": "age",
      "rejectedValue": -5,
      "message": "0'dan büyük olmalıdır"
    }
  ],
  "path": "/api/v1/users",
  "timestamp": "2025-01-15T14:32:00Z",
  "traceId": "mno345pqr678"
}

Global Exception Handler (@ControllerAdvice)

@ControllerAdvice, tüm controller'lardaki exception'ları tek bir yerde yakalar ve tutarlı yanıtlara dönüştürür:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    private final Tracer tracer;

    public GlobalExceptionHandler(Tracer tracer) {
        this.tracer = tracer;
    }

    // ===== 1. Business Exception'lar =====
    @ExceptionHandler(AppException.class)
    public ResponseEntity<ErrorResponse> handleAppException(
            AppException ex, HttpServletRequest request) {

        String traceId = getCurrentTraceId();
        String path = request.getRequestURI();

        // 4xx hataları WARN, 5xx hataları ERROR
        if (ex.getHttpStatus().is4xxClientError()) {
            log.warn("Business error [{}]: {} | path={} | traceId={}",
                ex.getErrorCode(), ex.getMessage(), path, traceId);
        } else {
            log.error("Server error [{}]: {} | path={} | traceId={}",
                ex.getErrorCode(), ex.getMessage(), path, traceId, ex);
        }

        ErrorResponse response = ErrorResponse.of(ex, path, traceId);
        return ResponseEntity.status(ex.getHttpStatus()).body(response);
    }

    // ===== 2. Bean Validation Hataları =====
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors().stream()
            .map(fe -> new ErrorResponse.FieldError(
                fe.getField(),
                fe.getRejectedValue(),
                fe.getDefaultMessage()
            ))
            .toList();

        log.warn("Validation error: {} field(s) | path={}",
            fieldErrors.size(), request.getRequestURI());

        ErrorResponse response = ErrorResponse.ofValidation(
            fieldErrors, request.getRequestURI(), getCurrentTraceId());

        return ResponseEntity.badRequest().body(response);
    }

    // ===== 3. Constraint Violation (Path/Query param validation) =====
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolation(
            ConstraintViolationException ex,
            HttpServletRequest request) {

        List<ErrorResponse.FieldError> fieldErrors = ex
            .getConstraintViolations().stream()
            .map(cv -> new ErrorResponse.FieldError(
                extractFieldName(cv.getPropertyPath()),
                cv.getInvalidValue(),
                cv.getMessage()
            ))
            .toList();

        ErrorResponse response = ErrorResponse.ofValidation(
            fieldErrors, request.getRequestURI(), getCurrentTraceId());

        return ResponseEntity.badRequest().body(response);
    }

    // ===== 4. JSON Parse Hataları =====
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleJsonParseError(
            HttpMessageNotReadableException ex,
            HttpServletRequest request) {

        log.warn("JSON parse error: {} | path={}",
            ex.getMostSpecificCause().getMessage(),
            request.getRequestURI());

        ErrorResponse response = new ErrorResponse(
            "INVALID_JSON",
            "İstek gövdesi geçerli bir JSON değil",
            null, null,
            request.getRequestURI(),
            Instant.now(),
            getCurrentTraceId()
        );

        return ResponseEntity.badRequest().body(response);
    }

    // ===== 5. HTTP Method Desteklenmiyor =====
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ErrorResponse> handleMethodNotSupported(
            HttpRequestMethodNotSupportedException ex,
            HttpServletRequest request) {

        ErrorResponse response = new ErrorResponse(
            "METHOD_NOT_ALLOWED",
            String.format("'%s' metodu desteklenmiyor. Desteklenen: %s",
                ex.getMethod(),
                String.join(", ", ex.getSupportedMethods())),
            null, null,
            request.getRequestURI(),
            Instant.now(),
            getCurrentTraceId()
        );

        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
            .body(response);
    }

    // ===== 6. Type Mismatch (id='abc' gibi) =====
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException ex,
            HttpServletRequest request) {

        ErrorResponse response = new ErrorResponse(
            "TYPE_MISMATCH",
            String.format("'%s' parametresi '%s' tipinde olmalı, " +
                "gelen değer: '%s'",
                ex.getName(),
                ex.getRequiredType().getSimpleName(),
                ex.getValue()),
            null, null,
            request.getRequestURI(),
            Instant.now(),
            getCurrentTraceId()
        );

        return ResponseEntity.badRequest().body(response);
    }

    // ===== 7. Access Denied (Spring Security) =====
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(
            AccessDeniedException ex,
            HttpServletRequest request) {

        log.warn("Access denied: {} | path={} | user={}",
            ex.getMessage(), request.getRequestURI(),
            request.getRemoteUser());

        ErrorResponse response = new ErrorResponse(
            "ACCESS_DENIED",
            "Bu kaynağa erişim yetkiniz yok",
            null, null,
            request.getRequestURI(),
            Instant.now(),
            getCurrentTraceId()
        );

        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response);
    }

    // ===== 8. Catch-all — Beklenmeyen hatalar =====
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(
            Exception ex, HttpServletRequest request) {

        // ASLA stack trace'i client'a göndermiyoruz
        // Sadece log'a yazıyoruz
        log.error("Unexpected error | path={} | traceId={}",
            request.getRequestURI(), getCurrentTraceId(), ex);

        ErrorResponse response = new ErrorResponse(
            "INTERNAL_ERROR",
            "Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin.",
            null, null,
            request.getRequestURI(),
            Instant.now(),
            getCurrentTraceId()
        );

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

    // ===== Yardımcı Metodlar =====
    private String getCurrentTraceId() {
        if (tracer != null && tracer.currentSpan() != null) {
            return tracer.currentSpan().context().traceId();
        }
        return UUID.randomUUID().toString().replace("-", "")
            .substring(0, 16);
    }

    private String extractFieldName(Path propertyPath) {
        String fullPath = propertyPath.toString();
        int lastDot = fullPath.lastIndexOf('.');
        return lastDot > 0 ? fullPath.substring(lastDot + 1) : fullPath;
    }
}

⚠️ Dikkat: Exception.class catch-all handler'ı en sona koyun ve ASLA stack trace'i response'a eklemeyin. Stack trace; kullanılan framework versiyonları, paket yapısı, veritabanı şema bilgisi gibi hassas bilgiler içerir. Bunları sadece log'a yazın.

Exception Kullanımı Controller ve Service'te

Service Layer'da Exception Fırlatmak

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final UserClient userClient;

    @Transactional
    public OrderResponse createOrder(CreateOrderRequest request) {
        // 1. Kullanıcı kontrolü
        UserDto user = userClient.getUser(request.userId());
        if (user == null) {
            throw ResourceNotFoundException.user(request.userId());
        }

        // 2. Stok kontrolü — iş kuralı validasyonu
        Product product = productRepository
            .findBySku(request.productSku())
            .orElseThrow(() -> new ResourceNotFoundException(
                "Product", request.productSku()));

        if (product.getStock() < request.quantity()) {
            throw BusinessValidationException.insufficientStock(
                request.productSku(),
                request.quantity(),
                product.getStock()
            );
        }

        // 3. Minimum sipariş tutarı kontrolü
        BigDecimal total = product.getPrice()
            .multiply(BigDecimal.valueOf(request.quantity()));

        if (total.compareTo(BigDecimal.valueOf(10)) < 0) {
            throw new BusinessValidationException(
                "Minimum sipariş tutarı 10 TL'dir",
                Map.of("orderTotal", total, "minimum", 10)
            );
        }

        // 4. Siparişi oluştur
        Order order = Order.builder()
            .userId(user.id())
            .productSku(product.getSku())
            .quantity(request.quantity())
            .totalAmount(total)
            .status(OrderStatus.PENDING)
            .build();

        return orderMapper.toResponse(orderRepository.save(order));
    }

    public OrderResponse getOrder(Long id) {
        return orderRepository.findById(id)
            .map(orderMapper::toResponse)
            .orElseThrow(() -> ResourceNotFoundException.order(id));
    }
}

Controller Layer — Temiz

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

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        // Hiç try-catch yok — exception'lar GlobalExceptionHandler'a gider
        OrderResponse response = orderService.createOrder(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return ResponseEntity.ok(orderService.getOrder(id));
    }
}

Controller'da try-catch bloğu YOKTUR. Bu bilinçli bir tasarım kararıdır:

  • Exception'lar @ControllerAdvice tarafından global olarak yakalanır

  • Controller sadece "happy path" ile ilgilenir

  • Kod temiz ve okunabilir kalır

  • Hata yönetimi tek bir yerde merkezileşir

Loglama Stratejisi

Hata loglaması, debugging için hayati önem taşır. Ancak yanlış loglama noise üretir ve gerçek sorunları gizler:

// ❌ YANLIŞ — Her catch bloğunda log
@Service
public class BadService {
    public UserDto getUser(Long id) {
        try {
            return userClient.getUser(id);
        } catch (Exception e) {
            log.error("Error getting user", e);  // Log burada
            throw e;                               // Exception da fırlatılıyor
        }
        // Sonuç: Aynı hata 2-3 kez loglanıyor (her katmanda catch + log)
    }
}

// ✅ DOĞRU — Exception'ı fırlat, loglama merkezi handler'da yapılsın
@Service
public class GoodService {
    public UserDto getUser(Long id) {
        return userClient.getUser(id);
        // Exception varsa yukarı fırlar → GlobalExceptionHandler loglar
    }
}

Log Seviyeleri

// ERROR — Sistem açısından ciddi sorun, müdahale gerekebilir
log.error("Database connection failed | host={} | traceId={}",
    dbHost, traceId, exception);

// WARN — Potansiyel sorun, ancak sistem çalışmaya devam ediyor
log.warn("User not found [404] | userId={} | path={}",
    userId, path);

// INFO — Önemli iş olayları
log.info("Order created | orderId={} | userId={} | amount={}",
    orderId, userId, amount);

// DEBUG — Geliştirme detayları (production'da kapalı)
log.debug("Calling payment gateway | url={} | timeout={}",
    gatewayUrl, timeout);

Structured Logging

JSON formatında log yazmak, log aggregation araçlarıyla (ELK, Grafana Loki) arama ve filtrelemeyi kolaylaştırır:

<!-- logback-spring.xml -->
<configuration>
    <!-- Development — okunabilir format -->
    <springProfile name="dev">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n</pattern>
            </encoder>
        </appender>
    </springProfile>

    <!-- Production — JSON format -->
    <springProfile name="prod">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <includeMdcKeyName>spanId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
            </encoder>
        </appender>
    </springProfile>
</configuration>

RFC 7807 — Problem Details for HTTP APIs

RFC 7807, HTTP API'leri için standart hata formatını tanımlar. Spring Framework 6+ bunu doğrudan destekler:

@RestControllerAdvice
public class ProblemDetailExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex,
                                       HttpServletRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND, ex.getMessage());

        problem.setTitle("Resource Not Found");
        problem.setType(URI.create(
            "https://api.example.com/errors/resource-not-found"));
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("errorCode", ex.getErrorCode());
        problem.setProperty("traceId", getCurrentTraceId());
        problem.setProperty("timestamp", Instant.now());

        return problem;
    }
}
# application.yml — ProblemDetail formatını aktif etme
spring:
  mvc:
    problemdetails:
      enabled: true

Yanıt:

{
  "type": "https://api.example.com/errors/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "User bulunamadı: 42",
  "instance": "/api/v1/users/42",
  "errorCode": "RESOURCE_NOT_FOUND",
  "traceId": "abc123def456",
  "timestamp": "2025-01-15T14:30:00Z"
}

i18n — Çok Dilli Hata Mesajları

Farklı dillerde hata mesajı döndürmek için MessageSource kullanın:

# messages.properties (varsayılan — Türkçe)
error.resource.not_found={0} bulunamadı: {1}
error.validation.failed=Girdi doğrulaması başarısız
error.insufficient_stock=Yetersiz stok. İstenen: {0}, Mevcut: {1}
error.internal=Beklenmeyen bir hata oluştu
# messages_en.properties
error.resource.not_found={0} not found: {1}
error.validation.failed=Input validation failed
error.insufficient_stock=Insufficient stock. Requested: {0}, Available: {1}
error.internal=An unexpected error occurred
@RestControllerAdvice
@RequiredArgsConstructor
public class I18nExceptionHandler {

    private final MessageSource messageSource;

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(
            ResourceNotFoundException ex,
            HttpServletRequest request,
            Locale locale) {

        String message = messageSource.getMessage(
            "error.resource.not_found",
            new Object[]{
                ex.getDetails().get("resource"),
                ex.getDetails().get("id")
            },
            ex.getMessage(),   // fallback
            locale             // Accept-Language header'dan
        );

        ErrorResponse response = new ErrorResponse(
            ex.getErrorCode(), message, ex.getDetails(),
            null, request.getRequestURI(),
            Instant.now(), getCurrentTraceId()
        );

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

Client, Accept-Language header'ı ile tercih ettiği dili belirtir:

GET /api/v1/users/42
Accept-Language: en

Yaygın Hatalar

1. Stack Trace'i Client'a Göndermek

// ❌ KÖTÜ — güvenlik açığı
@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()));
    return ResponseEntity.status(500).body(body);
}

Stack trace, uygulamanızın iç yapısını ifşa eder: framework versiyonları, paket yapısı, veritabanı bilgileri. Bu bilgi saldırganlar için altın değerindedir.

2. Exception Yutmak (Swallowing)

// ❌ KÖTÜ — exception yutuldu, hata kayboldu
try {
    paymentGateway.charge(amount);
} catch (PaymentException e) {
    // Hiçbir şey yapmamak — sessiz hata
}

// ✅ DOĞRU — en azından logla veya fırlat
try {
    paymentGateway.charge(amount);
} catch (PaymentException e) {
    log.error("Payment failed for order {}", orderId, e);
    throw new ServiceUnavailableException("payment-gateway", e);
}

3. Genel Exception Yakalamak

// ❌ KÖTÜ — tüm hataları aynı şekilde ele almak
try {
    processOrder(request);
} catch (Exception e) {
    return ResponseEntity.badRequest().body("Bir hata oluştu");
}

// ✅ DOĞRU — spesifik exception'ları yakalayın
try {
    processOrder(request);
} catch (InsufficientStockException e) {
    throw new BusinessValidationException(e.getMessage());
} catch (PaymentDeclinedException e) {
    throw new BadRequestException("Ödeme reddedildi: " + e.getReason());
}

4. Her Katmanda Loglama

// ❌ KÖTÜ — aynı hata 3 kez loglanır
// Repository
catch (Exception e) { log.error("DB error", e); throw e; }
// Service
catch (Exception e) { log.error("Service error", e); throw e; }
// Controller
catch (Exception e) { log.error("Controller error", e); throw e; }

// ✅ DOĞRU — sadece GlobalExceptionHandler'da logla
// Exception yukarı fırlasın, merkezi handler hem loglar hem response döner

5. Anlamsız Hata Mesajları

// ❌ KÖTÜ
throw new RuntimeException("Error occurred");
throw new RuntimeException("Something went wrong");
throw new RuntimeException("null");

// ✅ DOĞRU — kim, ne, neden
throw new ResourceNotFoundException("Order", orderId);
throw new BusinessValidationException(
    "Sipariş iptal edilemez: mevcut durum SHIPPED");
throw new ConflictException(
    "Bu ürün SKU'su zaten mevcut: " + sku);

Production Error Monitoring

Error handling sistemini monitoring ile tamamlayın:

@RestControllerAdvice
@RequiredArgsConstructor
public class MonitoredExceptionHandler {

    private final MeterRegistry meterRegistry;

    @ExceptionHandler(AppException.class)
    public ResponseEntity<ErrorResponse> handleAppException(
            AppException ex, HttpServletRequest request) {

        // Metrik: error count by code and status
        meterRegistry.counter("api.errors",
            "code", ex.getErrorCode(),
            "status", String.valueOf(ex.getHttpStatus().value()),
            "path", request.getRequestURI()
        ).increment();

        // ... response oluştur
    }

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

        // Beklenmeyen hataları özel metrikle izle
        meterRegistry.counter("api.errors.unexpected",
            "exception", ex.getClass().getSimpleName(),
            "path", request.getRequestURI()
        ).increment();

        // ... response oluştur
    }
}

Grafana dashboard'unda bu metrikleri izleyerek hata trendlerini görebilir, spike'ları yakalayabilirsiniz.

Test Etme

@WebMvcTest(OrderController.class)
class OrderControllerErrorTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        when(orderService.getOrder(999L))
            .thenThrow(ResourceNotFoundException.order(999L));

        mockMvc.perform(get("/api/v1/orders/999"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"))
            .andExpect(jsonPath("$.message").value("Order bulunamadı: 999"))
            .andExpect(jsonPath("$.path").value("/api/v1/orders/999"))
            .andExpect(jsonPath("$.timestamp").exists())
            .andExpect(jsonPath("$.traceId").exists());
    }

    @Test
    void shouldReturn400WhenValidationFails() throws Exception {
        String invalidJson = """
            {
                "userId": null,
                "productSku": "",
                "quantity": -1
            }
            """;

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidJson))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
            .andExpect(jsonPath("$.fieldErrors").isArray())
            .andExpect(jsonPath("$.fieldErrors.length()").value(3));
    }

    @Test
    void shouldReturn422WhenInsufficientStock() throws Exception {
        when(orderService.createOrder(any()))
            .thenThrow(BusinessValidationException.insufficientStock(
                "LAPTOP-001", 5, 2));

        String validJson = """
            {
                "userId": 1,
                "productSku": "LAPTOP-001",
                "quantity": 5
            }
            """;

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content(validJson))
            .andExpect(status().isUnprocessableEntity())
            .andExpect(jsonPath("$.code")
                .value("BUSINESS_VALIDATION_ERROR"))
            .andExpect(jsonPath("$.details.available").value(2));
    }

    @Test
    void shouldReturn500WithoutStackTrace() throws Exception {
        when(orderService.getOrder(1L))
            .thenThrow(new RuntimeException("DB connection failed"));

        mockMvc.perform(get("/api/v1/orders/1"))
            .andExpect(status().isInternalServerError())
            .andExpect(jsonPath("$.code").value("INTERNAL_ERROR"))
            .andExpect(jsonPath("$.message").value(
                "Beklenmeyen bir hata oluştu. Lütfen daha sonra tekrar deneyin."))
            // Stack trace OLMAMALI
            .andExpect(jsonPath("$.stackTrace").doesNotExist());
    }
}

Özet

  • Exception hiyerarşisi oluşturun: AppExceptionResourceNotFoundException, ConflictException, BusinessValidationException — her biri uygun HTTP status code ile

  • Tutarlı error response formatı kullanın: code, message, details, fieldErrors, path, timestamp, traceId — tüm endpoint'ler aynı yapıda hata döndürsün

  • @ControllerAdvice ile global exception handling uygulayın — controller'larda try-catch OLMASIN

  • Stack trace'i ASLA client'a göndermeyin — sadece logla, client'a generic mesaj ver

  • Loglama merkezileştirin — her katmanda değil, sadece global handler'da logla

  • 4xx = WARN, 5xx = ERROR — log seviyelerini doğru kullanın

  • Error metriklerini izleyin — Micrometer + Grafana ile hata trendlerini takip edin