Skip to content

@ControllerAdvice vs Service Layer: Where to Handle Exceptions

I was reviewing a pull request and saw this pattern:

UserService.java
@Service
public class UserService {
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "User not found"
));
}
}

Something felt off. Why is my service layer throwing ResponseStatusException? That’s an HTTP-specific exception. What if this service gets called from a CLI tool or a scheduled job later?

I asked around and got conflicting answers. Some said “it’s fine, it works.” Others said “service layer should be HTTP-agnostic.” I needed to understand the right approach.

The Problem: Mixed Concerns

Let me show you what I had before:

UserService.java (before)
@Service
public class UserService {
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "User not found"
));
}
public User createUser(UserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new ResponseStatusException(
HttpStatus.CONFLICT, "Email already exists"
);
}
return userRepository.save(new User(request));
}
}

This works, but it couples my service to HTTP. The service knows about HTTP status codes. That’s wrong.

First Attempt: Domain Exceptions

I tried creating domain-specific exceptions:

UserNotFoundException.java
public class UserNotFoundException extends RuntimeException {
private final Long userId;
public UserNotFoundException(Long userId) {
super("User not found with id: " + userId);
this.userId = userId;
}
public Long getUserId() {
return userId;
}
}
DuplicateEmailException.java
public class DuplicateEmailException extends RuntimeException {
private final String email;
public DuplicateEmailException(String email) {
super("Email already exists: " + email);
this.email = email;
}
public String getEmail() {
return email;
}
}

Then I updated my service:

UserService.java (refactored)
@Service
public class UserService {
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
public User createUser(UserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException(request.getEmail());
}
return userRepository.save(new User(request));
}
}

Better. Now my service throws domain exceptions. But wait, how do these become HTTP responses?

The Missing Piece: @ControllerAdvice

I needed something to translate domain exceptions to HTTP responses. Enter @ControllerAdvice:

GlobalExceptionHandler.java
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
"USER_NOT_FOUND",
ex.getMessage(),
Map.of("userId", ex.getUserId())
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ErrorResponse> handleDuplicateEmail(DuplicateEmailException ex) {
ErrorResponse error = new ErrorResponse(
"DUPLICATE_EMAIL",
ex.getMessage(),
Map.of("email", ex.getEmail())
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
}

This separates concerns cleanly:

Service Layer ControllerAdvice
| |
v v
Domain Exceptions --> HTTP Responses

But Why Not Handle in Controllers?

I thought about handling exceptions in each controller:

UserController.java (alternative)
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
try {
return ResponseEntity.ok(userService.getUser(id));
} catch (UserNotFoundException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, e.getMessage());
}
}
}

This is verbose and repetitive. Every controller method needs try-catch. And I’d duplicate the same error handling across multiple controllers.

@ControllerAdvice gives me cross-cutting exception handling. One place. All controllers.

Exception Matching at Arbitrary Depth

Here’s something I learned from the Spring documentation. Starting with Spring 5.3, exception matching works at arbitrary depth in the exception hierarchy:

ExceptionHandlerDepth.java
@ControllerAdvice
public class GlobalExceptionHandler {
// This catches BusinessException and ALL its subclasses
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("BUSINESS_ERROR", ex.getMessage()));
}
// This is more specific, catches only UserNotFoundException
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
}
}

If UserNotFoundException extends BusinessException, Spring matches the most specific handler. That’s handleUserNotFound, not handleBusinessException.

What About Multiple Content Types?

My API needed to support both JSON and XML responses. I learned that @ExceptionHandler can negotiate media types:

ExceptionHandlerWithContentNegotiation.java
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<?> handleUserNotFound(
UserNotFoundException ex,
HttpServletRequest request) {
String accept = request.getHeader("Accept");
if (accept != null && accept.contains("application/xml")) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_XML)
.body(new XmlErrorResponse(ex.getMessage()));
}
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_JSON)
.body(new JsonErrorResponse(ex.getMessage()));
}
}

Local vs Global Exception Handlers

I discovered that controllers can have their own @ExceptionHandler methods:

UserController.java (with local handler)
@RestController
@RequestMapping("/users")
public class UserController {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleLocal(UserNotFoundException ex) {
// This runs BEFORE the global handler
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("LOCAL_HANDLER", ex.getMessage()));
}
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUser(id);
}
}

The local handler executes first. If it handles the exception, the global handler never runs. This is useful for controller-specific error handling.

Narrowing @ControllerAdvice Scope

I didn’t want my @ControllerAdvice to handle exceptions from every controller. Spring provides several ways to narrow the scope:

ScopedControllerAdvice.java
// Only for controllers in specific packages
@ControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler { }
// Only for controllers with specific annotation
@ControllerAdvice(annotations = RestController.class)
public class RestExceptionHandler { }
// Only for specific controller classes
@ControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
public class SpecificExceptionHandler { }

This is useful when you have different error handling strategies for different parts of your application.

When to Use Each Approach

Here’s my decision framework:

Scenario Recommendation
─────────────────────────────────────────────────────────────────
REST API with consistent error format @ControllerAdvice + domain exceptions
Service reused across REST, GraphQL, CLI Each interface has its own handler
Legacy codebase Gradually migrate to @ControllerAdvice
Controller-specific error handling Local @ExceptionHandler in controller

What I Ended Up With

My final structure:

UserService.java (final)
@Service
public class UserService {
public User getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
public User createUser(UserRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException(request.getEmail());
}
return userRepository.save(new User(request));
}
}
GlobalExceptionHandler.java (final)
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("USER_NOT_FOUND", ex.getMessage()));
}
@ExceptionHandler(DuplicateEmailException.class)
public ResponseEntity<ErrorResponse> handleDuplicateEmail(DuplicateEmailException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("DUPLICATE_EMAIL", ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "An unexpected error occurred"));
}
}

The service layer throws domain exceptions. The @ControllerAdvice translates them to HTTP responses. Clean separation of concerns.

Key Takeaways

  1. Service layer should be HTTP-agnostic - throw domain exceptions, not ResponseStatusException
  2. @ControllerAdvice provides cross-cutting exception translation - one place to handle all exceptions
  3. Exception matching works at arbitrary depth - Spring matches the most specific handler
  4. Local handlers execute before global ones - use for controller-specific error handling
  5. Narrow @ControllerAdvice scope when needed - use basePackages, annotations, or assignableTypes

This pattern keeps my service layer reusable and my error handling consistent. The service doesn’t know about HTTP. The controller advice doesn’t know about business logic. Each layer does its job.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments