Skip to main content

Why Create Custom Exceptions? It's Not Just About the Name!

Β· 10 min read
Mahmut Salman
Software Developer

"Why create InvalidCredentialsException extending RuntimeException? Isn't it just to get a descriptive name instead of generic RuntimeException?" No! The name is only a small part. The real power is type-safe error handling - allowing Spring to distinguish between different errors and handle them differently without string parsing. Let's see why custom exceptions are essential, not just fancy naming.

The Simple Custom Exception​

public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException(String message) {
super(message);
}
}

Your question:

"This code is very basic - we create a child that inherits features of parent. Is the only reason to get the name InvalidCredentialsException while writing code so that it clearly indicates which error? So we add some details to RuntimeException class by creating child class of it?"

Short answer: No! It's about type-safe exception handling, not just naming.


The Problem: Using Only RuntimeException​

Scenario: Authentication Service​

@Service
public class AuthService {

public LoginResponse login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElse(null);

// Problem 1: User not found
if (user == null) {
throw new RuntimeException("User not found"); // ❌ Generic
}

// Problem 2: Invalid password
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new RuntimeException("Invalid credentials"); // ❌ Generic
}

// Problem 3: Account inactive
if (!user.getActive()) {
throw new RuntimeException("Account is inactive"); // ❌ Generic
}

return new LoginResponse(user.getId(), ...);
}
}

All three errors throw RuntimeException.

Problem: How to Handle Them Differently?​

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntime(RuntimeException ex) {
// ❌ Problem: How do I know WHICH error this is?
// I only have the message string!

// Option 1: Parse the message string (FRAGILE!)
if (ex.getMessage().contains("Invalid credentials")) {
return ResponseEntity.status(401)
.body(new ErrorResponse("AUTH_FAILED", ex.getMessage()));
} else if (ex.getMessage().contains("User not found")) {
return ResponseEntity.status(404)
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
} else if (ex.getMessage().contains("Account is inactive")) {
return ResponseEntity.status(403)
.body(new ErrorResponse("ACCOUNT_INACTIVE", ex.getMessage()));
} else {
return ResponseEntity.status(500)
.body(new ErrorResponse("INTERNAL_ERROR", ex.getMessage()));
}
}
}

Why This is Bad​

ProblemWhy It's Bad
String parsingMessage text might change β†’ breaks error handling
FragileTypo in message β†’ wrong HTTP status code
Hard to testMust test exact message strings
Not type-safeCompiler can't help catch errors
Hard to extendAdding new error requires updating if-else chain
Scattered logicError handling logic mixed with string matching

Example of fragility:

// Developer changes message
throw new RuntimeException("Invalid username or password"); // Changed wording!

// Handler no longer matches
if (ex.getMessage().contains("Invalid credentials")) { // ❌ Doesn't match anymore!
return 401;
}
// Falls through to 500 error instead! 🚨

The Solution: Custom Exceptions​

Create Specific Exception Types​

// Exception 1: Invalid credentials
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException(String message) {
super(message);
}
}

// Exception 2: User not found
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}

// Exception 3: Account inactive
public class AccountInactiveException extends RuntimeException {
public AccountInactiveException(String message) {
super(message);
}
}

Use Specific Exceptions in Service​

@Service
public class AuthService {

public LoginResponse login(LoginRequest request) {
User user = userRepository.findByEmail(request.getEmail())
.orElse(null);

// Specific exception: User not found
if (user == null) {
throw new UserNotFoundException("User not found"); // βœ… Specific
}

// Specific exception: Invalid password
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new InvalidCredentialsException("Invalid credentials"); // βœ… Specific
}

// Specific exception: Account inactive
if (!user.getActive()) {
throw new AccountInactiveException("Account is inactive"); // βœ… Specific
}

return new LoginResponse(user.getId(), ...);
}
}

Handle Each Exception Type Separately​

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
// βœ… Type-safe: I KNOW this is InvalidCredentialsException!
// βœ… No string parsing needed
return ResponseEntity.status(401)
.body(new ErrorResponse("AUTH_FAILED", ex.getMessage()));
}

