Handle Exceptions Consistently in Spring MVC with @ControllerAdvice
Introduction
In an application based on the MVC pattern, client requests are received by the controller ( C ), which, based on the Model layer (M) and associated services, performs processing. The result of this processing is returned to the appropriate view (V) in the form of HTML, XML, JSON, etc. In the case where the View part is externalized, the result is usually exposed in JSON format on an endpoint that the front-end application will consume. Poor exception handling within such an application can quickly lead to code repetition, violating the DRY principle and SOLID’s Single Responsibility principle to some extent, as well as inconsistency in the responses sent to the client. In this article, we propose a centralized approach to exception handling in a REST API based on Java 17, Spring and @ControllerAdvice
.
What are we going to do next
To make the concepts easier to understand, we will build a simple book management application. This will implement CRUD operations on books.
Technical Stack
We use the following tools, languages and frameworks:
Java 17,
Spring Boot 3.2.3,
H2 in-memory database (for simplicity and dev environment),
Maven to help us build,
IntelliJ to write our lines of code.
Postman
So we’ll assume that you have installed the whole stack. You can, however, use your favorite code editor. Let’s not forget our goal, which is to use a centralized approach to handling the exceptions generated by our application.
Steps
1- Application initialization
On the https://start.spring.io/page, select Maven, Java 17, Spring Boot 3.2.3, and JAR packaging. Next, select the dependencies: Spring Web, H2 Database, Spring Data JPA, Lombok. Once all the dependencies have been added and the project metadata edited, generate the ZIP file. After decompressing the archive, open the folder with IntelliJ or your favorite code editor.
2- application.properties
spring.application.name=bookapp
# Tomcat conf
server.port=9000
# Log level configuration
logging.level.root=ERROR
logging.level.org.springframework.boot.autoconfigure.h2=INFO
logging.level.org.springframework.boot.web.embedded.tomcat=INFO
# H2 configuration
spring.datasource.url=jdbc:h2:mem:bookapp
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=admin
spring.datasource.password=admin
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
3- Packages
Our project tree should look like this, after adding the packages:
4- Entities
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false)
private String author;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String date;
@Column(nullable = false)
private String editor;
}
The first four class annotations are from Lombok. @Data
generates getters and setters. @Builder
will probably remind you of the Builder design pattern, which enables dynamic creation of complex objects (even if our object isn't quite as complex as that :). The @Entity
annotation is used in the context of JPA to indicate that the entity will be persisted in a relational database. In our scenario, it will be represented by the "book" table, as indicated by the @Table
annotation.
import java.beans.Transient;
import java.time.LocalDateTime;
@Builder
public record ErrorEntity(
LocalDateTime timeStamp,
String message,
@Transient
String errorAuthor,
int httpStatus
) {
}
In the same package, we create the ErrorEntity
Record to store information on exceptions thrown. For an error, we'd like to store the date, error message, Http status, and possibly the class or method involved. The latter will be annotated @Transient
to hide this detail during serialization.
By the way, we don’t forget to commit our code😉
5- @ControllerAdvice
All right ! Since we’re all anxious to get straight to the point, let’s set up the backbone of our exception system. But first, how does it work? Our Postman client sends a request to delete a book to one of our end-points. It waits for a response. A servlet
catches the request and dispatches it to the right controller. At some point during processing, an exception occurs: the object to be deleted does not exist. The service throws one of the exceptions we've defined. The ControllerAdvice
, which listens to all our throw
, then takes over. It creates an object of ErrorEntity
entity and sends it back to Postman in a ResponseEntity
.
In the exception package, let’s start by creating the BookNotFindException
class, which extends RuntimeException
.
public class BookNotFoundException extends RuntimeException{
public BookNotFoundException(Integer bookId) {
super ("Book not found with ID : "+ bookId);
}
}
In the same package, let’s add the BadRequestException
class, which constructs an exception of type BadRequestException
.
public class BadRequestException extends RuntimeException{
public BadRequestException(String message) {
super(message);
}
}
Each of these two classes inherits from RuntimeException
, which in turn inherits from Exception
, which in turn inherits from Throwable
🤷. As a reminder, the super()
method calls the constructor of the parent class.
Let’s end by creating the GlobalExceptionHandler
class, which will catch all exceptions propagated via the ControllerAdvice
annotation.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorEntity> bookNotFoundHandler(BookNotFoundException exception) {
ErrorEntity error = ErrorEntity.builder()
.timeStamp(LocalDateTime.now())
.message(exception.getMessage())
.httpStatus(HttpStatus.NOT_FOUND.value())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND.value()).body(error);
}
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<ErrorEntity> badRequestHandler(BadRequestException exception) {
ErrorEntity error = ErrorEntity.builder()
.timeStamp(LocalDateTime.now())
.message(exception.getMessage())
.httpStatus(HttpStatus.BAD_REQUEST.value())
.build();
return ResponseEntity.status(HttpStatus.NOT_FOUND.value()).body(error);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorEntity> runtimeExceptionHandler(RuntimeException exception) {
ErrorEntity error = ErrorEntity.builder()
.timeStamp(LocalDateTime.now())
.message(exception.getMessage())
.httpStatus(HttpStatus.FORBIDDEN.value())
.build();
return ResponseEntity.status(HttpStatus.FORBIDDEN.value()).body(error);
}
}
What does this class do? When a service throws one of the exceptions passed as @ExceptionHandler
arguments, the corresponding method is executed. We use the builder pattern to build the ErrorEntity
object. Example: if the BookNotFound
exception is propagated somewhere in our code, the ControllerAvice
will delegate its processing to the bookNotFoundHandler
method.
Another commit 😉
6- DAO layer
import com.namek.bookapp.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
@Repository
public interface BookRepository extends JpaRepository<Book, Integer> {
// Ajouter des methodes si besoin, en respectant la syntaxe JPQL et les conventions de nommage des methodes Spring Data JPA
// Par defaut, l'interface heritera de toutes les methodes essentielles pour notre tutoriel.
}
7- Services
import java.util.Optional;
public interface BookService {
public Book saveBook(Book book);
public Optional<Book> getBookById(final Integer id);
public Iterable<Book> getBook();
public void deleteBook(final Integer id);
}
@Service
public class BookServiceImp implements BookService{
@Autowired
private BookRepository bookRepository;
public Book saveBook(Book book) {
try {
return bookRepository.save(book);
} catch (Exception exception) {
throw new BadRequestException(exception.getMessage());
}
}
public Optional<Book> getBookById(final Integer id) {
return Optional.ofNullable(bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id)));
}
public Iterable<Book> getBook() {
try {
return bookRepository.findAll();
} catch (Exception exception) {
throw new RuntimeException(exception.getMessage());
}
}
public void deleteBook(final Integer id) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new BookNotFoundException(id));
bookRepository.deleteById(book.getId());
}
}
Each method in the Service class uses an exception propagation mechanism. A particular type of exception is propagated in each method. As mentioned above, the ControllerAdvice
catches them and routes them to the corresponding method.
…A commit 😉
8- Controllers
@RestController
@RequestMapping("api/v1/book")
public class BookController {
@Autowired
private BookService bookService;
@PostMapping("/")
public Book save(@RequestBody Book book) {
return bookService.saveBook(book);
}
@GetMapping("/{bookId}")
public Optional<Book> getBookById(@PathVariable Integer bookId) {
return bookService.getBookById(bookId);
}
@GetMapping("/")
public Iterable<Book> getBook(){
return bookService.getBook();
}
@DeleteMapping("/{bookId}")
public void deleteBook(@PathVariable Integer bookId) {
bookService.deleteBook(bookId);
}
}
9- Testing with curl
The following Curl request asks our API to return the book with ID 1, however, we haven’t yet created any book (assuming we’re progressing at the same rate 😎). The result is predictable:
curl --location 'http://localhost:9000/api/v1/book/1'
{
"localDateTime": "2024-03-14T14:08:27.9527042",
"message": "Book not found with ID : 1",
"httpStatus": 404
}
In this response, you’ll recognize the structure of the ErrorEntity
entity.
The following creation request contains a null
field, thus violating the NotNull
constraint. A BadRequestException is thrown.
curl --location 'http://localhost:9000/api/v1/book/' \
--header 'Content-Type: application/json' \
--data '{
"book_author": "Jean Joel NTEPP",
"book_title": "Jesus Christ est Seigneur",
"book_date": null,
"book_editor": "Bible Study"
}'
{
"timeStamp": "2024-03-14T14:41:20.8842908",
"message": "Bad request : not-null property references a null or transient value",
"httpStatus": 400
}
Conclusion
Exception handling within an application is an important element to take into account before even starting to implement business functionality. In this article, we explored an approach based on @ControllerAdvice
in an MVC architecture using Spring. This approach is a good start, but it forces developers to spend a lot of time on exceptions (which are cross-cutting concerns) rather than on business functionality. AOP (Aspect Oriented Programming) helps to separate these concerns by creating an Aspect dedicated to exception handling.
We hope to return to this subject in a future article.
The source code for this project is available on github https://github.com/ntjoel19/controller_advice_exception
Let’s explore the AOP further https://docs.spring.io/spring-framework/reference/core/aop.html
Resources:
Let us connect :
Subscribe to my newsletter
Read articles from jean joel Ntepp directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by