Which Layer to throw exception in
In modern production microservices and enterprise applications, the industry standard is to throw business exceptions (like `AccountNotFoundException`) in the Service Layer rather than the Controller layer.
Why the Service Layer is the Right Choice ?
The Controller Shouldn’t Know Database Realities
The Controller’s only job is to handle the HTTP protocol (parsing requests, managing status codes, validating input formats). It shouldn’t know whether data comes from a database, a cache, or a third-party API. If the service layer remains silent and returns null or an empty Optional, you force the controller to take on business logic validation.
Reusability (The Core Reason)
An account might be looked up by multiple entry points, not just your REST controller. For example, a scheduled background job, a Kafka/RabbitMQ message listener, or a gRPC endpoint might also need to call accountService.processPayment(accountId).
- If the exception is thrown in the Service: All channels are automatically protected.
- If the exception is thrown in the Controller: The background job will get a
nullreference, crash with aNullPointerException, and skip safety validation.
Keeps Service Interfaces Clean
Instead of cluttering your service methods with complex validation flows or forcing them to return Optional<Account> to the controller (which forces the controller to do the unwrapping), your service methods can directly return the required object or throw an error.
TODO - Merge 1
How the Layer Architecture Connects in Production
Here is how this pattern is cleanly executed across layers using a Global Exception Handler (like @ControllerAdvice in Spring Boot) to keep both layers exceptionally clean.
The Service Layer (Throws the Exception)
The service owns the business rules. If an account isn’t there, it’s a domain error.
@Service
public class AccountService {
@Autowired
private AccountRepository accountRepository;
public AccountDetailsResponse getAccountDetails(String accountId) {
Account account = getAccountOrThrow(accountId);
return convertToDto(account);
}
// Perfect placement: encapsulated inside the business layer
private Account getAccountOrThrow(String accountId) {
return accountRepository
.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException("Account not found: " + accountId));
}
}The Controller Layer (Stays Lean)
The controller simply passes the ID to the service. It does not use try-catch blocks. It assumes everything works.
@RestController
@RequestMapping("/api/accounts")
public class AccountController {
@Autowired
private AccountService accountService;
@GetMapping("/{id}")
public ResponseEntity<AccountDetailsResponse> getAccount(@PathVariable String id) {
// No checks or catches here. Clean delegation.
AccountDetailsResponse response = accountService.getAccountDetails(id);
return ResponseEntity.ok(response);
}
}The Global Exception Handler (Translates to HTTP)
Instead of forcing the controller to catch the exception, Spring’s engine intercepts the AccountNotFoundException thrown by the service and automatically converts it into a safe, well-formatted REST error response.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<ErrorResponse> handleAccountNotFound(AccountNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(), // 404
ex.getMessage(),
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
}The Exception to the Rule
The only time you should throw exceptions in the Controller layer is for pure HTTP-level validation failures. For example, if a client submits an invalid email format, an expired authentication token, or a missing required query parameter, the controller can reject it immediately before hitting the service layer.
Are your custom exceptions currently checked exceptions (extending Exception) or runtime exceptions (extending RuntimeException)? I can show you how choosing the right base class changes how clean your code stays across layers.
Checked or Unchecked Exception Handling
In modern Java development, you should almost always throw RuntimeExceptions (unchecked exceptions) for business logic failures like AccountNotFoundException.
Checked exceptions are largely considered an anti-pattern today, it forces the caller to handle them, which quickly ruins clean code.
The Flaw of Checked Exceptions in Modern Architecture
Checked exceptions (extending Exception) were originally designed with good intentions: to force developers to explicitly think about and handle expected failures. However, in enterprise systems, they create two massive issues:
They Ruin Lambda Expressions and Streams
- Checked exceptions do not play well with Java Functional Interfaces (
Supplier,Function,Consumer). - If a method throws a checked exception, you cannot use it cleanly inside an
Optionalchain or a Stream without wrapping it in an uglytry-catchblock.
// HORRIBLE: If AccountNotFoundException was a Checked Exception
public AccountDetailsResponse getAccountDetails(String id) {
return accountRepository.findById(id)
.orElseThrow(() -> {
try {
throw new AccountNotFoundException("Missing"); // Won't compile cleanly!
} catch (AccountNotFoundException e) {
throw new RuntimeException(e); // Forced to wrap it anyway
}
});
}They Cause “Catch and Log” Pollution
When forced to handle a checked exception that they can’t actually fix, developers often write empty or meaningless catch blocks just to make the compiler happy:
// Anti-pattern caused by checked exceptions
try {
accountService.transferMoney(from, to, amount);
} catch (InsufficientFundsException e) {
e.printStackTrace(); // The app keeps running in an invalid state!
}Unchecked Exceptions Are Ideal for Global Handlers
When an AccountNotFoundException occurs, there is usually nothing the application can do programmatically to “recover” or “fix” the state on the fly. The only logical recovery step is to abort the current transaction and tell the user they made a mistake.
By making your custom exceptions extend RuntimeException:
- Your method signatures stay clean: No more bubbling up
throws AccountNotFoundExceptionthrough 5 different layers of code. - Spring handles the rollback automatically: Frameworks like Spring Boot will automatically roll back a database transaction (
@Transactional) only when aRuntimeExceptionis thrown. If a checked exception is thrown, Spring commits the transaction anyway unless you explicitly configure it otherwise. - The Global Exception Handler catches it seamlessly: The exception propagates cleanly up the call stack until it hits your
@ControllerAdvice, which smoothly turns it into a404 Not FoundAPI response.
Is There Ever a Reason to Use a Checked Exception?
The rule of thumb in modern software engineering :
Only use a checked exception if the caller can realistically recover from it programmatically.
- Example of a valid Checked Exception (Contrived):
- A file-upload service tries to save a profile picture to a premium cloud storage bucket. If it throws a
StorageQuotaExceededException, the calling method might realistically catch it and immediately execute a fallback path to save it to a cheaper local drive instead.
- A file-upload service tries to save a profile picture to a premium cloud storage bucket. If it throws a
- Everything else:
AccountNotFoundException,InvalidPasswordException,OrderAlreadyProcessedExceptionare all terminal business logic violations. They should always beRuntimeException.
Would you like to see how to structure a base architecture for your custom exceptions (e.g., creating a base BusinessException that all your other exceptions extend) to make your global error handling even more scalable?
TODO : Merge 2
The Base Architecture Pattern for Custom Exceptions
Instead of having dozens of individual exceptions directly extending RuntimeException, enterprise applications use an inheritance hierarchy. You create a single BusinessException as the parent class, and have your specific errors (like AccountNotFoundException or InsufficientFundsException) inherit from it.
This allows your Global Exception Handler to catch everything in one place, while still giving you the flexibility to return distinct HTTP status codes and custom error messages.
Step 1: Create the Base Class
This class captures the essential pieces of information needed for any error in your application: an internal error code, an HTTP status code, and the error message.
public abstract class BusinessException extends RuntimeException {
private final String errorCode;
private final int statusCode;
protected BusinessException(String message, String errorCode, int statusCode) {
super(message);
this.errorCode = errorCode;
this.statusCode = statusCode;
}
public String getErrorCode() { return errorCode; }
public int getStatusCode() { return statusCode; }
}Step 2: Create Specific Subclasses
Now, your domain-specific exceptions become incredibly compact. They simply pass their configuration up to the parent constructor.
The “Not Found” Variant (404)
public class AccountNotFoundException extends BusinessException {
public AccountNotFoundException(String accountId) {
super(
"Account not found: " + accountId,
"ERR_ACCOUNT_NOT_FOUND",
404
);
}
}The “Bad Request” Variant (400)
public class InsufficientFundsException extends BusinessException {
public InsufficientFundsException(String accountId, double required, double available) {
super(
String.format("Account %s lacks funds. Required: %.2f, Available: %.2f", accountId, required, available),
"ERR_INSUFFICIENT_FUNDS",
400
);
}
}Step 3: Implement the Global Exception Handler
Because every business exception inherits from BusinessException, you only need one handler method in your @ControllerAdvice to manage your entire business exception library. It reads the dynamic status code and error code directly from the exception object.
@ControllerAdvice
public class GlobalExceptionHandler {
// This single method handles AccountNotFound, InsufficientFunds, and any future business errors
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessExceptions(BusinessException ex) {
ErrorResponse error = new ErrorResponse(
ex.getStatusCode(),
ex.getErrorCode(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity
.status(ex.getStatusCode())
.body(error);
}
}The Standard Production Payload Template
When a frontend application or another microservice calls your API and hits an error, they will receive a clean, structured JSON response like this:
{
"status": 404,
"errorCode": "ERR_ACCOUNT_NOT_FOUND",
"message": "Account not found: ACC-99231",
"timestamp": "2026-09-22T22:24:00"
}Why this approach scales beautifully
If you add a new feature next week (e.g., LoanApplicationException), you don’t need to touch your GlobalExceptionHandler at all. You just make the new class extend BusinessException, define its HTTP status code (e.g., 422 Unprocessable Entity), and the architecture automatically routes and formats it correctly.