@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
// βœ… Type-safe: I KNOW this is UserNotFoundException!
return ResponseEntity.status(404)
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
}

@ExceptionHandler(AccountInactiveException.class)
public ResponseEntity<ErrorResponse> handleAccountInactive(AccountInactiveException ex) {
// βœ… Type-safe: I KNOW this is AccountInactiveException!
return ResponseEntity.status(403)
.body(new ErrorResponse("ACCOUNT_INACTIVE", ex.getMessage()));
}
}

Benefits​

BenefitWhy It's Good
Type-safeCompiler checks exception types
No string parsingMatch by type, not message
Clear intentException name shows exactly what went wrong
Easy to extendAdd new exception = add new handler (no if-else chains)
RobustMessage can change without breaking handlers
TestableTest by throwing specific exception types
MaintainableEach exception has dedicated handler

IS-A Relationship and Polymorphism​

The Inheritance Chain​

Object
↑
Throwable
↑
Exception
↑
RuntimeException
↑
InvalidCredentialsException

IS-A relationships:

  • InvalidCredentialsException IS-A RuntimeException
  • InvalidCredentialsException IS-A Exception
  • InvalidCredentialsException IS-A Throwable
  • InvalidCredentialsException IS-A Object

Polymorphism in Action​

// All of these are valid assignments:
RuntimeException ex1 = new InvalidCredentialsException("test"); // βœ…
Exception ex2 = new InvalidCredentialsException("test"); // βœ…
Throwable ex3 = new InvalidCredentialsException("test"); // βœ…
Object ex4 = new InvalidCredentialsException("test"); // βœ…

How Spring Uses Polymorphism​

@RestControllerAdvice
public class GlobalExceptionHandler {

// Handler 1: Most specific
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity.status(401).body(...);
}

// Handler 2: Less specific
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntime(RuntimeException ex) {
return ResponseEntity.status(500).body(...);
}

// Handler 3: Least specific
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(500).body(...);
}
}

When InvalidCredentialsException is thrown:

1. Spring checks: Does InvalidCredentialsException match handler 1?
β†’ YES! Use handler 1 βœ…

2. Spring doesn't check handler 2 or 3 (already matched)

When NullPointerException is thrown:

1. Spring checks: Does NullPointerException match handler 1?
β†’ NO (it's not InvalidCredentialsException)

2. Spring checks: Does NullPointerException match handler 2?
β†’ YES! NullPointerException IS-A RuntimeException βœ…
β†’ Use handler 2

Handler priority order:

Most specific β†’ Least specific

InvalidCredentialsException
↓
RuntimeException
↓
Exception
↓
(No handler matched β†’ default error)

Real-World Example: Different Handling Logic​

Scenario: Retry Logic​

Different errors require different handling strategies:

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
// Wrong password: DON'T retry, return 401 immediately
logSecurityEvent("Failed login attempt"); // Log for security monitoring
return ResponseEntity.status(401)
.body(new ErrorResponse("AUTH_FAILED", "Invalid username or password"));
}

@ExceptionHandler(DatabaseConnectionException.class)
public ResponseEntity<ErrorResponse> handleDatabaseConnection(DatabaseConnectionException ex) {
// Database error: Client SHOULD retry later
alertOps("Database connection failed"); // Alert operations team
return ResponseEntity.status(503) // Service Temporarily Unavailable
.header("Retry-After", "60") // Retry after 60 seconds
.body(new ErrorResponse("DB_ERROR", "Service temporarily unavailable"));
}

@ExceptionHandler(AccountInactiveException.class)
public ResponseEntity<ErrorResponse> handleAccountInactive(AccountInactiveException ex) {
// Account inactive: DON'T retry, send email to reactivate
emailService.sendReactivationEmail(ex.getEmail()); // Trigger reactivation flow
return ResponseEntity.status(403)
.body(new ErrorResponse("ACCOUNT_INACTIVE", "Please check your email to reactivate"));
}

@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ErrorResponse> handleRateLimit(RateLimitExceededException ex) {
// Rate limit: Client should retry after cooldown
return ResponseEntity.status(429) // Too Many Requests
.header("Retry-After", String.valueOf(ex.getRetryAfterSeconds()))
.body(new ErrorResponse("RATE_LIMIT", "Too many requests, try again later"));
}
}

