← Kursa Dön
📄 Text · 18 min

Custom Exception Sınıfları ve Error Response

Bir hastaneyi düşünün. Hasta acile geldiğinde, doktor "bir şeyler yanlış" demez — "sağ kolda kapalı kırık var, 3. derece" gibi spesifik bir teşhis koyar. Çünkü tedavi, teşhisin netliğine bağlıdır.

Yazılımda exception handling de aynı mantıkla çalışır. throw new RuntimeException("Hata oluştu") demek, "bir şeyler yanlış" demekle eşdeğerdir. Oysa throw new InsufficientBalanceException(account, amount) demek — hem hatanın ne olduğunu, hem neden oluştuğunu, hem de hangi verilerle ilgili olduğunu taşır.

Bu derste, profesyonel Spring Boot projelerinde nasıl bir exception hiyerarşisi kurulacağını, her exception'ın hangi HTTP status'a karşılık geleceğini ve tutarlı error response formatı tasarlamayı öğreneceksiniz.


Neden Custom Exception?

Java'da zaten RuntimeException, IllegalArgumentException, NullPointerException gibi onlarca exception var. Neden kendi exception'larımızı yazıyoruz?

Gerçek Dünya Analojisi

Bir kargo şirketini düşünün. Teslimat sorunlarını şöyle raporlasalar:

  • ❌ "Hata oluştu" → Hangi hata? Adres mi yanlış, paket mi kayıp?

  • ✅ "Adres bulunamadı (Kargo #12345, Ankara/Çankaya)" → Net, actionable

  • ✅ "Alıcı evde değildi — 2. teslimat denemesi yapılacak" → Durum + aksiyon

Custom exception'lar aynı netliği sağlar:

// ❌ Generic — hata hakkında hiçbir bilgi yok
throw new RuntimeException("User not found");

// ❌ Biraz daha iyi ama hâlâ yetersiz
throw new IllegalArgumentException("User not found with id: 42");

// ✅ Custom — tam bilgi: ne, nerede, hangi veriyle
throw new ResourceNotFoundException("User", "id", 42);
// → "User not found with id: 42"
// → HTTP 404
// → Error code: RESOURCE_NOT_FOUND

Custom Exception'ın Avantajları

AvantajAçıklama
Semantik netlikResourceNotFoundException gören herkes ne olduğunu anlar
HTTP status eşlemesiHer exception kendi HTTP status'unu bilir (404, 409, 422...)
Error code sistemiMakine tarafından okunabilir hata kodları (INSUFFICIENT_BALANCE)
Ek veri taşımaException'a bağlam bilgisi ekleyebilirsiniz (hangi kaynak, hangi alan)
Merkezi handling@ControllerAdvice ile türe göre yakalayıp farklı response dönebilirsiniz
Test kolaylığıassertThrows(ResourceNotFoundException.class, ...) — net assertions

Exception Hiyerarşisi Tasarlama

İyi bir exception hiyerarşisi, bir soy ağacı gibi organize edilmelidir. En üstte soyut bir base class, altında kategorilere ayrılmış spesifik exception'lar:

RuntimeException
  └── BusinessException (abstract — tüm iş hataları)
        ├── ResourceNotFoundException (404)
        ├── ResourceAlreadyExistsException (409)
        ├── BusinessRuleException (422)
        │     ├── InsufficientBalanceException
        │     ├── InsufficientStockException
        │     └── OrderLimitExceededException
        ├── UnauthorizedException (401)
        └── ForbiddenException (403)

Neden RuntimeException?

Java'da iki tür exception vardır:

  • Checked Exception (extends Exception): Derleyici try-catch veya throws zorunlu tutar

  • Unchecked Exception (extends RuntimeException): Yakalamak zorunlu değil

Spring Boot'ta unchecked exception kullanılır çünkü:

  1. Controller metotlarında throws declaration gereksiz

  2. @ControllerAdvice ile merkezi yakalama yapılır

  3. Checked exception'lar her katmanda throws zincirine sebep olur — gereksiz boilerplate

Adım 1: Abstract Base Exception

/**
 * Tüm iş mantığı exception'larının base class'ı.
 * 
 * Neden abstract?
 * → Doğrudan new BusinessException() yapılmasını engeller.
 * → Her hata spesifik bir alt sınıf ile fırlatılmalıdır.
 */
public abstract class BusinessException extends RuntimeException {

    private final String errorCode;
    private final HttpStatus httpStatus;

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

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

    public String getErrorCode() {
        return errorCode;
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }
}

Neden `errorCode` ve `httpStatus` burada?

  • errorCode: Frontend'in hata türünü programatik olarak anlaması için (if (error.code === "INSUFFICIENT_BALANCE"))

  • httpStatus: @ControllerAdvice'ta doğru HTTP status dönmek için — exception kendi status'unu biliyor

Adım 2: Spesifik Exception Sınıfları

ResourceNotFoundException (404)

/**
 * Aranan kaynak veritabanında bulunamadığında fırlatılır.
 * HTTP 404 Not Found döner.
 * 
 * Kullanım:
 *   throw new ResourceNotFoundException("User", "id", 42);
 *   throw new ResourceNotFoundException("Product", "slug", "iphone-15");
 */
public class ResourceNotFoundException extends BusinessException {

    private final String resourceName;
    private final String fieldName;
    private final Object fieldValue;

    public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
        super(
            "RESOURCE_NOT_FOUND",
            HttpStatus.NOT_FOUND,
            String.format("%s not found with %s: %s", resourceName, fieldName, fieldValue)
        );
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }

    public String getResourceName() { return resourceName; }
    public String getFieldName() { return fieldName; }
    public Object getFieldValue() { return fieldValue; }
}

ResourceAlreadyExistsException (409)

/**
 * Oluşturulmak istenen kaynak zaten mevcut olduğunda fırlatılır.
 * HTTP 409 Conflict döner.
 * 
 * Kullanım:
 *   throw new ResourceAlreadyExistsException("User", "email", "john@example.com");
 */
public class ResourceAlreadyExistsException extends BusinessException {

    public ResourceAlreadyExistsException(String resourceName, String fieldName,
                                            Object fieldValue) {
        super(
            "RESOURCE_ALREADY_EXISTS",
            HttpStatus.CONFLICT,
            String.format("%s already exists with %s: %s", resourceName, fieldName, fieldValue)
        );
    }
}

BusinessRuleException (422)

/**
 * İş kuralı ihlal edildiğinde fırlatılır.
 * HTTP 422 Unprocessable Entity döner.
 * 
 * 422 vs 400:
 *   400 → Syntax hatası (JSON formatı bozuk, zorunlu alan eksik)
 *   422 → Syntax doğru ama semantik yanlış (bakiye yetersiz, limit aşıldı)
 */
public class BusinessRuleException extends BusinessException {

    public BusinessRuleException(String errorCode, String message) {
        super(errorCode, HttpStatus.UNPROCESSABLE_ENTITY, message);
    }

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

Spesifik İş Kuralı Exception'ları

public class InsufficientBalanceException extends BusinessRuleException {

    private final BigDecimal currentBalance;
    private final BigDecimal requiredAmount;

    public InsufficientBalanceException(BigDecimal currentBalance, BigDecimal requiredAmount) {
        super("INSUFFICIENT_BALANCE",
            String.format("Yetersiz bakiye. Mevcut: %s TL, Gereken: %s TL",
                currentBalance, requiredAmount));
        this.currentBalance = currentBalance;
        this.requiredAmount = requiredAmount;
    }

    public BigDecimal getCurrentBalance() { return currentBalance; }
    public BigDecimal getRequiredAmount() { return requiredAmount; }
}

public class InsufficientStockException extends BusinessRuleException {

    public InsufficientStockException(String productName, int requested, int available) {
        super("INSUFFICIENT_STOCK",
            String.format("'%s' için stok yetersiz. İstenen: %d, Mevcut: %d",
                productName, requested, available));
    }
}

public class OrderLimitExceededException extends BusinessRuleException {

    public OrderLimitExceededException(BigDecimal maxLimit, BigDecimal attemptedAmount) {
        super("ORDER_LIMIT_EXCEEDED",
            String.format("Sipariş limiti aşıldı. Maksimum: %s TL, Denenen: %s TL",
                maxLimit, attemptedAmount));
    }
}

Authentication ve Authorization Exception'ları

public class UnauthorizedException extends BusinessException {
    public UnauthorizedException(String message) {
        super("UNAUTHORIZED", HttpStatus.UNAUTHORIZED, message);
    }

