· Jose Antonio López  · 7 min read

@RestController Guide in Spring Boot - Step-by-Step CRUD

A technical guide to implementing a REST Controller in Spring Boot. Best practices, SOLID principles, and code examples for a professional CRUD with proper handling of HTTP states.

A technical guide to implementing a REST Controller in Spring Boot. Best practices, SOLID principles, and code examples for a professional CRUD with proper handling of HTTP states.

Introduction

This guide describes my recommended approach for developing REST controllers in Spring Boot 3.5.x. The goal is to create clean, maintainable controllers using good practices. The information you’ll find applies to the version of Spring Boot that uses Servlets to create web applications.

The guidelines in this article will help you structure your controllers effectively, ensuring a clear separation of concerns and facilitating long-term code maintenance. All the information here is based on my professional experience developing REST APIs with Spring Boot.

Why Am I Writing This Article?

Throughout my professional career, I’ve seen that many developers have misconceptions about how to develop a REST controller. At first glance, it seems like a simple task, but several mistakes are commonly made:

  • Poor versioning management from the start
  • Business logic within the controller methods
  • APIs that do not validate or check input data

Prerequisites

To follow this article, you need to have the following installed:

Tool/StepDownload Link
JavaDownload Java
MavenDownload Maven
IDE (IntelliJ, Eclipse, VS Code, etc.)Download IntelliJ
Spring Boot 3.5.x ProjectCreate Spring Boot project

Dependencies

The dependencies we will work with are:

DependencyDescription
spring-boot-starter-webNecessary dependencies to build web applications with servlets. More in the official documentation.
spring-boot-starter-validationSupport for data validation using the Bean Validation specification. More in the official documentation.
lombokA library that reduces repetitive code through annotations. More in the official documentation.

Design Decisions

Constructor Dependency Injection

The type of dependency injection I’ve used is via the constructor. It’s the best way to inject dependencies in Spring and the recommended way in the official documentation.

If you usually use field dependency injection, I recommend changing it to constructor dependency injection.

Lombok

To reduce boilerplate code, I’ve used the Lombok library, which reduces visible code through annotations. In this case, @RequiredArgsConstructor and @Log annotations are used.

Other annotations that can be used are @Data, @EqualsAndHashCode, and @ToString, although I consider them to touch on very sensitive topics and recommend implementing them by hand if the need arises.

Unique Identifier for Entities

Entities are identified by a valid UUID. For the example, I’m going to use UUID version 4, although in production and new systems, I advise using UUID v7 as it provides information about the creation time and helps optimize database indexing.

No Pagination

To simplify, I am avoiding pagination for returning a list. In a real production system, a paginated list is not always necessary.

For start-ups or creating new prototypes, I always advise against including a list because requirements fluctuate a lot, and two things can happen:

  • Constant code changes
  • The functionality ends up not being used

API Versioning

Versioning an API is very important because it allows the functionalities exposed to the outside world to evolve without breaking existing ones.

In Spring Boot, there are several strategies for versioning the API. In my case, for practicality, I’m going to use a pure static class that will be open for extension but closed for modification.

You can find another approach to API versioning in the official Spring Boot documentation.

Information Flow in Spring using Servlet

When an HTTP request arrives at a Spring Boot application that uses Servlets, it follows a defined flow before reaching the REST controller:

Imagine a request traveling through your Spring Boot application. It’s like a package going through several stations to reach its destination:

  1. Tomcat: The HTTP request first arrives at the web server, which is Tomcat by default.

  2. Filters: Before proceeding, the request passes through a chain of filters (Filter). A very common use is for authentication or authorization, where a filter checks if the request includes a valid token in the headers.

  3. DispatcherServlet: The request is handed to the DispatcherServlet, which then consults the HandlerMapping.

  4. HandlerMapping: This component acts as a map, linking the request’s URL to a specific controller method.

    • Example: If a GET /v1/crm-mgmt-api/customers/{id} request arrives, the HandlerMapping knows it should be directed to the getCustomer(id) method in the CustomerController.
  5. HandlerAdapter: Once the method is located, the HandlerAdapter prepares to call it. Its job is to map the request data to the parameters the Java method expects. At this point, DTO validations are applied (you can see how to create custom validators here). If something fails, the system will throw an exception that should be caught by a global exception handler.

    • Example: It extracts the id from the URL and converts it to a UUID object to pass to the getCustomer(UUID id) method. If it were a POST request, it would convert the JSON to a DTO object annotated with @RequestBody.
  6. @RestController: Finally, the request reaches your method in the controller. This is where the code you’ve written is executed.

  7. Serialization to JSON: The method returns a Java object (e.g., a CustomerDTO). Spring Boot, using a library called Jackson by default, automatically converts it to JSON format. This JSON is sent back in the HTTP response body along with an HTTP status code.

