4.2 Custom Domain Exceptions
← Back to Chapter 4: Exception Handling, Logging & Production Support
Domain-driven design emphasizes creating domain-specific exceptions to model business rule violations explicitly, rather than overloading generic Java exceptions (RuntimeException or IllegalArgumentException).
What is Custom Exception
In real projects, we usually create our own exceptions.
Example:
public class CustomerNotFoundException extends RuntimeException {
public CustomerNotFoundException(String message) {
super(message);
}
}
Then:
Customer customer = repository.findById(customerId)
.orElseThrow(() ->
new CustomerNotFoundException("Customer not found"));
And globally:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomerNotFoundException.class)
public ResponseEntity<String> handleCustomerNotFound(
CustomerNotFoundException ex) {
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(ex.getMessage());
}
}
Why Create Custom Exceptions?
Instead of throwing generic:
RuntimeException
we can create meaningful exceptions.
For example:
CustomerNotFoundException
InvalidCustomerException
VerificationException
ThirdPartyServiceException
SessionExpiredException
This makes the code easier to understand and allows us to handle different exceptions differently.
Clean Domain Exception Hierarchy
Always base business domain exceptions on unchecked exceptions (RuntimeException) so that service signatures remain clean and transactional rollback occurs automatically:
┌────────────────────────┐
│ RuntimeException │
└───────────┬────────────┘
│
┌───────────▼────────────┐
│ BaseBusinessException │
└───────────┬────────────┘
┌──────────────────────┼──────────────────────┐
│ │ │
┌────────▼───────────┐ ┌────────▼───────────┐ ┌────────▼───────────┐
│ ResourceNotFound │ │ ConflictException │ │ InsufficientBalance│
│ Exception (404) │ │ (Duplicate) (409) │ │ Exception (422) │
└────────────────────┘ └────────────────────┘ └────────────────────┘
Common HTTP Status Codes
| Status | Meaning | Example |
|---|---|---|
| 200 | OK | Successful GET |
| 201 | Created | Successful POST |
| 202 | Accepted | Request accepted for processing |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid request |
| 401 | Unauthorized | Missing/invalid authentication |
| 403 | Forbidden | User doesn't have permission |
| 404 | Not Found | Customer doesn't exist |
| 405 | Method Not Allowed | Wrong HTTP method |
| 409 | Conflict | Duplicate/conflicting data |
| 422 | Unprocessable Content | Validation/business-rule issue |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unexpected server error |
| 502 | Bad Gateway | Invalid response from upstream |
| 503 | Service Unavailable | Service temporarily unavailable |
| 504 | Gateway Timeout | Upstream service timed out |
Designing the Base Exception Class
public abstract class BaseBusinessException extends RuntimeException {
private final String errorCode;
private final Instant timestamp;
protected BaseBusinessException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
this.timestamp = Instant.now();
}
protected BaseBusinessException(String errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
this.timestamp = Instant.now();
}
public String getErrorCode() { return errorCode; }
public Instant getTimestamp() { return timestamp; }
}
Specialized Concrete Exceptions
public class ResourceNotFoundException extends BaseBusinessException {
public ResourceNotFoundException(String resourceName, Object identifier) {
super("RESOURCE_NOT_FOUND", "%s with identifier [%s] was not found".formatted(resourceName, identifier));
}
}
public class DuplicateResourceException extends BaseBusinessException {
public DuplicateResourceException(String resourceName, String field, Object value) {
super("DUPLICATE_RESOURCE", "%s already exists with %s: [%s]".formatted(resourceName, field, value));
}
}
Best Practices & Anti-Patterns
| Recommended Best Practice | Anti-Pattern to Avoid |
|---|---|
Use unchecked exceptions (RuntimeException) so @Transactional automatically triggers rollback. | Using checked exceptions that require throwing in every method signature. |
| Provide structured context (e.g., entity name, ID). | Throwing raw new RuntimeException("Not found"). |
Centralize mapping in @RestControllerAdvice. | Catching exceptions in each individual controller method. |
Use @ResponseStatus directly on the exception class for simple APIs. | Cluttering business classes with HTTP status codes. |