    public UnauthorizedException() {
        this("Kimlik doğrulama gerekli");
    }
}

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

    public ForbiddenException() {
        this("Bu işlem için yetkiniz yok");
    }
}

Error Response Tasarlama

Kötü Error Response vs İyi Error Response

// ❌ KÖTÜ — bilgi yetersiz
{
    "error": "Something went wrong"
}

// ❌ KÖTÜ — tutarsız format
{
    "msg": "User not found",
    "code": 404
}

// ✅ İYİ — tutarlı, bilgilendirici
{
    "timestamp": "2024-01-15T10:30:00",
    "status": 404,
    "error": "Not Found",
    "errorCode": "RESOURCE_NOT_FOUND",
    "message": "User not found with id: 42",
    "path": "/api/users/42"
}

ErrorResponse Record

/**
 * Tüm API hata yanıtları için standart format.
 * 
 * Neden record?
 * → Immutable (değiştirilemez) — thread-safe
 * → Otomatik equals/hashCode/toString
 * → Kısa söz dizimi
 */
public record ErrorResponse(
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
    LocalDateTime timestamp,

    int status,
    String error,
    String errorCode,
    String message,
    String path,

    @JsonInclude(JsonInclude.Include.NON_NULL)
    Map<String, String> validationErrors,

    @JsonInclude(JsonInclude.Include.NON_NULL)
    Map<String, Object> metadata
) {
    // ─── Factory Methods ───

    public static ErrorResponse of(HttpStatus status, String errorCode,
                                     String message, String path) {
        return new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            status.getReasonPhrase(),
            errorCode,
            message,
            path,
            null,
            null
        );
    }

    public static ErrorResponse fromBusinessException(BusinessException ex, String path) {
        return new ErrorResponse(
            LocalDateTime.now(),
            ex.getHttpStatus().value(),
            ex.getHttpStatus().getReasonPhrase(),
            ex.getErrorCode(),
            ex.getMessage(),
            path,
            null,
            null
        );
    }

    public static ErrorResponse ofValidation(Map<String, String> errors, String path) {
        return new ErrorResponse(
            LocalDateTime.now(),
            HttpStatus.BAD_REQUEST.value(),
            "Validation Failed",
            "VALIDATION_ERROR",
            "Bir veya daha fazla alan doğrulama kuralını ihlal ediyor",
            path,
            errors,
            null
        );
    }

    public static ErrorResponse withMetadata(HttpStatus status, String errorCode,
                                               String message, String path,
                                               Map<String, Object> metadata) {
        return new ErrorResponse(
            LocalDateTime.now(),
            status.value(),
            status.getReasonPhrase(),
            errorCode,
            message,
            path,
            null,
            metadata
        );
    }
}

💡 İpucu: @JsonInclude(NON_NULL) sayesinde validationErrors ve metadata alanları null olduğunda JSON çıktısına eklenmez. Bu, yanıtı daha temiz tutar.

Metadata ile Zengin Error Response

Bazı hata durumlarında ek bilgi taşımak isteyebilirsiniz:

