Handle Exceptions Consistently in Spring MVC with @ControllerAdvice

jean joel Nteppjean joel Ntepp
7 min read

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:

The bookapp project structure

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.

Diagramme de sequence

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 :

10
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

jean joel Ntepp
jean joel Ntepp