📚 Series Navigation:
Page 1: Exception Reference & HTTP Mapping – Foundation mappings and priority order
Page 2: Exception Architecture & Custom Design – Custom exception design and GlobalExceptionHandler
Page 3: Controller vs Service Patterns – Implementation decision framework ← You are here
In Spring-based REST APIs, one of the most critical decisions is determining where to handle different types of failures. Should you handle them directly in the Controller layer, or let them flow to the GlobalExceptionHandler through Service layer exceptions?
This article provides a clear decision framework and practical examples to help you make the right choice every time.
🎯 The Core Decision Framework
The key question that determines your approach is:
“Does the failure condition require follow-up processing?”
- YES → Service throws exception → GlobalExceptionHandler
- NO → Controller handles as data presentation
🔄 The Two Approaches
Approach 1: Controller Layer Handling
Use this when the “failure” is actually a legitimate result of a data query operation. The controller receives data from the service and makes decisions about how to present that data to the client.
@GetMapping("/users/search")
public ResponseEntity<ServiceResponse<List<Member>>> searchUsers(
@RequestParam String city) {
List<Member> users = memberService.findUsersByCity(city);
if (users.isEmpty()) {
return ResponseEntity.ok(ServiceResponse.success(
"No users found in " + city,
users
));
} else {
return ResponseEntity.ok(ServiceResponse.success(
"Found " + users.size() + " users",
users
));
}
}
Approach 2: Service Layer Exception Throwing
Use this when the service layer encounters a genuine business rule violation or system failure. The service throws a custom exception (designed in Page 2) which flows to the GlobalExceptionHandler.
// Service Layer
@Service
public class MemberService {
public Member deleteUser(String email) {
Optional<Member> member = memberRepository.findMemberByEmail(email);
if (member.isEmpty()) {
throw new UserNotFoundException(MemberErrorCode.USER_NOT_FOUND);
}
memberRepository.delete(member.get());
return member.get();
}
}
// Controller Layer
@PostMapping("/users/delete")
public ResponseEntity<ServiceResponse<Boolean>> deleteUser(@RequestBody String email) {
memberService.deleteUser(email); // Exception flows to GlobalExceptionHandler
return ResponseEntity.ok(ServiceResponse.success("User deleted successfully", true));
}
💡 Note: The GlobalExceptionHandler setup and custom exception design (like
UserNotFoundException
andMemberErrorCode
) are covered in Page 2: Exception Architecture.
📋 Decision Criteria
✅ Use Controller Layer Handling When:
Simple Information Presentation – No follow-up processing needed beyond formatting the response:
- Searching for users by criteria (results may be empty)
- Checking email availability for registration
- Listing recent activity (may have no recent activity)
- Finding optional profile information
Legitimate Result Variations – Different outcomes are all valid:
- Search returns 0 or more results
- Optional data that may not be configured
- Status checks that can be true or false
@GetMapping("/users/check-email")
public ResponseEntity<ServiceResponse<Boolean>> checkEmailAvailability(
@RequestParam String email) {
boolean isAvailable = !memberService.existsByEmail(email);
String message = isAvailable
? "Email is available"
: "Email is already taken";
return ResponseEntity.ok(ServiceResponse.success(message, isAvailable));
}
🚨 Use Service Layer Exception Throwing When:
Complex Business Operations – Failure requires follow-up processing, cleanup, or business workflows:
- Deleting a user account (user should exist)
- Updating user profile (user should exist)
- Processing payments (account should have funds)
- Authenticating users (credentials should be valid)
System Failures – Infrastructure or external service issues:
- Database connection failures
- External API timeouts
- Configuration missing or invalid
Security Violations – Authentication or authorization failures:
- Expired or invalid tokens
- Insufficient permissions
- Malformed requests
@Service
public class AuthService {
public TokenResponse refreshToken(String refreshToken) {
if (!tokenValidator.isValid(refreshToken)) {
throw new InvalidTokenException(AuthErrorCode.INVALID_TOKEN);
}
if (tokenValidator.isExpired(refreshToken)) {
throw new TokenExpiredException(AuthErrorCode.TOKEN_EXPIRED);
}
return generateNewTokens(refreshToken);
}
}
📚 Reference: For HTTP status code mappings of these exceptions, see Page 1: Exception Reference.
🔍 Practical Examples
Example 1: User Profile Operations
Scenario A: Get User Profile (Data Query → Controller Handling)
// Service - returns what's available
public Optional<UserProfile> getUserProfile(Long userId) {
return profileRepository.findByUserId(userId);
}
// Controller - handles optional result
@GetMapping("/profile/{userId}")
public ResponseEntity<ServiceResponse<UserProfile>> getProfile(@PathVariable Long userId) {
Optional<UserProfile> profile = memberService.getUserProfile(userId);
if (profile.isPresent()) {
return ResponseEntity.ok(ServiceResponse.success("Profile found", profile.get()));
} else {
return ResponseEntity.ok(ServiceResponse.success("Profile not configured", null));
}
}
Scenario B: Update User Profile (Business Operation → Service Exception)
// Service - expects user to exist for business operation
public UserProfile updateUserProfile(Long userId, ProfileUpdateRequest request) {
UserProfile profile = profileRepository.findByUserId(userId)
.orElseThrow(() -> new UserNotFoundException(MemberErrorCode.USER_NOT_FOUND));
profile.updateFrom(request);
return profileRepository.save(profile);
}
// Controller - simple success path
@PutMapping("/profile/{userId}")
public ResponseEntity<ServiceResponse<UserProfile>> updateProfile(
@PathVariable Long userId,
@RequestBody ProfileUpdateRequest request) {
UserProfile updated = memberService.updateUserProfile(userId, request);
return ResponseEntity.ok(ServiceResponse.success("Profile updated", updated));
}
Example 2: Search vs Delete Operations
Search Users (Controller Handling)
@GetMapping("/users")
public ResponseEntity<ServiceResponse<Page<Member>>> getUsers(
@RequestParam(required = false) String city,
@RequestParam(required = false) String interests,
Pageable pageable) {
Page<Member> users = memberService.searchUsers(city, interests, pageable);
if (users.isEmpty()) {
return ResponseEntity.ok(ServiceResponse.success("No users found", users));
} else {
return ResponseEntity.ok(ServiceResponse.success(
String.format("Found %d users", users.getTotalElements()),
users
));
}
}
Delete User (Service Exception)
@DeleteMapping("/users/{userId}")
public ResponseEntity<ServiceResponse<Void>> deleteUser(@PathVariable Long userId) {
memberService.deleteUser(userId); // Throws UserNotFoundException if not found
return ResponseEntity.ok(ServiceResponse.success("User deleted successfully", null));
}
⚠️ Common Anti-Pattern: Treating Query Results as Exceptions
The Problem
One of the most common mistakes is throwing exceptions for normal query results, particularly empty search results:
// ❌ WRONG - Treating search as business operation
public List<Member> findUsersByCity(String city) {
List<Member> users = memberRepository.findByCity(city);
if (users.isEmpty()) {
throw new UsersNotFoundException("No users found in " + city); // Wrong!
}
return users;
}
Why This Anti-Pattern Is Problematic
1. Semantic Confusion
// The code semantics are misleading:
throw new UsersNotFoundException("No users found"); // Suggests an error condition
// But handled as: HTTP 200 OK = success?
// Developers reading the service code think:
// "This throws an exception - something must be wrong!"
// But actually: "It's handled as success in GlobalExceptionHandler"
This creates a semantic mismatch where you’re using exception syntax for normal control flow.
2. Performance Overhead
// Exceptions are expensive in Java
public List<Member> findUsersByCity(String city) {
List<Member> users = memberRepository.findByCity(city);
if (users.isEmpty()) {
throw new UsersNotFoundException("No users found"); // Stack trace creation is costly
}
return users;
}
Impact: If 30% of your searches return empty results, you’re creating expensive exception objects for normal operations.
3. Debugging and Monitoring Confusion
// Your logs will show exceptions for normal operations:
2025-06-26 10:30:00 [WARN] UsersNotFoundException: No users found in Tokyo
2025-06-26 10:30:01 [WARN] UsersNotFoundException: No users found in Paris
2025-06-26 10:30:02 [WARN] UsersNotFoundException: No users found in Berlin
// Operations team: "Why are we getting so many exceptions?"
// Developer: "Those aren't real errors, just empty searches"
// Operations team: "Then why are they exceptions??"
This pollutes your logs with fake errors, making it harder to identify real problems.
The Better Approach: Semantic Clarity
// ✅ CORRECT - Service returns data as-is
@Service
public class MemberService {
public List<Member> findUsersByCity(String city) {
// Empty list is a perfectly valid result
return memberRepository.findByCity(city);
}
}
// ✅ CORRECT - Controller handles presentation
@GetMapping("/users/search")
public ResponseEntity<ServiceResponse<List<Member>>> searchUsers(@RequestParam String city) {
List<Member> users = memberService.findUsersByCity(city);
// Handle both cases as successful operations
if (users.isEmpty()) {
return ResponseEntity.ok(ServiceResponse.success(
"Search completed successfully. No users found in " + city,
users
));
} else {
return ResponseEntity.ok(ServiceResponse.success(
"Found " + users.size() + " users in " + city,
users
));
}
}
🔑 Key Principle: Follow-up Processing Test
✅ Exception Appropriate – Business Operation
public Member deleteUser(Long userId) {
Member member = memberRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found for deletion"));
// Follow-up processing needed:
tokenService.revokeAllTokens(member.getId()); // Cleanup tokens
notificationService.sendDeletionConfirmation(member.getEmail()); // Send notification
auditService.logUserDeletion(member); // Audit logging
memberRepository.delete(member); // Delete from DB
return member;
}
✅ No Exception Needed – Query Operation
public List<Member> searchUsers(String criteria) {
// Empty results are information, not errors
// No follow-up processing needed beyond presenting results
return memberRepository.findByCriteria(criteria);
}
📋 Implementation Guidelines
Service Layer Guidelines
- Throw exceptions for business failures: When business rules are violated or expected resources don’t exist
- Return data structures for queries: Use
Optional
,List
, or other appropriate data types - Keep exceptions specific: Use meaningful exception types with descriptive messages (from Page 2)
- Don’t catch and convert: Avoid try-catch blocks that convert exceptions to boolean returns
Controller Layer Guidelines
- Handle success scenarios: Focus on the happy path for business operations
- Manage result variations: Handle different states for data queries appropriately
- Let exceptions bubble up: Don’t catch exceptions that should go to GlobalExceptionHandler
- Use appropriate HTTP status codes: 200 for success, even when data is empty (see Page 1 for complete mappings)
🎯 Conclusion
The key to effective exception handling in Spring REST APIs is understanding whether a failure condition requires follow-up processing:
- Complex business operations that need cleanup, validation, or workflows → Service layer exceptions
- Simple information queries that only need response formatting → Controller layer handling
This approach leads to cleaner, more maintainable code with consistent API responses. Controllers focus on HTTP semantics, while business logic remains clean and intention-revealing in the service layer.
📚 Series Navigation
⬅️ Previous: Exception Architecture & Custom Design – Learn how to design and build custom exceptions
🏠 Start: Exception Reference & HTTP Mapping – Foundation mappings and priority order
🎥 YouTube Series: “Exception Handling in RESTful API Microservices”
This is Episode 3: Implementation patterns for Controller vs Service exception handling