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_FOUNDCustom Exception'ın Avantajları
| Avantaj | Açıklama |
|---|---|
| Semantik netlik | ResourceNotFoundException gören herkes ne olduğunu anlar |
| HTTP status eşlemesi | Her exception kendi HTTP status'unu bilir (404, 409, 422...) |
| Error code sistemi | Makine tarafından okunabilir hata kodları (INSUFFICIENT_BALANCE) |
| Ek veri taşıma | Exception'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): Derleyicitry-catchveyathrowszorunlu tutarUnchecked Exception (
extends RuntimeException): Yakalamak zorunlu değil
Spring Boot'ta unchecked exception kullanılır çünkü:
Controller metotlarında
throwsdeclaration gereksiz@ControllerAdviceile merkezi yakalama yapılırChecked exception'lar her katmanda
throwszincirine 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.javaGenel 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 Status | Anlam | Ne Zaman Kullanılır | Exception Örneği |
|---|---|---|---|
| 400 Bad Request | İstek formatı hatalı | JSON parse edilemedi, zorunlu alan eksik | MethodArgumentNotValidException |
| 401 Unauthorized | Kimlik doğrulama gerekli | Token yok veya geçersiz | UnauthorizedException |
| 403 Forbidden | Yetki yetersiz | Admin yetkisi gerekli | ForbiddenException |
| 404 Not Found | Kaynak bulunamadı | ID ile aranan entity yok | ResourceNotFoundException |
| 405 Method Not Allowed | HTTP metodu yanlış | POST yerine GET gönderildi | Spring otomatik |
| 409 Conflict | Kaynak çakışması | Email zaten kayıtlı | ResourceAlreadyExistsException |
| 415 Unsupported Media Type | Content-Type yanlış | XML gönderildi, JSON bekleniyor | Spring otomatik |
| 422 Unprocessable Entity | İş kuralı ihlali | Bakiye yetersiz, stok yok | BusinessRuleException |
| 429 Too Many Requests | Rate limit aşıldı | Çok fazla istek yapıldı | RateLimitExceededException |
| 500 Internal Server Error | Sunucu hatası | Beklenmedik exception | Genel 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); // → 4224. ❌ 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'larHer exception kendi error code ve HTTP status'unu taşısın
RuntimeException (unchecked) kullanın — checked exception service katmanında gereksiz
throwszincirine neden olurErrorResponse formatı tutarlı olsun —
errorCodealanı frontend'in programatik error handling yapmasını sağlarException'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
AI Asistan
Sorularını yanıtlamaya hazır