// InsufficientBalanceException handler'ında
@ExceptionHandler(InsufficientBalanceException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ErrorResponse handleInsufficientBalance(InsufficientBalanceException ex,
                                                 WebRequest request) {
    return ErrorResponse.withMetadata(
        HttpStatus.UNPROCESSABLE_ENTITY,
        "INSUFFICIENT_BALANCE",
        ex.getMessage(),
        extractPath(request),
        Map.of(
            "currentBalance", ex.getCurrentBalance(),
            "requiredAmount", ex.getRequiredAmount(),
            "deficit", ex.getRequiredAmount().subtract(ex.getCurrentBalance())
        )
    );
}

Yanıt:

{
    "timestamp": "2024-01-15T10:30:00",
    "status": 422,
    "error": "Unprocessable Entity",
    "errorCode": "INSUFFICIENT_BALANCE",
    "message": "Yetersiz bakiye. Mevcut: 150.00 TL, Gereken: 500.00 TL",
    "path": "/api/orders",
    "metadata": {
        "currentBalance": 150.00,
        "requiredAmount": 500.00,
        "deficit": 350.00
    }
}

Service Katmanında Exception Kullanımı

Exception'lar service katmanında fırlatılır, controller'da değil:

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

    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;

    public UserResponse findById(Long id) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
        return userMapper.toResponse(user);
    }

    public UserResponse findByEmail(String email) {
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new ResourceNotFoundException("User", "email", email));
        return userMapper.toResponse(user);
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        // Benzersizlik kontrolü
        if (userRepository.existsByEmail(request.email())) {
            throw new ResourceAlreadyExistsException("User", "email", request.email());
        }

        if (userRepository.existsByUsername(request.username())) {
            throw new ResourceAlreadyExistsException("User", "username", request.username());
        }

        // Entity oluştur ve kaydet
        User user = userMapper.toEntity(request);
        user.setPasswordHash(passwordEncoder.encode(request.password()));

        return userMapper.toResponse(userRepository.save(user));
    }

    @Transactional
    public UserResponse update(Long id, UpdateUserRequest request) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));

        // Email değiştiyse benzersizlik kontrolü
        if (!user.getEmail().equals(request.email())
                && userRepository.existsByEmail(request.email())) {
            throw new ResourceAlreadyExistsException("User", "email", request.email());
        }

        userMapper.updateEntity(request, user);
        return userMapper.toResponse(userRepository.save(user));
    }

    @Transactional
    public void delete(Long id) {
        if (!userRepository.existsById(id)) {
            throw new ResourceNotFoundException("User", "id", id);
        }
        userRepository.deleteById(id);
    }
}

OrderService — Karmaşık İş Kuralları

@Service
@RequiredArgsConstructor
@Transactional
public class OrderService {

    private static final BigDecimal MAX_ORDER_LIMIT = new BigDecimal("50000.00");
    private static final int MAX_ITEMS_PER_ORDER = 20;

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