If you want to know more about the Servlet specification, you can consult the official Servlet 6.0 specification, which applies to Spring Boot 3.5.

API Version

The code to control the API path is as follows:

package com.test.crm.config;

public class ApiConfig {
    public static final String API_VERSION_V1 = "/v1";
    public static final String COMMON_PATH = "/crm-mgmt-api";
    public static final String API_BASE_PATH_V1 = COMMON_PATH + API_VERSION_V1;

    private ApiConfig() {
        throw new UnsupportedOperationException("This class should never be instantiated");
    }
}

Code Explanation

The code defines purely static and granular variables so there is no mistake about which version the endpoint is using.

In an API contract, it is strange to find minor versions like 1.1 or 1.2. If they existed, there would be no problem adding a new variable, but keep in mind that it should add new functionality or more data.

Open for extension and closed for modification.

DTOs

The code is as follows:

package com.test.crm.dto;

public record CreateCustomerDTO(
    @NotBlank
    @Size(min = 3)
    @Pattern(regexp = "^[^0-9]*$", message = "Only letters")
    String name,

    @NotBlank
    @Size(min = 3)
    @Pattern(regexp = "^[^0-9]*$", message = "Only letters")
    String surname
) {
}
package com.test.crm.dto;

public record CustomerDTO(
    UUID id,
    String name,
    String surname,
    String image,
    String createdBy,
    String lastUpdatedBy
) {
}

Controller

The code is as follows:

package com.test.crm.controller;

@RestController
@RequestMapping(API_BASE_PATH + "/customers")
@RequiredArgsConstructor
public class CustomerController {

    private final CustomerService customerService;

    @GetMapping("/{id}")
    public CustomerDTO getCustomer(final @PathVariable UUID id) {
        return customerService.getCustomer(id);
    }

    @GetMapping
    public List<CustomerDTO> getCustomers() {
        return customerService.getCustomers();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public CustomerDTO createCustomer(final @RequestBody @Valid CreateCustomerDTO createCustomerDTO) {
        return customerService.createCustomer(createCustomerDTO);
    }

    @PatchMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void updateCustomer(final @PathVariable UUID id, final @RequestBody @Valid UpdateCustomerDTO updateCustomerDTO) {
        customerService.updateCustomer(id, updateCustomerDTO);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteCustomer(final @PathVariable UUID id) {
        customerService.deleteCustomer(id);
    }
}

Code Explanation

@PatchMapping or @PutMapping?

For a partial substitution or update of data, you should use the PATCH method. If it is a full replacement, then you should use the PUT method.

This subtle difference is often overlooked in many current systems when designing APIs, so it’s good to keep in mind.

DeleteMapping

In the example, the entity deletion is done physically, meaning the data is deleted from the database. There is another type of deletion called a logical delete. It consists of updating a flag of a boolean type like “isActive”.

For logical deletion, I prefer to use the PATCH method.

ResponseEntity

Many developers use the ResponseEntity entity to wrap response objects in a @RestController. However, this is often unnecessary thanks to Spring Boot’s auto-configuration.

When a controller method returns an object, Spring Boot uses Jackson (by default) to automatically serialize it to JSON format. It also sets the Content-Type header to application/json and returns a 200 OK status code.

As can be seen in the getCustomer, getCustomers, and createCustomer methods, the DTO (CustomerDTO or List<CustomerDTO>) is returned directly. Spring takes care of the rest.

When should you use ResponseEntity?

ResponseEntity is very useful when you need finer control over the HTTP response. For example:

  1. Set dynamic status codes: If a resource is not found, you can return ResponseEntity.notFound().build(), which translates to a 404 Not Found. You can do a check with an early return after the response. Avoid using a try/catch.

  2. Add custom headers: After creating a resource, it may be a good practice to return a 201 Created along with the Location header containing the URL of the new resource.

@PostMapping
public ResponseEntity<CustomerDTO> createCustomer(final @RequestBody @Valid CreateCustomerDTO createCustomerDTO) {
    final CustomerDTO createdCustomer = customerService.createCustomer(createCustomerDTO);
    final URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(createdCustomer.getId())
            .toUri();
    return ResponseEntity.created(location).body(createdCustomer);
}
Why not use ResponseEntity<?>?

Avoid using generic types like ResponseEntity<?>. This practice hides the actual data type your API returns and makes it difficult to consume and to generate automatic documentation (as with OpenAPI/Swagger). Always specify the data type with ResponseEntity<CustomerDTO>.

@GetMapping("/{id}")
public ResponseEntity<CustomerDTO> getCustomer(final @PathVariable UUID id) {
    return ResponseEntity.ok(customerService.getCustomer(id));
}
  • Java
  • Spring Boot
Share: