Spring Boot @ControllerAdvice
last modified July 18, 2023
SpringBoot @ControllerAdvice tutorial shows how to use @ControllerAdvice annotation to manage exceptions in a Spring Boot application.
Spring is a popular Java application framework. Spring Boot is an effort to create stand-alone, production-grade Spring based applications with minimal effort.
@ControllerAdvice
@ControllerAdvice is a specialization of the @Component
annotation which allows to handle exceptions across the whole application in one
global handling component. It can be viewed as an interceptor of exceptions
thrown by methods annotated with @RequestMapping and similar.
It declares @ExceptionHandler, @InitBinder, or
@ModelAttribute methods to be shared across multiple
@Controller classes.
ResponseEntityExceptionHandler is a convenient base class for
@ControllerAdvice classes that wish to provide centralized exception
handling across all @RequestMapping methods through
@ExceptionHandler methods. It provides an methods for handling
internal Spring MVC exceptions. It returns a ResponseEntity in
contrast to DefaultHandlerExceptionResolver which returns a
ModelAndView.
Spring Boot @ControllerAdvice example
In the following Spring Boot application we use @ControllerAdvice
to handle three exceptions: when a city is not found, when there is no
data, and when a data for a new city to be saved is not valid.
build.gradle
...
src
├───main
│ ├───java
│ │ └───com
│ │ └───zetcode
│ │ │ Application.java
│ │ │ MyRunner.java
│ │ ├───controller
│ │ │ MyController.java
│ │ ├───exception
│ │ │ CityNotFoundException.java
│ │ │ ControllerAdvisor.java
│ │ │ NoDataFoundException.java
│ │ ├───model
│ │ │ City.java
│ │ ├───repository
│ │ │ CityRepository.java
│ │ └───service
│ │ CityService.java
│ │ ICityService.java
│ └───resources
│ application.properties
└───test
├── java
└── resources
This is the project structure.
plugins {
id 'org.springframework.boot' version '3.1.1'
id 'io.spring.dependency-management' version '1.1.0'
id 'java'
}
group = 'com.zetcode'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'com.h2database:h2'
}
This is the Gradle build file. Since Spring Boot 2.3 the
spring-boot-starter-validation dependency must be explicitly
specified.
spring.main.banner-mode=off
The application.properties is the main Spring Boot configuration
file. With the spring.main.banner-mode property we turn off the
Spring banner.
package com.zetcode.model;
import org.hibernate.validator.constraints.Range;
import java.util.Objects;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotEmpty;
@Entity
@Table(name = "cities")
public class City {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotEmpty
private String name;
@Range(min=10, max=100_000_000)
private int population;
public City() {
}
public City(String name, int population) {
this.name = name;
this.population = population;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getPopulation() {
return population;
}
public void setPopulation(int population) {
this.population = population;
}
@Override
public int hashCode() {
int hash = 7;
hash = 79 * hash + Objects.hashCode(this.id);
hash = 79 * hash + Objects.hashCode(this.name);
hash = 79 * hash + this.population;
return hash;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
final City other = (City) obj;
if (this.population != other.population) {
return false;
}
if (!Objects.equals(this.name, other.name)) {
return false;
}
return Objects.equals(this.id, other.id);
}
@Override
public String toString() {
var builder = new StringBuilder();
builder.append("City{id=").append(id).append(", name=")
.append(name).append(", population=")
.append(population).append("}");
return builder.toString();
}
}
This is the City entity. It contains the following
attributes: id, name, and population.
@NotEmpty private String name; @Range(min=10, max=100_000_000) private int population;
We have validation annotations for the city data. An exception is thrown when the name is empty and the population does not fit the specified range.
package com.zetcode.repository;
import com.zetcode.model.City;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CityRepository extends CrudRepository<City, Long> {
}
The CityRepository extends from the CrudRepository.
It provides the type of the entity and of its primary key. A repository is
a storage of city objects.
package com.zetcode.service;
import com.zetcode.model.City;
import java.util.List;
public interface ICityService {
City findById(Long id);
City save(City city);
List<City> findAll();
}
ICityService provides contract methods to save a city, to get all
cities and get a city by its Id from the data source.
package com.zetcode.service;
import com.zetcode.exception.CityNotFoundException;
import com.zetcode.exception.NoDataFoundException;
import com.zetcode.model.City;
import com.zetcode.repository.CityRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CityService implements ICityService {
private final CityRepository cityRepository;
public CityService(CityRepository cityRepository) {
this.cityRepository = cityRepository;
}
@Override
public City findById(Long id) {
return cityRepository.findById(id)
.orElseThrow(() -> new CityNotFoundException(id));
}
@Override
public City save(City city) {
return cityRepository.save(city);
}
@Override
public List<City> findAll() {
var cities = (List<City>) cityRepository.findAll();
if (cities.isEmpty()) {
throw new NoDataFoundException();
}
return cities;
}
}
CityService contains the implementation of the
findAll, save, and findById
methods. We use repository to retrieve data from the database.
return cityRepository.findById(id)
.orElseThrow(() -> new CityNotFoundException(id));
If a city cannot be found, the CityNotFoundException is thrown.
if (cities.isEmpty()) {
throw new NoDataFoundException();
}
If there are no data in the database, the NoDataFoundException
is thrown.
package com.zetcode.exception;
public class CityNotFoundException extends RuntimeException {
public CityNotFoundException(Long id) {
super(String.format("City with Id %d not found", id));
}
}
This is the CityNotFoundException.
package com.zetcode.exception;
public class NoDataFoundException extends RuntimeException {
public NoDataFoundException() {
super("No data found");
}
}
This is the NoDataFoundException.
package com.zetcode.exception;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@ControllerAdvice
public class ControllerAdvisor extends ResponseEntityExceptionHandler {
@ExceptionHandler(CityNotFoundException.class)
public ResponseEntity<Object> handleCityNotFoundException(
CityNotFoundException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", "City not found");
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(NoDataFoundException.class)
public ResponseEntity<Object> handleNodataFoundException(
NoDataFoundException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", "No cities found");
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDate.now());
body.put("status", status.value());
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
body.put("errors", errors);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
}
The ControllerAdvisor is a component which handles all three
exceptions in one place.
@ControllerAdvice
public class ControllerAdvisor extends ResponseEntityExceptionHandler {
The ResponseEntityExceptionHandler inherits from the
ResponseEntityExceptionHandler, which is a convenient base class
for controller advisor components.
@ExceptionHandler(CityNotFoundException.class)
public ResponseEntity<Object> handleCityNotFoundException(
CityNotFoundException ex, WebRequest request) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("timestamp", LocalDateTime.now());
body.put("message", "City not found");
return new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
This is a handler method for the CityNotFoundException.
We send a ResponseEntity with a timestamp, error message
and a status code to the client.
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
The handleMethodArgumentNotValid handles the
MethodArgumentNotValidException which is thrown when
validation on an argument annotated with @Valid fails.
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(DefaultMessageSourceResolvable::getDefaultMessage)
.collect(Collectors.toList());
body.put("errors", errors);
We get the error fields.
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
A ResponseEntity is returned with the error messages
and the status code.
package com.zetcode.controller;
import com.zetcode.model.City;
import com.zetcode.service.ICityService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.Valid;
import java.util.List;
@RestController
public class MyController {
private final ICityService cityService;
public MyController(ICityService cityService) {
this.cityService = cityService;
}
@GetMapping(value = "/cities/{id}")
public City getCity(@PathVariable Long id) {
return cityService.findById(id);
}
@PostMapping(value = "/cities", consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public City createCity(@RequestBody @Valid City city) {
return cityService.save(city);
}
@GetMapping(value = "/cities")
public List<City> findAll() {
return cityService.findAll();
}
}
MyController is a Restful controller. It contains mappings for
retrieving a city, saving a city and retrieving all cities.
@PostMapping(value = "/cities", consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public City createCity(@RequestBody @Valid City city) {
With the @Valid annotation we ensure that the data fits the
validation rules.
package com.zetcode;
import com.zetcode.model.City;
import com.zetcode.repository.CityRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class MyRunner implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(MyRunner.class);
private final CityRepository cityRepository;
@Autowired
public MyRunner(CityRepository cityRepository) {
this.cityRepository = cityRepository;
}
@Override
public void run(String... args) throws Exception {
logger.info("Saving cities");
cityRepository.save(new City("Bratislava", 432000));
cityRepository.save(new City("Budapest", 1759000));
cityRepository.save(new City("Prague", 1280000));
cityRepository.save(new City("Warsaw", 1748000));
cityRepository.save(new City("Los Angeles", 3971000));
cityRepository.save(new City("New York", 8550000));
cityRepository.save(new City("Edinburgh", 464000));
cityRepository.save(new City("Suzhou", 4327066));
cityRepository.save(new City("Zhengzhou", 4122087));
cityRepository.save(new City("Berlin", 3671000));
}
}
In the MyRunner, we save a couple of city objects into the
database.
private final CityRepository cityRepository;
@Autowired
public MyRunner(CityRepository cityRepository) {
this.cityRepository = cityRepository;
}
We inject the CityRepository into the cityRepository
field.
cityRepository.save(new City("Bratislava", 432000));
A new city is inserted with save.
package com.zetcode;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
The Application sets up the Spring Boot application.
$ curl localhost:8080/cities/23
{"timestamp":"2023-07-18T19:06:10.1986204","message":"City not found"}
There is no city with id 23.
$ curl localhost:8080/cities -H "Content-Type: application/json" -X POST -d '{"name":"Sydney", "population":"2"}'
{"timestamp":"2023-07-18","status":400,"errors":["must be between 10 and 100000000"]}
We get an error message when we provide invalid population value.
(Use -d "{\"name\":\"Sydney\", \"population\":\"2\"}" on Windows.)
In this article we have worked with the @ControllerAdvice
annotation.