Custom Exceptions — Practical Demo
Hands-on examples for Custom Exceptions. Walk through building a real exception hierarchy from scratch.
Make sure you understand the Exception Hierarchy and try/catch/finally before working through these demos.
Example 1: Minimal Custom Exception
The simplest useful custom exception — two constructors are the minimum.
public class ResourceNotFoundException extends RuntimeException { // ← unchecked
public ResourceNotFoundException(String message) {
super(message); // ← passes message up to Throwable
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, cause); // ← cause constructor: never omit this
}
}
public class Example1Demo {
static String findUser(long id) {
if (id <= 0) {
throw new ResourceNotFoundException("User not found with id=" + id);
}
return "User#" + id;
}
public static void main(String[] args) {
try {
System.out.println(findUser(42)); // ok
System.out.println(findUser(-1)); // throws
} catch (ResourceNotFoundException e) {
System.out.println("Caught: " + e.getMessage());
System.out.println("Is unchecked: " + (e instanceof RuntimeException));
}
}
}
Expected Output:
User#42
Caught: User not found with id=-1
Is unchecked: true
Example 2: Typed Context Fields
Adding a typed field so callers can read the ID without parsing the message string.
public class UserNotFoundException extends RuntimeException {
private final long userId; // ← immutable, typed context field
public UserNotFoundException(long userId) {
super("User not found: " + userId); // ← message for humans
this.userId = userId;
}
public UserNotFoundException(long userId, Throwable cause) {
super("User not found: " + userId, cause);
this.userId = userId;
}
public long getUserId() { return userId; } // ← accessor for code
}
public class Example2Demo {
static String fetchUser(long id) {
if (id == 99) throw new UserNotFoundException(id);
return "User[" + id + "]";
}
public static void main(String[] args) {
try {
System.out.println(fetchUser(1));
System.out.println(fetchUser(99)); // throws
} catch (UserNotFoundException e) {
// Typed access — no string parsing needed
System.out.printf("No user found for id=%d%n", e.getUserId());
System.out.println("Message: " + e.getMessage());
}
}
}
Expected Output:
User[1]
No user found for id=99
Message: User not found: 99
Typed fields on exceptions let monitoring, logging, and error responses reference specific identifiers without parsing fragile message strings that might change.
Example 3: Full Exception Hierarchy
Building a two-level hierarchy for an order management domain.
// Base exception — every domain exception extends this
public class AppException extends RuntimeException {
private final int httpStatus;
private final String errorCode;
public AppException(String message, int httpStatus, String errorCode) {
super(message);
this.httpStatus = httpStatus;
this.errorCode = errorCode;
}
public AppException(String message, Throwable cause, int httpStatus, String errorCode) {
super(message, cause); // ← always chain the cause
this.httpStatus = httpStatus;
this.errorCode = errorCode;
}
public int getHttpStatus() { return httpStatus; }
public String getErrorCode() { return errorCode; }
}
public class OrderNotFoundException extends AppException {
private final String orderId;
public OrderNotFoundException(String orderId) {
super("Order not found: " + orderId, 404, "ORDER_NOT_FOUND");
this.orderId = orderId;
}
public String getOrderId() { return orderId; }
}
public class OrderAlreadyPaidException extends AppException {
public OrderAlreadyPaidException(String orderId) {
super("Order " + orderId + " has already been paid", 409, "ORDER_ALREADY_PAID");
}
}
public class Example3Demo {
enum OrderState { NEW, PAID, CANCELLED }
static void payOrder(String orderId, OrderState state) {
if (state == OrderState.PAID) {
throw new OrderAlreadyPaidException(orderId); // ← specific subtype
}
}
static String getOrder(String orderId) {
if (orderId.startsWith("MISSING")) {
throw new OrderNotFoundException(orderId); // ← specific subtype
}
return "Order[" + orderId + "]";
}
public static void main(String[] args) {
// Catch specific subtype
try {
payOrder("ORD-001", OrderState.PAID);
} catch (OrderAlreadyPaidException e) {
System.out.printf("Specific catch — code=%s, status=%d%n",
e.getErrorCode(), e.getHttpStatus());
}
// Catch base type — works for any AppException subtype
try {
getOrder("MISSING-999");
} catch (AppException e) {
System.out.printf("Base catch — [%s] %s (HTTP %d)%n",
e.getErrorCode(), e.getMessage(), e.getHttpStatus());
}
}
}
Expected Output:
Specific catch — code=ORDER_ALREADY_PAID, status=409
Base catch — [ORDER_NOT_FOUND] Order not found: MISSING-999 (HTTP 404)
Example 4: Exception Chaining — Wrapping Infrastructure Exceptions
Demonstrates the correct way to translate a low-level exception into a domain exception while preserving the original cause.
import java.sql.SQLException;
public class ExceptionChainingDemo {
// Simulates a repository layer — converts SQL exception to domain exception
static String findOrderFromDb(String orderId) throws SQLException {
if (orderId.isBlank()) {
throw new SQLException("JDBC: query failed — orderId cannot be blank", "42000");
}
return "Order[" + orderId + "]";
}
// Service layer — translates infrastructure exceptions at the boundary
static String getOrder(String orderId) {
try {
return findOrderFromDb(orderId);
} catch (SQLException e) {
// Wrap: add domain context, preserve original SQL cause
throw new AppException(
"Failed to load order: " + orderId,
e, // ← original SQLException preserved as cause
500, "DB_ERROR"
);
}
}
public static void main(String[] args) {
try {
getOrder(""); // blank orderId — triggers SQL exception
} catch (AppException e) {
System.out.println("Domain exception: " + e.getMessage());
System.out.println("Root cause type: " + e.getCause().getClass().getSimpleName());
System.out.println("Root cause msg: " + e.getCause().getMessage());
}
}
}
Expected Output:
Domain exception: Failed to load order:
Root cause type: SQLException
Root cause msg: JDBC: query failed — orderId cannot be blank
The most common mistake with exception chaining is forgetting to pass the cause: throw new AppException("message", 500, "ERR") — this loses the SQLException stack trace entirely. Always use the constructor that accepts Throwable cause.
Example 5: Spring Boot @ControllerAdvice Integration (Conceptual)
This shows how the exception hierarchy maps to HTTP responses in a Spring Boot REST API.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handles ALL AppException subtypes (OrderNotFoundException, etc.)
@ExceptionHandler(AppException.class)
public ResponseEntity<ErrorResponse> handleApp(AppException ex) {
return ResponseEntity
.status(ex.getHttpStatus()) // ← typed field → HTTP status
.body(new ErrorResponse(ex.getErrorCode(), ex.getMessage()));
}
// More specific handler — Spring picks this over the base handler for OrderNotFoundException
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
// Could add special headers, metrics, etc. for 404s
return ResponseEntity
.notFound()
.build();
}
}
// Simple error response DTO
record ErrorResponse(String code, String message) {}
The controller itself stays clean — no try/catch:
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/{id}")
public OrderDto getOrder(@PathVariable String id) {
return orderService.getOrder(id); // ← throws OrderNotFoundException; handled by advice
}
}
Exercises
- Easy: Create a
ValidationException extends AppExceptionwith aList<String> violationsfield that stores fields that failed validation. - Medium: Write a
PaymentServicethat throwsInsufficientFundsException(includesBigDecimal requiredandBigDecimal availablefields) and aPaymentGatewayException(wraps aThrowablecause from an HTTP client). Catch both in a demomainmethod. - Hard: Design a complete exception hierarchy for a library management system (books, members, loans). Include a base
LibraryException, domain-specific subtypes for each entity, and aGlobalExceptionHandlerthat maps each to appropriate HTTP status codes.
Back to Topic
Return to the Custom Exceptions note for theory, interview questions, and further reading.