[MERGED] Controller vs Service Exception Handling

📚 Series Navigation:
Page 1: Exception Reference & HTTP MappingFoundation mappings and priority order
Page 2: Exception Architecture & Custom DesignCustom exception design and GlobalExceptionHandler
Page 3: Controller vs Service PatternsImplementation decision frameworkYou 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 and MemberErrorCode) 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 DesignLearn how to design and build custom exceptions

🏠 Start: Exception Reference & HTTP MappingFoundation mappings and priority order

🎥 YouTube Series: “Exception Handling in RESTful API Microservices”
This is Episode 3: Implementation patterns for Controller vs Service exception handling

Leave a Comment

Your email address will not be published. Required fields are marked *