    public OrderResponse createOrder(Long userId, CreateOrderRequest request) {
        // 1. Kullanıcıyı bul
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User", "id", userId));

        // 2. Ürün sayısı kontrolü
        if (request.items().size() > MAX_ITEMS_PER_ORDER) {
            throw new BusinessRuleException("ORDER_ITEM_LIMIT",
                String.format("Bir siparişte en fazla %d ürün olabilir", MAX_ITEMS_PER_ORDER));
        }

        // 3. Stok kontrolü — her ürün için
        BigDecimal totalAmount = BigDecimal.ZERO;
        List<OrderItem> items = new ArrayList<>();

        for (OrderItemRequest itemReq : request.items()) {
            Product product = productRepository.findById(itemReq.productId())
                .orElseThrow(() -> new ResourceNotFoundException(
                    "Product", "id", itemReq.productId()));

            if (product.getStock() < itemReq.quantity()) {
                throw new InsufficientStockException(
                    product.getName(), itemReq.quantity(), product.getStock());
            }

            BigDecimal lineTotal = product.getPrice()
                .multiply(BigDecimal.valueOf(itemReq.quantity()));
            totalAmount = totalAmount.add(lineTotal);

            // Stok düş
            product.setStock(product.getStock() - itemReq.quantity());
            items.add(new OrderItem(product, itemReq.quantity(), lineTotal));
        }

        // 4. Sipariş limiti kontrolü
        if (totalAmount.compareTo(MAX_ORDER_LIMIT) > 0) {
            throw new OrderLimitExceededException(MAX_ORDER_LIMIT, totalAmount);
        }

        // 5. Bakiye kontrolü
        if (user.getBalance().compareTo(totalAmount) < 0) {
            throw new InsufficientBalanceException(user.getBalance(), totalAmount);
        }

        // 6. Bakiye düş ve siparişi oluştur
        user.setBalance(user.getBalance().subtract(totalAmount));
        Order order = new Order(user, items, totalAmount);

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

Exception Sınıfları Paket Organizasyonu

Büyük projelerde exception sınıflarınızı organize edin:

com.example.myapp
├── exception
│   ├── BusinessException.java          // Abstract base
│   ├── ResourceNotFoundException.java
│   ├── ResourceAlreadyExistsException.java
│   ├── BusinessRuleException.java
│   ├── UnauthorizedException.java
│   ├── ForbiddenException.java
│   ├── handler
│   │   └── GlobalExceptionHandler.java
│   └── response
│       └── ErrorResponse.java
├── domain
│   ├── order
│   │   └── exception
│   │       ├── InsufficientStockException.java
│   │       └── OrderLimitExceededException.java
│   └── payment
│       └── exception
│           └── InsufficientBalanceException.java

Genel exception'lar (ResourceNotFoundException, ResourceAlreadyExistsException) exception paketinde, domain-spesifik exception'lar (InsufficientStockException) ilgili domain paketinde yer alır.


HTTP Status Code Rehberi

Hangi exception hangi HTTP status'a eşlenmeli?

HTTP StatusAnlamNe Zaman KullanılırException Örneği
400 Bad Requestİstek formatı hatalıJSON parse edilemedi, zorunlu alan eksikMethodArgumentNotValidException
401 UnauthorizedKimlik doğrulama gerekliToken yok veya geçersizUnauthorizedException
403 ForbiddenYetki yetersizAdmin yetkisi gerekliForbiddenException
404 Not FoundKaynak bulunamadıID ile aranan entity yokResourceNotFoundException
405 Method Not AllowedHTTP metodu yanlışPOST yerine GET gönderildiSpring otomatik
409 ConflictKaynak çakışmasıEmail zaten kayıtlıResourceAlreadyExistsException
415 Unsupported Media TypeContent-Type yanlışXML gönderildi, JSON bekleniyorSpring otomatik
422 Unprocessable Entityİş kuralı ihlaliBakiye yetersiz, stok yokBusinessRuleException
429 Too Many RequestsRate limit aşıldıÇok fazla istek yapıldıRateLimitExceededException
500 Internal Server ErrorSunucu hatasıBeklenmedik exceptionGenel Exception

⚠️ Dikkat: 400 vs 422 karışıklığı yaygındır:

  • 400: "Senin gönderdiğin veriyi anlayamıyorum" (syntax hatası)

  • 422: "Veriyi anladım ama iş kuralına göre işleyemiyorum" (semantic hata)


Exception'larda Internationalization (i18n)

Çok dilli uygulamalarda hata mesajlarını da çevirmek gerekir:

// messages.properties (varsayılan — Türkçe)
error.user.not-found=Kullanıcı bulunamadı: {0}
error.email.duplicate=Bu email zaten kayıtlı: {0}
error.insufficient.balance=Yetersiz bakiye. Mevcut: {0} TL, Gereken: {1} TL

// messages_en.properties
error.user.not-found=User not found: {0}
error.email.duplicate=Email already registered: {0}
error.insufficient.balance=Insufficient balance. Current: {0} TL, Required: {1} TL
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

    private final MessageSource messageSource;

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex,
                                          WebRequest request, Locale locale) {
        String message = messageSource.getMessage(
            "error." + ex.getResourceName().toLowerCase() + ".not-found",
            new Object[]{ex.getFieldValue()},
            ex.getMessage(),  // fallback
            locale
        );

        return ErrorResponse.of(HttpStatus.NOT_FOUND, ex.getErrorCode(),
            message, extractPath(request));
    }
}

Yaygın Hatalar ve Anti-Pattern'ler

1. ❌ Checked Exception Kullanmak

// ❌ YANLIŞ — throws zincirleme zorunluluğu
public class UserNotFoundException extends Exception {  // Checked!
    public UserNotFoundException(String msg) { super(msg); }
}

// Controller'da throws gerekir
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) throws UserNotFoundException {
    // Spring bu exception'ı düzgün yakalayamayabilir
}

// ✅ DOĞRU — RuntimeException
public class UserNotFoundException extends RuntimeException { }

2. ❌ Exception'da Gereğinden Fazla Bilgi

// ❌ YANLIŞ — SQL sorgusu ve stack trace sızıyor
throw new RuntimeException("SELECT * FROM users WHERE id=" + id + " returned no rows");

// ✅ DOĞRU — sadece gerekli bilgi
throw new ResourceNotFoundException("User", "id", id);
// Mesaj: "User not found with id: 42"

