Skip to main content

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​

StatusMeaningExample
200OKSuccessful GET
201CreatedSuccessful POST
202AcceptedRequest accepted for processing
204No ContentSuccessful DELETE
400Bad RequestInvalid request
401UnauthorizedMissing/invalid authentication
403ForbiddenUser doesn't have permission
404Not FoundCustomer doesn't exist
405Method Not AllowedWrong HTTP method
409ConflictDuplicate/conflicting data
422Unprocessable ContentValidation/business-rule issue
429Too Many RequestsRate limit exceeded
500Internal Server ErrorUnexpected server error
502Bad GatewayInvalid response from upstream
503Service UnavailableService temporarily unavailable
504Gateway TimeoutUpstream 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 PracticeAnti-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.