Part 3: Implementation – Controller vs Service Patterns

💻 Part 3: Implementation – Controller vs Service Patterns

💻 Implementation Patterns

Mastering Controller vs Service Layer Exception Handling

🎯 What You’ll Master

  • Decision Framework: When to use exceptions vs normal flow
  • Controller Patterns: HTTP-specific exception handling
  • Service Patterns: Business logic exception management
  • Anti-Patterns: Common mistakes to avoid

🤔 The Critical Decision: Exception vs Normal Flow

The most important decision in exception handling is when to use exceptions versus normal control flow. This determines your system’s performance, maintainability, and clarity.

✅ Use Exceptions For

  • Unexpected failures (database down, network timeout)
  • Business rule violations (duplicate email, insufficient balance)
  • Security issues (unauthorized access, invalid tokens)
  • System errors (configuration missing, service unavailable)

❌ Don’t Use Exceptions For

  • Expected conditions (user not found in search)
  • Control flow (pagination, filtering results)
  • Optional operations (cache miss, feature flags)
  • Performance-critical paths (hot loops, frequent calls)

🎮 Controller Layer Patterns

Controllers should handle HTTP-specific concerns and delegate business logic to services. Exception handling at this layer focuses on request/response transformation.

✅ Good Controller Pattern

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    @PostMapping
    public ResponseEntity<ServiceResponse<User>> createUser(@Valid @RequestBody CreateUserRequest request) {
        // Controller only handles HTTP concerns
        User user = userService.createUser(request);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ServiceResponse.success("User created successfully", user));
    }

    @GetMapping("/{id}")
    public ResponseEntity<ServiceResponse<User>> getUser(@PathVariable Long id) {
        // Let GlobalExceptionHandler handle UserNotFoundException
        User user = userService.findById(id);
        
        return ResponseEntity.ok(ServiceResponse.success(user));
    }

    @GetMapping("/search")
    public ResponseEntity<ServiceResponse<List<User>>> searchUsers(
            @RequestParam String query,
            @RequestParam(defaultValue = "0") int page) {
        
        // Expected empty results - use normal flow, not exceptions
        List<User> users = userService.searchUsers(query, page);
        
        if (users.isEmpty()) {
            return ResponseEntity.ok(ServiceResponse.success("No users found", users));
        }
        
        return ResponseEntity.ok(ServiceResponse.success(users));
    }
}

❌ Anti-Pattern: Controller Exception Handling

@RestController
public class BadUserController {

    @PostMapping("/users")
    public ResponseEntity<?> createUser(@RequestBody CreateUserRequest request) {
        try {
            // DON'T: Handle business exceptions in controller
            if (request.getEmail() == null) {
                throw new ValidationException("Email is required");
            }
            
            if (userService.emailExists(request.getEmail())) {
                throw new DuplicateException("Email already exists");
            }
            
            User user = userService.createUser(request);
            return ResponseEntity.ok(user);
            
        } catch (ValidationException e) {
            // DON'T: Manual exception handling in controller
            return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
        } catch (DuplicateException e) {
            return ResponseEntity.status(409).body(Map.of("error", e.getMessage()));
        } catch (Exception e) {
            return ResponseEntity.status(500).body(Map.of("error", "Internal error"));
        }
    }
}

⚙️ Service Layer Patterns

Services handle business logic and should throw exceptions for business rule violations and system failures. This is where the priority order from Part 1 is crucial.

✅ Good Service Pattern

@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;
    private final DatabaseHealthCheck healthCheck;

    public User createUser(CreateUserRequest request) {
        // 1. DEPENDENCY CHECK FIRST (5xx)
        if (!healthCheck.isDatabaseAvailable()) {
            throw new ServiceException(EXTERNAL_SERVICE_ERROR);
        }
        
        if (!emailService.isAvailable()) {
            throw new ServiceException(EMAIL_SERVICE_UNAVAILABLE);
        }

        // 2. BUSINESS VALIDATION SECOND (4xx)
        validateUserRequest(request);
        
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateException(USER_EMAIL_DUPLICATE, request.getEmail());
        }

        // 3. IMPLEMENTATION (5xx if fails)
        try {
            User user = User.builder()
                .email(request.getEmail())
                .name(request.getName())
                .createdAt(LocalDateTime.now())
                .build();

            User savedUser = userRepository.save(user);
            
            // Non-critical operation - don't fail the main flow
            emailService.sendWelcomeEmailAsync(savedUser.getEmail());
            
            return savedUser;
            
        } catch (DataAccessException ex) {
            log.error("Database error creating user: {}", ex.getMessage());
            throw new ServiceException(MEMBER_REGISTRATION_FAILED, ex);
        }
    }

    public User findById(Long id) {
        // Business operation - throw exception if not found
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException(USER_NOT_FOUND, id.toString()));
    }

    public List<User> searchUsers(String query, int page) {
        // Expected operation - return empty list, don't throw exception
        return userRepository.findByNameContainingIgnoreCase(
            query, 
            PageRequest.of(page, 20)
        ).getContent();
    }

    private void validateUserRequest(CreateUserRequest request) {
        if (request.getEmail() == null || request.getEmail().trim().isEmpty()) {
            throw new ValidationException(INVALID_INPUT, "email", "null/empty");
        }
        
        if (!EmailValidator.isValid(request.getEmail())) {
            throw new ValidationException(INVALID_EMAIL_FORMAT, "email", request.getEmail());
        }
        
        if (request.getName() == null || request.getName().trim().length() < 2) {
            throw new ValidationException(INVALID_INPUT, "name", "too short");
        }
    }
}

🚫 Common Anti-Patterns to Avoid

❌ Exception for Control Flow

// DON'T: Use exceptions for expected conditions
public User findUserByEmail(String email) {
    try {
        return userRepository.findByEmail(email);
    } catch (NoResultException e) {
        throw new UserNotFoundException("User not found");
    }
}

// DO: Use Optional for expected absence
public Optional<User> findUserByEmail(String email) {
    return userRepository.findByEmail(email);
}

❌ Swallowing Exceptions

// DON'T: Silently catch and ignore
public void updateUser(User user) {
    try {
        userRepository.save(user);
    } catch (Exception e) {
        // Silent failure - very dangerous!
    }
}

// DO: Handle appropriately or let it propagate
public void updateUser(User user) {
    try {
        userRepository.save(user);
    } catch (DataAccessException e) {
        log.error("Failed to update user {}: {}", user.getId(), e.getMessage());
        throw new ServiceException(USER_UPDATE_FAILED, e);
    }
}

❌ Generic Exception Types

// DON'T: Throw generic exceptions
public void validateUser(User user) {
    if (user.getEmail() == null) {
        throw new RuntimeException("Email is required");
    }
}

// DO: Use specific, typed exceptions
public void validateUser(User user) {
    if (user.getEmail() == null) {
        throw new ValidationException(INVALID_INPUT, "email", "null");
    }
}

🎯 Decision Framework

Use this framework to decide when and where to handle exceptions:

🤔 Decision Questions

  1. Is this condition expected in normal operation?
    Expected → Normal flow | Unexpected → Exception
  2. Can the caller reasonably handle this condition?
    Yes → Specific exception | No → Generic system exception
  3. Is this a business rule violation or system failure?
    Business → 4xx exception | System → 5xx exception
  4. Does this affect the core operation’s success?
    Core → Exception | Side effect → Log and continue

✅ Implementation Best Practices

🎮 Controller Layer

  • Keep it thin – delegate to services
  • Handle only HTTP-specific concerns
  • Let GlobalExceptionHandler manage exceptions
  • Use @Valid for request validation

⚙️ Service Layer

  • Follow dependency → business → implementation order
  • Use specific exception types
  • Include context in exception messages
  • Log appropriately by severity

📊 Performance

  • Exceptions are expensive – use wisely
  • Cache validation results when possible
  • Fail fast on dependency checks
  • Use async for non-critical operations

🎯 Part 3 Mastery Achieved

✅ Decision Framework

  • Exception vs normal flow criteria
  • Layer-specific responsibilities
  • Performance considerations

✅ Controller Patterns

  • Thin controller principle
  • HTTP-specific error handling
  • Delegation to services

✅ Service Patterns

  • Priority order implementation
  • Business logic exception handling
  • Specific exception types

✅ Anti-Pattern Awareness

  • Common mistakes to avoid
  • Performance implications
  • Maintainability concerns

🔗 Continue Your Journey

Leave a Comment

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