Spring Boot Rest Controller Tutorial


In this article, we’ll go through how Spring Boot Rest Controller works through detailed examples.

1. Setting up the project

First of all, you need to have Java installed, if you do not you can go through these tutorials depending on the OS your machine runs:

For this tutorial, we are using the IntelliJ IDEA Community edition which you can download here.

If you are not using this IDE, you also need to download Maven here.

The next step is to visit Spring initializr and generate a project according to these settings shown in the image:

Figure 1 – Spring Initializr

After having chosen the above options, press the GENERATE button, and a new spring boot project will be downloaded for you. Then you have to unzip the compressed archive and open the folder with your favorite IDE.

2. Creating a model

The model of our project is pretty simple, just a Car class under a new package model under com.codelearnhub. springbootrestcontrollertutorial:

package com.codelearnhub.springbootrestcontrollertutorial.model;

public class Car {

    private Long id;
    private String model;
    private String brand;
    private Integer horses;
    private Double price;

    public Car() {

    public Car(Long id, String model, String brand, Integer horses, Double price) {
        this.id = id;
        this.model = model;
        this.brand = brand;
        this.horses = horses;
        this.price = price;

    //setters, getters, equas, hashCode and toString

3. Creating a Service

For this tutorial, we will create a service that will handle all the data in memory as connecting, retrieving, and updating a database is out of scope for this tutorial.

3.1 CarService Interface

First of all, we’ll create an interface that will contain all the supported service methods, the interface is the following:

package com.codelearnhub.springbootrestcontrollertutorial.service;

import com.codelearnhub.springbootrestcontrollertutorial.model.Car;
import java.util.List;

public interface CarService {

     * Retrieves all cars currently existing
     * @return
    List<Car> getAllCars();

     * @param min The minimum price inclusive
     * @param max The maximum price exclusive
     * @return A list of cars with price inside [min, max]
    List<Car> getCarsWithPriceFilter(Double min, Double max);

     * @param id The id of the car
     * @return The car with the matching id
    Car getById(Long id);

     * @param id The id of the car to be updated
     * @param carRequest The car object to be updated
     * @return The updated car
    Car update(Long id, Car carRequest);

     * @param The car object to be created
     * @return The car object that was created
    Car create(Car car);

     * @param id The id of the car to be deleted
    void delete(Long id);

3.2 CarService Interface Implementation

After having created the CarService interface, we should create a class to implement that interface. Note that this class must have @Service annotation in order to be able to inject it later in the RestController.

The CarServiceImpl class will have a list of cars as a private member. Also, we’ll implement all the methods defined in CarService:

package com.codelearnhub.springbootrestcontrollertutorial.service;

import com.codelearnhub.springbootrestcontrollertutorial.model.Car;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Optional;

public class CarServiceImpl implements CarService{

    private List<Car> cars = new ArrayList<>(
                new Car(1L,"Astra", "Opel", 100, 18000d),
                new Car(2L, "Insignia", "Opel", 120, 22000d),
                new Car(3L, "Golf", "VW", 90, 17000d)
    )       );

    public List<Car> getAllCars() {
        return cars;

    public List<Car> getCarsWithPriceFilter(Double min, Double max) {
        return cars.stream()
                .filter(car -> car.getPrice() >= min && car.getPrice() <= max)

    public Car getById(Long id) {
        return cars.stream()
                .filter(car -> car.getId().equals(id))
    public Car create(Car car) {
        Long newId = cars.stream().mapToLong(car_ -> Long.valueOf(car_.getId())).max().orElse(0L) + 1L;
        return getById(car.getId());

    public Car update(Long id, Car carRequest) {
        Car carToBeUpdated = getById(id);
        return carToBeUpdated;

    public void delete(Long id) {
        boolean successfulDeletion = cars.removeIf(car -> car.getId().equals(id));
            throw new NoSuchElementException();

The logic of the methods is pretty straightforward.

  • getAllCars() just returns the car list.
  • getCarsWithPriceFilter(Double min, Double max) filters the car list and returns only the cars within the desired price range.
  • getById(Long id) returns the car which has the specified id or throws an exception if it wasn’t found.
  • update(Long id, Car carRequest) updates the car with every value and returns the updated car. If the car does not exist, it throws an exception.
  • create(Car car) creates a new car based on the request body.
  • delete(Long id) deletes a car if it exists, else it throws an exception

4. Creating the Spring Boot Rest Controller

REST stands for REpresentational State Transfer and is a set of architectural constraints. REST APIs utilize the HTTP protocol in order to transfer information between the client and the server.

The most common format used by REST APIs is the JSON format, however, you can use other formats such as XML, plain text, etc.

4.1 CarRestController Overview

Firstly, create a new package under com.codelearnhub. springbootrestcontrollertutorial named controller. Then inside that package create a new class CarRestController.

To mark your class as a Rest Controller, you just have to add the @RestController annotation on top of your class; this annotation contains both @Controller annotation and @ResponseBody annotation. Now let’s explain those two:

  • @Controller is an annotation just like @Component and it marks our class as a bean to be picked up by Spring Context.
  • @ResponseBody annotation is an instruction to serialize automatically any response coming from our endpoints inside this class

Now that we have marked our class as @RestController, we should choose the path of the REST API by adding a @RequestMapping annotation on top of the class.

Therefore, we will use @RequestMapping and we will add 2 attributes as shown below:

@RequestMapping(value = "/cars", produces = MediaType.APPLICATION_JSON_VALUE)

The value attribute is the path of the REST API, which means the path for every method-endpoint that we will create will be http://localhost:8080/cars.

The produces attribute defines what will be the format of the response of the endpoints. Here we set it to application/json as the REST API, we are building will use JSON as the format.

Now that we have explained the class-level annotation let’s go through the implementation of the Rest Controller:

package com.codelearnhub.springbootrestcontrollertutorial.controller;

import com.codelearnhub.springbootrestcontrollertutorial.model.Car;
import com.codelearnhub.springbootrestcontrollertutorial.service.CarService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;

 * Rest Controller for cars
@RequestMapping(value = "/cars", produces = MediaType.APPLICATION_JSON_VALUE)
public class CarRestController {

    private final CarService carService;

    public CarRestController(CarService carService) {
        this.carService = carService;

Here we injected the carService that we previously created through constructor dependency injection, in order to use it inside each endpoint.

4.2 Retrieving all cars

HTTP GET requests are read-only requests that must be used to retrieve records from the server. Additionally, GET requests do not have any request body, but they can have path variables(e.g. to specify the id of the record to be retrieved) and request parameters(e.g. to filter the records based on specific criteria).

Now it’s time to create our first endpoint. Since we want to retrieve the information we should map this request to HTTP GET requests. The endpoint for retrieving all the cars is the following:

public List<Car> getAll() {
    return carService.getAllCars();

@GetMapping maps the GET http://localhost:8080/cars to the method described above. Alternatively, you could use @RequestMapping(method = RequestMethod.GET) which is effectively the same thing as @GetMapping.

Note that if you do not add @ResponseStatus annotation for a method, the response code will be 200. Since we do want that response, there is no need to add it.

4.3 Retrieving cars based on the price filter

Again we need a GET request to be mapped, but this time we want to map requests such as http://localhost:8080/cars?minPrice=NUMBER_A&maxPrice=NUMBER_B. Our endpoint is the following:

@GetMapping(params = {"minPrice", "maxPrice"})
public List<Car> getAllFilteredByPrice(
        @RequestParam Double minPrice,
        @RequestParam Double maxPrice
    return carService.getCarsWithPriceFilter(minPrice, maxPrice);

First of all, we added the annotations @RequestParam to both parameters, so that the parameters of the requests are mapped to these variables. If for any reason we wanted the request parameters to have a different name, let’s say min-price and max-price, then we would have to add them as follows:

@RequestParam(value = "min-price") Double minPrice,
@RequestParam(value = "max-price") Double maxPrice

Additionally, we added the attribute params inside the @GetMapping, which makes the existence of these two params mandatory in order for the request to be matched. If we hadn’t added the params attribute the error that will get when we start our app will be the following:

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'requestMappingHandlerMapping' defined in class path resource [org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]: Invocation of init method failed; nested exception is java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'carRestController' method
com.codelearnhub.springbootrestcontrollertutorial.controller.CarRestController#getAllFilteredByPrice(Double, Double)
to {GET [/cars], produces [application/json]}: There is already 'carRestController' bean method

This means there isn’t enough information to differentiate this endpoint from the previous endpoint we created.

Now if we want an endpoint to have optional request parameters, then we should not add the params attribute at all and at the same time, set the request parameters as required false. So if we didn’t have getAll() endpoint at all, we could have created the following endpoint:

public List<Car> getAllFilteredByPrice(
        @RequestParam(required = false) Double minPrice,
        @RequestParam(required = false) Double maxPrice
    return carService.getCarsWithPriceFilter(minPrice, maxPrice);

All of the following requests would get mapped to the endpoint above:

  • http:localhost:8080/cars
  • http:localhost:8080/cars?minPrice=10000
  • http:localhost:8080/cars?minPrice=12000&maxPrice=20000
  • http:localhost:8080/cars?maxPrice=20000

4.4 Retrieve a car by id

This endpoint should have the id as a path variable and not as a request parameter that we previously used.

public Car getById(@PathVariable Long id){
   return carService.getById(id);

Firstly, we add the “/{id}” inside the @GetMapping annotation so that all requests with the same pattern:

  • http:localhost:8080/cars/1
  • http:localhost:8080/cars/2
  • http:localhost:8080/cars/hello

Are mapped to this endpoint.

Then we want to map the variable of the request to the id variable of our method by adding @PathVariable annotation in front of the variable. Hadn’t we added this annotation, the variable would always be null.

Additionally, if we send a request like http:localhost:8080/cars/hello, the id variable will be null since hello cannot be interpreted as a Long variable. The same applies to decimals.

4.5 Creating a new Car

HTTP POST requests are used to create a new record in the server. Additionally, POST requests do have request body which will be used for the creation of the new record.

In order for the HTTP POST http:localhost:8080/cars to be matched by our controller method, we should create the following method:

public Car create(@RequestBody Car car) {
    return carService.create(car);
  • The @PostMapping annotation means that this controller will only match POST requests.
  • The @RequestBody annotation maps the request body from the request to our Car object.

Then our service handles the addition of the new car to our list.

4.6 Updating a car

HTTP PUT requests are used to update record in the server. Additionally, PUT requests do have request body and they usually have path variables(e.g. the id of the record to be updated).

In order for the HTTP PUT http:localhost:8080/cars/{id} ({id} is a long number in our case) to be matched by our controller method, we should create the following method:

public Car update(@RequestBody Car car, @PathVariable Long id){
    return carService.update(id, car);

Here we have both a request body that will be used to update the values of our car and a path variable that specifies the id of the car to be updated.

4.7 Deleting a car

HTTP DELETE requests are used to delete a record in the server. Additionally, DELETE requests do not have request body and they must have a path variable to specify the record to be deleted.

In order for the HTTP DELETE http:localhost:8080/cars/{id}({id} is a long number in our case) to be matched by our controller method, we should create the following method:

public void delete(@PathVariable Long id) {

As you can observe, the same logic applies as with getById() endpoint, but now the HTTP DELETE request will be matched.

In addition, we do not want to return a 200 OK if we do not want to include any response body in the response, but a 204 NO CONTENT code according to Mozilla’s docs.

To achieve that, we add the @ResponseStatus annotation to our method so in case of successful deletion, this status is returned instead of 200 OK.

5. Validating requests to Spring Boot Rest Controller

Let’s say we get one of the following requests:

  • GET /cars/-1
  • GET /cars?minPrice=-100&maxPrice=-10
  • DELETE /cars/-1
  • PUT /cars/-1

All of these are invalid requests since our ids should only be a positive number. So why bother searching inside the list while we could “reject” the request at the moment we see a negative or zero number?

Additionally, for POST and PUT requests the values of the Car object could be invalid, for example:

    "model" : "",
    "brand": "",
    "horses": -10,
    "price": 17000.0

Should be invalid as we always want a model and a brand to be present and the number of horses cannot be negative.

5.1 Validation in the Car class

To validate the attributes of the Car object to be inserted or updated, let’s jump back to our Car class. Then we should add all the validations needed on top of the getter methods as shown below:

@NotNull(message = "Model must not be null")
@NotEmpty(message = "Model must have value")
public String getModel() {
    return model;

@NotNull(message = "Brand must not be null")
@NotEmpty(message = "Brand must have value")
public String getBrand() {
    return brand;

@NotNull(message = "Horses must not be null")
public Integer getHorses() {
    return horses;

@NotNull(message = "Price must not be null")
public Double getPrice() {
    return price;

Let’s explain what the annotation should do here:

  • @NotNull annotation means that this field must not be null and it must be included in the request body.
  • @NotEmpty annotation means that this String field cannot be empty.
  • @Positive means that this field must have a value greater than zero

5.2 Adding the validation at the Controller level

First of all, we must add @Validated annotation on the class level so that the validations are taken into account.

Now let’s go each by each endpoint:

5.2.1 getAll()

This endpoint does not need any validation as there isn’t any input

5.2.2 getAllFilteredByPrice

This endpoint accepts 2 request parameters, minPrice and maxPrice both of which must be positive in order to have meaning. Therefore the @Positive annotation should be added as shown below:

@GetMapping(params = {"minPrice", "maxPrice"})
public List<Car> getAllFilteredByPrice(
        @RequestParam @Positive(message = "minPrice parameter must be greater than zero") Double minPrice,
        @RequestParam @Positive(message = "maxPrice parameter must be greater than zero") Double maxPrice
    return carService.getCarsWithPriceFilter(minPrice, maxPrice);

5.2.3 getById

Same as above but this time the @Positive will be applied to the id variable.

5.2.4 create

Now we want to validate the request body so the @Valid annotation must be added in front of the request body so that it gets validated according to the constraints we added in section 4.1.

public Car create(@Valid @RequestBody Car car) {
    return carService.create(car);

5.2.5 update

Same as with create, we will add @Valid in front of the @RequestBody annotation and a @Positive annotation in front of the id.

Note that here we require the presence of all attributes of the car even if the only the request was sent to update only one attribute. If we want to accept requests like this:

PUT http://localhost:8080/cars/1 with request body:

    "price": 12000.0

We should remove the @Valid and handle the absence of attributes inside the service.

5.2.6 delete

Same as getById.

6. Handling Exceptions Using @RestControllerAdvice

Alright, now we have set our validations, but if any of the validations fail, an exception will be thrown and the server will return a generic 500 - INTERNAL SERVER ERROR which is not very helpful for the client.

For that reason, we will create a new class ControllerAdvice which will be responsible for handling the exceptions thrown.

The exceptions that we need to handle are the following:

  • NoSuchElementException: This will be thrown when the resource that was requested does not exist
  • ConstraintViolationException: This will be thrown if any of the constraints we have set for id, minPrice and maxPrice failed.
  • MethodArgumentNotValidException: This will be thrown if the request body violates any of the validations we have previously set.

Now let’s jump to our ControllerAdvice class:

package com.codelearnhub.springbootrestcontrollertutorial.exception;

import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;
import java.util.NoSuchElementException;
import java.util.stream.Stream;

 * Class to handle exceptions thrown by controller
public class ControllerAdvice {

    @ExceptionHandler(value = NoSuchElementException.class)
    @ResponseStatus(value = HttpStatus.NOT_FOUND)
    public void carNotFound() {}

    @ExceptionHandler(value = ConstraintViolationException.class)
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    public List<String> wrongInputPathVariableRequestParam(ConstraintViolationException exception) {
        return exception.getConstraintViolations()
                        .map(constraintViolation -> constraintViolation.getMessage())


    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    public List<String> wrongInputRequestBody(MethodArgumentNotValidException exception) {
        return exception.getBindingResult().getFieldErrors()
                        .map(error -> error.getDefaultMessage())


Let’s explain what happened here:

@RestControllerAdvice is a class-level annotation that included @ControllerAdvice and @ResponseBody annotation and is like a controller that is responsible for handling exceptions.

@ExceptionHandler annotation defines which exception should be handled by this method and it takes the type of the exceptions as a parameter(more than one exceptions can be handled by the same method)

@ResponseStatus as we have already said defines the status code to be returned by the method.

  • For carNotFound() method we just return 404 - NOT FOUND status as it is pretty descriptive and it does not need a response body.
  • For wrongInputPathVariableRequestParam(), we return a List that contains the messages we have defined at the rest controller level.
  • For wrongInputInputRequestBody(), we return a List that contains the messages we have defined at the Car class level.

7. Testing our Spring Boot Rest Controller using MockMvc

To test our rest controller our strategy will be pretty simple.

First, we will create a new class called CarRestControllerTest that we will annotate with:

  • @SpringBootTest: This annotation indicates that we require the Spring Context when we run each test.
  • @AutoConfigureMockMvc which enables auto-configuration of MockMvc

Additionally, we will inject MockMvc using @Autowired at the field level which will allow us to perform requests and test the responses and an ObjectMapper object to read and convert the JSON responses into objects.

@DirtiesContext indicates that this test will change the data of the context so it resets the context to the previous state. As a result, the next test will start with a fresh Spring Context

Our test class will be the following:

package com.codelearnhub.springbootrestcontrollertutorial.controller;

import com.codelearnhub.springbootrestcontrollertutorial.model.Car;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

import java.util.List;

import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

class CarRestControllerTest {

    private MockMvc mockMvc;

    private ObjectMapper objectMapper = new ObjectMapper();

    @DisplayName("Test getting all cars")
    void getAll() throws Exception {

        var expectedResult = objectMapper.readValue(TestHelper.readFile("/get_all_cars.json"), List.class);

        MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/cars"))

        var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), List.class);
        Assertions.assertEquals(expectedResult, actualResult);

    @DisplayName("Test getting car with id 1")
    void getById() throws Exception {
        var expectedResult = objectMapper.readValue(TestHelper.readFile("/get_car_id_1.json"), Car.class);

        MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/cars/1"))

        var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Car.class);
        Assertions.assertEquals(expectedResult, actualResult);


    @DisplayName("Test getting car with id 4 - does not exist")
    void getByIdNonExistent() throws Exception {



    @DisplayName("Update price of car with id 1 to 19000")
    void update() throws Exception {

        var requestBody = TestHelper.readFile("/update_car_with_id_1_request.json");
        var responseBody = TestHelper.readFile("/update_car_with_id_1_response.json");

        var expectedResult = objectMapper.readValue(responseBody, Car.class);

        MvcResult mvcResult = mockMvc.perform(

        var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Car.class);
        Assertions.assertEquals(expectedResult, actualResult);

    @DisplayName("Create a new car")
    void create() throws Exception {
        var requestBody = TestHelper.readFile("/create_lamborghini_gallardo_response.json");
        var responseBody = TestHelper.readFile("/create_lamborghini_gallardo_response.json");

        var expectedResult = objectMapper.readValue(responseBody, Car.class);

        MvcResult mvcResult = mockMvc.perform(

        var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Car.class);
        Assertions.assertEquals(expectedResult, actualResult);

    @DisplayName("Delete an existing car")
    void deleteAnExistingCar() throws Exception {

    @DisplayName("Get all cars with price between 10000 and 20000 euro")
    void getCarsFilteredByPrice() throws Exception {
        var expectedResult = objectMapper.readValue(TestHelper.readFile("/get_cars_by_price.json"), List.class);

        MvcResult mvcResult = mockMvc.perform(MockMvcRequestBuilders.get("/cars?minPrice=10000&maxPrice=20000"))

        var actualResult = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), List.class);
        Assertions.assertEquals(expectedResult, actualResult);


Let’s explain the getAll() test:

  1. We read from a file the expected response using a TestHelper class that just reads files and then we convert it back to a list of objects
  2. We perform a get request using the mockMvc and before we get the response, we validate that indeed the response code was 200.
  3. Then we read the response and convert it back to a list of car objects.
  4. We assert that the expected and the actual response are equal.

For a post/update request we follow the same approach but we must also send a request body and specify the contentType. Then again we compare the responses.

You can find all the expected responses here.

8. Testing our REST API using Postman

Postman makes sending HTTP requests as easy as possible. If you don’t have postman installed, you can visit this page to download it:

The next step is to head to SpringBootRestControllerTutorialApplication.java and run the application.

8.1 Retrieve all cars

Figure 2 – GET /cars

8.2 Retrieve all cars with a price filter

Figure 3 – GET /cars?minPrice=17000&maxPrice=19000

When we give a negative number:

Figure 4 – /cars?minPrice=-17000&maxPrice=19000

8.3 Get a Car by Id

Figure 5 – GET /car/1

When we give a negative id:

Figure 6 – GET /cars/-1

When we give an id that does not exist:

Figure 7 – GET /cars/100

8.4 Create a new Car

Figure 8 – POST /cars

When any of the required attributes is missing:

Figure 9 – POST /cars with a missing parameter

8.5 Update a Car

Figure 10 – Update a car’s price

When we give negative horses and set the model as empty:

Figure 11 – Updating a car’s horses negative and model empty

8.6 Delete a car

Figure 12 – Deleting a car with id 1

When we try to delete a car that does not exist:

Figure 12 – Delete a car that does not exist

8.7 Retrieve all cars after all the of the above requests:

Figure 13 – Retrieving all cars

9. Conclusion

By now you should be able to create your own REST APIs using Spring Boot Rest Controller. You can find the source code on our Github page.

10. Sources

[1]: RestController (Spring Framework 5.3.18 API)

[2]: Getting Started | Testing the Web Layer – Spring

[3]: Exception Handling in Spring MVC

[4]: DELETE – HTTP – MDN Web Docs

Related Posts