With only RuntimeException, you can't do this!

// ❌ With RuntimeException only
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntime(RuntimeException ex) {
// How do I know if I should:
// - Send reactivation email? (AccountInactive)
// - Alert ops team? (DatabaseConnection)
// - Log security event? (InvalidCredentials)
// - Set Retry-After header? (RateLimit)

// ❌ Would require fragile string parsing!
}

Comparison: RuntimeException vs Custom Exceptions​

Scenario: Adding New Error Type​

You need to handle email already registered:

With RuntimeException only:

// Step 1: Add to service
if (userRepository.existsByEmail(email)) {
throw new RuntimeException("Email already registered"); // ❌
}

// Step 2: Update exception handler (MODIFY existing code)
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntime(RuntimeException ex) {
if (ex.getMessage().contains("Invalid credentials")) {
return 401;
} else if (ex.getMessage().contains("User not found")) {
return 404;
} else if (ex.getMessage().contains("Account is inactive")) {
return 403;
} else if (ex.getMessage().contains("Email already registered")) { // ← ADD THIS
return 409; // Conflict
}
return 500;
}

Problems:

  • ❌ Modified existing handler (risk of breaking existing logic)
  • ❌ Growing if-else chain (harder to read)
  • ❌ String parsing (fragile)

With custom exceptions:

// Step 1: Create new exception class
public class EmailAlreadyExistsException extends RuntimeException {
public EmailAlreadyExistsException(String message) {
super(message);
}
}

// Step 2: Use in service
if (userRepository.existsByEmail(email)) {
throw new EmailAlreadyExistsException("Email already registered"); // βœ…
}

// Step 3: Add new handler (NO modification to existing code)
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailExists(EmailAlreadyExistsException ex) {
return ResponseEntity.status(409) // Conflict
.body(new ErrorResponse("EMAIL_EXISTS", ex.getMessage()));
}

Benefits:

  • βœ… No modification to existing handlers (no risk)
  • βœ… New handler is isolated (clear separation)
  • βœ… Type-safe (compiler checks)
  • βœ… Easy to test independently

Code Comparison Table​

AspectRuntimeException OnlyCustom Exceptions
Error IdentificationString parsingType matching
Handler Logicif-else chainsSeparate @ExceptionHandler methods
Compiler HelpNone (strings not type-checked)Full (types checked)
ExtensibilityModify existing codeAdd new code
TestabilityTest message stringsTest exception types
MaintainabilityHard (scattered logic)Easy (isolated handlers)
Risk of BreakingHigh (string changes break logic)Low (type system protects)
Code ClarityLow (what does "Invalid" mean?)High (exception name is self-documenting)
Different HandlingHard (all in one method)Easy (one method per type)

Advanced: Adding Context to Custom Exceptions​

Problem: Need More Than Just Message​

// You want to know WHICH email failed
throw new InvalidCredentialsException("Invalid credentials");
// ❌ No context about which user

Solution: Add Fields to Custom Exception​

public class InvalidCredentialsException extends RuntimeException {
private final String email;
private final int attemptCount;

public InvalidCredentialsException(String message, String email, int attemptCount) {
super(message);
this.email = email;
this.attemptCount = attemptCount;
}

public String getEmail() {
return email;
}

public int getAttemptCount() {
return attemptCount;
}
}

Use Context in Handler​

@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
// βœ… Access exception fields!
String email = ex.getEmail();
int attempts = ex.getAttemptCount();

// Log for security monitoring
securityLogger.warn("Failed login: email={}, attempts={}", email, attempts);

// Block account after 5 attempts
if (attempts >= 5) {
accountLockService.lockAccount(email);
return ResponseEntity.status(403)
.body(new ErrorResponse("ACCOUNT_LOCKED", "Too many failed attempts"));
}

return ResponseEntity.status(401)
.body(new ErrorResponse("AUTH_FAILED", "Invalid username or password"));
}

Try doing this with RuntimeException only! You can't access email or attemptCount because they don't exist.


Complete Example: User Registration​

Custom Exceptions​