3. ❌ Exception Hiyerarşisi Olmadan Düz Fırlatma

// ❌ YANLIŞ — her yerde aynı generic exception
throw new RuntimeException("User not found");
throw new RuntimeException("Email already exists");
throw new RuntimeException("Insufficient balance");
// ControllerAdvice'ta hepsine aynı 500 dönecek!

// ✅ DOĞRU — her durum kendi exception'ına sahip
throw new ResourceNotFoundException("User", "id", id);        // → 404
throw new ResourceAlreadyExistsException("User", "email", e); // → 409
throw new InsufficientBalanceException(balance, amount);       // → 422

4. ❌ Exception'ı Catch Edip Yutmak

// ❌ YANLIŞ — hata sessizce yutulmuş
public User findUser(Long id) {
    try {
        return userRepository.findById(id).orElseThrow();
    } catch (NoSuchElementException e) {
        return null;  // Caller null kontrolü yapmayı unutabilir → NPE
    }
}

// ✅ DOĞRU — anlamlı exception fırlat
public User findUser(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
}

5. ❌ Error Code Olmadan Mesaja Bağımlılık

// ❌ YANLIŞ — Frontend mesaj string'ine göre aksiyon alıyor
// if (error.message.includes("not found")) { ... }

// ✅ DOĞRU — Error code ile programatik kontrol
// if (error.errorCode === "RESOURCE_NOT_FOUND") { ... }

Test Stratejisi

Exception Sınıflarını Test Etme

class ResourceNotFoundExceptionTest {

    @Test
    void shouldFormatMessageCorrectly() {
        var ex = new ResourceNotFoundException("User", "id", 42L);

        assertThat(ex.getMessage()).isEqualTo("User not found with id: 42");
        assertThat(ex.getErrorCode()).isEqualTo("RESOURCE_NOT_FOUND");
        assertThat(ex.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(ex.getResourceName()).isEqualTo("User");
        assertThat(ex.getFieldName()).isEqualTo("id");
        assertThat(ex.getFieldValue()).isEqualTo(42L);
    }
}

Service'te Exception Fırlatıldığını Test Etme

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void findById_shouldThrowNotFound_whenUserDoesNotExist() {
        when(userRepository.findById(999L)).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.findById(999L))
            .isInstanceOf(ResourceNotFoundException.class)
            .hasMessageContaining("User not found with id: 999");
    }

    @Test
    void create_shouldThrowConflict_whenEmailExists() {
        var request = new CreateUserRequest("John", "john@example.com", "Pass1234");
        when(userRepository.existsByEmail("john@example.com")).thenReturn(true);

        assertThatThrownBy(() -> userService.create(request))
            .isInstanceOf(ResourceAlreadyExistsException.class)
            .hasMessageContaining("email: john@example.com");
    }
}

Integration Test — HTTP Response Doğrulama

@WebMvcTest(UserController.class)
class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void shouldReturn404WithCorrectBody() 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("$.errorCode").value("RESOURCE_NOT_FOUND"))
            .andExpect(jsonPath("$.message").value("User not found with id: 999"))
            .andExpect(jsonPath("$.path").value("/api/users/999"));
    }

    @Test
    void shouldReturn409WhenEmailExists() throws Exception {
        when(userService.create(any()))
            .thenThrow(new ResourceAlreadyExistsException("User", "email", "john@example.com"));

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"name": "John", "email": "john@example.com", "password": "Pass1234"}
                    """))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.errorCode").value("RESOURCE_ALREADY_EXISTS"));
    }
}

Özet

  • Custom exception sınıfları, hata yönetimini anlamlı, tutarlı ve test edilebilir yapar

  • Hiyerarşik yapı kurun: BusinessException → spesifik exception'lar

  • Her exception kendi error code ve HTTP status'unu taşısın

  • RuntimeException (unchecked) kullanın — checked exception service katmanında gereksiz throws zincirine neden olur

  • ErrorResponse formatı tutarlı olsun — errorCode alanı frontend'in programatik error handling yapmasını sağlar

  • Exception'ları service katmanında fırlatın, controller'da try-catch yazmayın

  • 500 hatalarında teknik detay göndermeyin — log'a yazın, client'a genel mesaj dönün