public class EmailAlreadyExistsException extends RuntimeException {
private final String email;

public EmailAlreadyExistsException(String email) {
super("Email already registered: " + email);
this.email = email;
}

public String getEmail() {
return email;
}
}

public class WeakPasswordException extends RuntimeException {
private final List<String> violations;

public WeakPasswordException(List<String> violations) {
super("Password does not meet requirements");
this.violations = violations;
}

public List<String> getViolations() {
return violations;
}
}

public class InvalidEmailFormatException extends RuntimeException {
private final String invalidEmail;

public InvalidEmailFormatException(String invalidEmail) {
super("Invalid email format: " + invalidEmail);
this.invalidEmail = invalidEmail;
}

public String getInvalidEmail() {
return invalidEmail;
}
}

Service Using Custom Exceptions​

@Service
public class UserService {

public UserResponse register(RegisterRequest request) {
// Validate email format
if (!isValidEmail(request.getEmail())) {
throw new InvalidEmailFormatException(request.getEmail());
}

// Check if email already exists
if (userRepository.existsByEmail(request.getEmail())) {
throw new EmailAlreadyExistsException(request.getEmail());
}

// Validate password strength
List<String> violations = validatePassword(request.getPassword());
if (!violations.isEmpty()) {
throw new WeakPasswordException(violations);
}

// Create user
User user = new User();
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
userRepository.save(user);

return new UserResponse(user.getId(), user.getEmail());
}
}

Handlers with Rich Error Messages​

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailExists(EmailAlreadyExistsException ex) {
ErrorResponse error = new ErrorResponse(
"EMAIL_EXISTS",
"Email already registered",
Map.of("email", ex.getEmail()) // βœ… Include email in response
);
return ResponseEntity.status(409).body(error);
}

@ExceptionHandler(WeakPasswordException.class)
public ResponseEntity<ErrorResponse> handleWeakPassword(WeakPasswordException ex) {
ErrorResponse error = new ErrorResponse(
"WEAK_PASSWORD",
"Password does not meet requirements",
Map.of("violations", ex.getViolations()) // βœ… Include violations
);
return ResponseEntity.status(400).body(error);
}

@ExceptionHandler(InvalidEmailFormatException.class)
public ResponseEntity<ErrorResponse> handleInvalidEmail(InvalidEmailFormatException ex) {
ErrorResponse error = new ErrorResponse(
"INVALID_EMAIL",
"Email format is invalid",
Map.of("email", ex.getInvalidEmail()) // βœ… Include invalid email
);
return ResponseEntity.status(400).body(error);
}
}

Client Receives Rich Error Response​

{
"errorCode": "WEAK_PASSWORD",
"message": "Password does not meet requirements",
"details": {
"violations": [
"Must be at least 8 characters",
"Must contain at least one uppercase letter",
"Must contain at least one number"
]
}
}

Try building this rich error response with RuntimeException only! You can't access violations list.


Summary​

The Question​

"Why create custom exceptions extending RuntimeException? Isn't it just to get a descriptive name?"

The Answer​

No! It's about type-safe exception handling.

What You GetRuntimeException OnlyCustom Exceptions
Descriptive name❌ Generic nameβœ… Self-documenting
Type-safe handling❌ String parsingβœ… Type matching
Different handlers❌ One big if-elseβœ… Separate methods
Compiler checking❌ Noβœ… Yes
Easy to extend❌ Modify existing codeβœ… Add new handler
Rich context❌ Only message stringβœ… Custom fields
Different retry logic❌ Hard to implementβœ… Easy per exception
Maintainability❌ Fragileβœ… Robust

Key Takeaways​

  1. Custom exceptions enable type-safe error handling - no string parsing needed
  2. Each exception type gets its own handler - clean separation of concerns
  3. IS-A relationship enables polymorphism - Spring matches most specific handler first
  4. Custom fields provide rich context - beyond just message strings
  5. Easy to extend - add new exception + handler without modifying existing code
  6. Different errors require different handling - retry, logging, alerting, emails

Bottom line: Custom exceptions aren't just fancy naming - they're the foundation of robust, type-safe, maintainable error handling in Spring Boot! 🎯