Scientyfic World

How to Build a RESTful API With Java and Spring Boot 4 (Validation, Security, Tests)

This guide builds a RESTful API for a book catalog with Spring Boot 4: a JPA entity, request and response records with validation, a service layer, a controller with pagination,...

Share:

Get an AI summary of this article

busilding restful api with Java and Spring Boot

This guide builds a RESTful API for a book catalog with Spring Boot 4: a JPA entity, request and response records with validation, a service layer, a controller with pagination, consistent RFC 9457 error responses, Spring Security with role-based access, OpenAPI documentation, and tests that exercise the real HTTP layer.

What I tested: the project was generated with Spring Initializr (Spring Boot 4.1.1, Java 21, Hibernate 7.4.5, H2) plus springdoc-openapi 3.1.1. Five MockMvc test groups pass, covering CRUD, validation, malformed JSON, duplicate ISBNs, security rules, pagination with filtering, and the OpenAPI document. I also ran the application and called it with curl, and loaded Swagger UI.

The 2024 version of this post imported javax.persistence and javax.validation. Spring Boot 3 moved to Jakarta EE, so those imports do not compile on any current Spring Boot; they are jakarta.* now. Its exception handler also returned raw exception messages to clients with a 500 status.

Step 1: Generate the Project

On Spring Initializr, choose Maven, Java 21, Spring Boot 4.1.x, and the dependencies Spring Web, Spring Data JPA, Validation, Spring Security and H2. Then add springdoc for API docs to pom.xml:

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>3.1.1</version>
</dependency>

Spring Boot 4 splits its starters into smaller modules, so the generated pom.xml contains spring-boot-starter-webmvc and matching test starters such as spring-boot-starter-webmvc-test. Keep what Initializr gives you.

spring.application.name=books
spring.jpa.open-in-view=false

Turning off open-in-view (still on by default in Boot 4.1) keeps database access inside the service layer.

Step 2: The Entity

The @Version field adds optimistic locking: if two clients edit the same book at once, the second update fails instead of silently overwriting the first.

package com.example.books;

import jakarta.persistence.*;

@Entity
public class Book {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String author;

    @Column(nullable = false, unique = true, length = 13)
    private String isbn;

    private Integer publishedYear;

    @Version
    private Long version; // optimistic locking: concurrent edits fail instead of overwriting each other

    protected Book() {}

    public Book(String title, String author, String isbn, Integer publishedYear) {
        update(title, author, isbn, publishedYear);
    }

    public void update(String title, String author, String isbn, Integer publishedYear) {
        this.title = title; this.author = author; this.isbn = isbn; this.publishedYear = publishedYear;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getAuthor() { return author; }
    public String getIsbn() { return isbn; }
    public Integer getPublishedYear() { return publishedYear; }
}
package com.example.books;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
    Page<Book> findByAuthorContainingIgnoreCase(String author, Pageable pageable);
    boolean existsByIsbn(String isbn);
}

Step 3: Request and Response Records

Never expose JPA entities directly as request bodies: clients could set fields they should not (like id or version), and entity changes would break your API contract. Records with Jakarta Bean Validation annotations define exactly what the API accepts and returns:

package com.example.books;

import jakarta.validation.constraints.*;

// Request and response bodies are records, separate from the JPA entity.
public final class BookDtos {
    private BookDtos() {}

    public record BookRequest(
        @NotBlank @Size(max = 200) String title,
        @NotBlank @Size(max = 120) String author,
        @NotBlank @Pattern(regexp = "\\d{13}", message = "must be 13 digits") String isbn,
        @Min(1450) @Max(2100) Integer publishedYear) {}

    public record BookResponse(Long id, String title, String author, String isbn, Integer publishedYear) {
        static BookResponse from(Book b) {
            return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getIsbn(), b.getPublishedYear());
        }
    }
}

Step 4: The Service Layer

The service owns transactions and business rules, such as unique ISBNs. Read methods use a read-only transaction; updates rely on JPA dirty checking, so no explicit save is needed:

package com.example.books;

import com.example.books.BookDtos.BookRequest;
import com.example.books.BookDtos.BookResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)
public class BookService {
    private final BookRepository books;

    public BookService(BookRepository books) { this.books = books; }

    public Page<BookResponse> list(String author, Pageable pageable) {
        Page<Book> page = author == null || author.isBlank()
            ? books.findAll(pageable)
            : books.findByAuthorContainingIgnoreCase(author, pageable);
        return page.map(BookResponse::from);
    }

    public BookResponse get(Long id) {
        return BookResponse.from(books.findById(id).orElseThrow(() -> new BookNotFoundException(id)));
    }

    @Transactional
    public BookResponse create(BookRequest r) {
        if (books.existsByIsbn(r.isbn())) throw new DuplicateIsbnException(r.isbn());
        return BookResponse.from(books.save(new Book(r.title(), r.author(), r.isbn(), r.publishedYear())));
    }

    @Transactional
    public BookResponse update(Long id, BookRequest r) {
        Book book = books.findById(id).orElseThrow(() -> new BookNotFoundException(id));
        book.update(r.title(), r.author(), r.isbn(), r.publishedYear());
        return BookResponse.from(book); // saved on commit by dirty checking
    }

    @Transactional
    public void delete(Long id) {
        if (!books.existsById(id)) throw new BookNotFoundException(id);
        books.deleteById(id);
    }
}
package com.example.books;

public class BookNotFoundException extends RuntimeException {
    public BookNotFoundException(Long id) { super("Book " + id + " not found"); }
}
package com.example.books;

public class DuplicateIsbnException extends RuntimeException {
    public DuplicateIsbnException(String isbn) { super("A book with ISBN " + isbn + " already exists"); }
}

Step 5: The Controller

package com.example.books;

import com.example.books.BookDtos.BookRequest;
import com.example.books.BookDtos.BookResponse;
import jakarta.validation.Valid;
import java.net.URI;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.data.web.PagedModel;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/books")
public class BookController {
    private final BookService service;

    public BookController(BookService service) { this.service = service; }

    // GET /api/books?author=orwell&page=0&size=20&sort=title,asc
    @GetMapping
    public PagedModel<BookResponse> list(@RequestParam(required = false) String author,
                                        @PageableDefault(size = 20, sort = "title") Pageable pageable) {
        return new PagedModel<>(service.list(author, pageable));
    }

    @GetMapping("/{id}")
    public BookResponse get(@PathVariable Long id) {
        return service.get(id);
    }

    @PostMapping
    public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest request) {
        BookResponse created = service.create(request);
        return ResponseEntity.created(URI.create("/api/books/" + created.id())).body(created);
    }

    @PutMapping("/{id}")
    public BookResponse update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
        return service.update(id, request);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }
}
  • POST returns 201 Created with a Location header; DELETE returns 204 No Content.
  • @Valid triggers the validation annotations on the request record.
  • Pageable reads page, size and sort from the query string. Wrapping the result in PagedModel produces a stable JSON shape (content plus a page object) instead of serializing Spring’s internal Page type.

Step 6: Errors as Problem Details

Spring Framework supports RFC 9457 problem details with ProblemDetail. Extending ResponseEntityExceptionHandler makes Spring MVC’s own errors (malformed JSON, unsupported methods, missing parameters) use the same application/problem+json format as your domain errors:

package com.example.books;

import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

// Extending ResponseEntityExceptionHandler makes Spring MVC's own errors (bad JSON, wrong method,
// missing parameters...) use RFC 9457 problem details too, alongside our domain errors.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    ProblemDetail notFound(BookNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(DuplicateIsbnException.class)
    ProblemDetail conflict(DuplicateIsbnException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
    }

    // Validation failures: 422 with one entry per invalid field.
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_CONTENT, "Request has invalid fields");
        List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
            .map(f -> Map.of("field", f.getField(), "message", String.valueOf(f.getDefaultMessage())))
            .toList();
        problem.setProperty("errors", errors);
        return ResponseEntity.unprocessableContent().body(problem);
    }
}

A detail from testing: I first wrote a plain @ExceptionHandler(MethodArgumentNotValidException.class) and also set spring.mvc.problemdetails.enabled=true. Validation errors then came back as 400 from Spring Boot’s built-in handler instead of my 422, because that property registers its own ResponseEntityExceptionHandler, which handles validation exceptions first. Extending the base class and overriding handleMethodArgumentNotValid, without the property, gave one handler for everything.

Here is what a missing book looks like:

$ curl -i -u admin:change-me-admin localhost:8080/api/books/99
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"Book 99 not found","instance":"/api/books/99","status":404,"title":"Not Found"}

Step 7: Security

Spring Security 7 is configured with a SecurityFilterChain bean; the old WebSecurityConfigurerAdapter no longer exists. Anyone can read books, only admins can write, and everything else is denied:

package com.example.books;

import static org.springframework.security.config.Customizer.withDefaults;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/books/**").permitAll()   // anyone can read
                .requestMatchers("/api/books/**").hasRole("ADMIN")               // writes need ADMIN
                .anyRequest().denyAll())
            .httpBasic(withDefaults())
            .csrf(csrf -> csrf.disable())          // stateless API with no cookies: CSRF protection not needed
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .build();
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default
    }

    // Demo users only. In production, load users from a database or use an identity provider (OAuth2/OIDC).
    @Bean
    UserDetailsService users(PasswordEncoder encoder) {
        return new InMemoryUserDetailsManager(
            User.withUsername("admin").password(encoder.encode("change-me-admin")).roles("ADMIN").build(),
            User.withUsername("reader").password(encoder.encode("change-me-reader")).roles("USER").build());
    }
}

HTTP Basic with in-memory users keeps the example short. For real users, store accounts in a database with hashed passwords, or let an identity provider issue tokens and configure the app as an OAuth2 resource server with spring-boot-starter-oauth2-resource-server. Always serve the API over HTTPS. CSRF protection is disabled because this API is stateless and does not use cookies; keep it enabled if browsers authenticate with session cookies.

Step 8: API Documentation

With springdoc on the classpath, the OpenAPI document is generated from your controllers at /v3/api-docs, and Swagger UI is served at /swagger-ui.html. Both are permitted in the security config. Publishing the OpenAPI document lets client teams generate typed clients.

Step 9: Test the API

@SpringBootTest with @AutoConfigureMockMvc runs requests through the full stack, including security, validation, error handling and the database, without starting a server. In Spring Boot 4, @AutoConfigureMockMvc lives in org.springframework.boot.webmvc.test.autoconfigure.

package com.example.books;

import static org.hamcrest.Matchers.*;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class BookApiTest {

    @Autowired MockMvc mvc;

    static final String ORWELL = """
        {"title":"Nineteen Eighty-Four","author":"George Orwell","isbn":"9780451524935","publishedYear":1949}""";

    private String createAsAdmin(String json) throws Exception {
        return mvc.perform(post("/api/books").with(httpBasic("admin", "change-me-admin"))
                .contentType(MediaType.APPLICATION_JSON).content(json))
            .andExpect(status().isCreated())
            .andReturn().getResponse().getHeader("Location");
    }

    @Test
    void createReadUpdateDelete() throws Exception {
        String location = createAsAdmin(ORWELL);
        mvc.perform(get(location))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("Nineteen Eighty-Four"));

        mvc.perform(put(location).with(httpBasic("admin", "change-me-admin")).contentType(MediaType.APPLICATION_JSON)
                .content(ORWELL.replace("Nineteen Eighty-Four", "1984")))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.title").value("1984"));

        mvc.perform(delete(location).with(httpBasic("admin", "change-me-admin"))).andExpect(status().isNoContent());
        mvc.perform(get(location))
            .andExpect(status().isNotFound())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON));
    }

    @Test
    void validationAndDuplicates() throws Exception {
        mvc.perform(post("/api/books").with(httpBasic("admin", "change-me-admin")).contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"title":"","author":"X","isbn":"123","publishedYear":3000}"""))
            .andExpect(status().isUnprocessableContent())
            .andExpect(jsonPath("$.errors[*].field", containsInAnyOrder("title", "isbn", "publishedYear")));

        mvc.perform(post("/api/books").with(httpBasic("admin", "change-me-admin"))
                .contentType(MediaType.APPLICATION_JSON).content("{not json"))
            .andExpect(status().isBadRequest())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON));

        createAsAdmin(ORWELL);
        mvc.perform(post("/api/books").with(httpBasic("admin", "change-me-admin"))
                .contentType(MediaType.APPLICATION_JSON).content(ORWELL))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.detail", containsString("9780451524935")));
    }

    @Test
    void securityRules() throws Exception {
        mvc.perform(get("/api/books")).andExpect(status().isOk());                                   // public read
        mvc.perform(post("/api/books").contentType(MediaType.APPLICATION_JSON).content(ORWELL))
            .andExpect(status().isUnauthorized());                                                    // no credentials
        mvc.perform(post("/api/books").with(httpBasic("reader", "change-me-reader"))
                .contentType(MediaType.APPLICATION_JSON).content(ORWELL))
            .andExpect(status().isForbidden());                                                       // wrong role
        mvc.perform(get("/actuator")).andExpect(status().isUnauthorized());                           // everything else denied
    }

    @Test
    void paginationAndFiltering() throws Exception {
        createAsAdmin(ORWELL);
        createAsAdmin("""
            {"title":"Animal Farm","author":"George Orwell","isbn":"9780451526342","publishedYear":1945}""");
        createAsAdmin("""
            {"title":"Brave New World","author":"Aldous Huxley","isbn":"9780060850524","publishedYear":1932}""");

        mvc.perform(get("/api/books").param("author", "orwell").param("size", "1").param("sort", "title,asc"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.content", hasSize(1)))
            .andExpect(jsonPath("$.content[0].title").value("Animal Farm"))
            .andExpect(jsonPath("$.page.totalElements").value(2))
            .andExpect(jsonPath("$.page.totalPages").value(2));
    }

    @Test
    void openApiDocsArePublished() throws Exception {
        mvc.perform(get("/v3/api-docs"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.paths['/api/books']").exists());
    }
}
$ ./mvnw test
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0

Step 10: Run and Call It

$ ./mvnw spring-boot:run

$ curl -i -u admin:change-me-admin -X POST localhost:8080/api/books \
    -H 'Content-Type: application/json' \
    -d '{"title":"Dune","author":"Frank Herbert","isbn":"9780441172719","publishedYear":1965}'
HTTP/1.1 201
Location: /api/books/1
{"id":1,"title":"Dune","author":"Frank Herbert","isbn":"9780441172719","publishedYear":1965}

$ curl 'localhost:8080/api/books?author=herbert'
{"content":[{"id":1,"title":"Dune",...}],"page":{"size":20,"number":0,"totalElements":1,"totalPages":1}}

Going to Production

  • Replace H2 with PostgreSQL or MySQL, set spring.jpa.hibernate.ddl-auto=validate, and manage the schema with Flyway or Liquibase migrations.
  • Read credentials and URLs from environment variables, not from application.properties in source control.
  • Add spring-boot-starter-actuator for health checks and metrics, and expose only the endpoints you need.
  • Package with ./mvnw package and run the JAR, or build a container image with ./mvnw spring-boot:build-image (requires Docker), then deploy to any JVM or container platform.

Conclusion

A solid Spring Boot API keeps entities internal, validates request records, puts transactions and rules in a service, returns proper status codes, reports every error as problem details, secures writes with Spring Security, documents itself with OpenAPI, and is tested through the full HTTP stack. For more advanced queries behind these endpoints, see advanced Spring Data JPA queries; for the same design in JavaScript, see building a REST API with Node.js and Express.

FAQs

Why does javax.persistence not compile in my Spring Boot project?

Spring Boot 3 and later use Jakarta EE, so JPA and validation imports moved from javax.* to jakarta.*, for example jakarta.persistence.Entity and jakarta.validation.constraints.NotBlank.

How do I return consistent error responses in Spring Boot?

Return ProblemDetail objects from a @RestControllerAdvice that extends ResponseEntityExceptionHandler. Your domain errors and Spring MVC’s built-in errors then all use the RFC 9457 application/problem+json format.

What replaced WebSecurityConfigurerAdapter?

Define a SecurityFilterChain bean that configures HttpSecurity with lambdas, as in this guide. WebSecurityConfigurerAdapter was removed in Spring Security 6.

Should I expose JPA entities in my REST API?

No. Use separate request and response classes such as records. This prevents clients from setting internal fields, avoids lazy-loading surprises during serialization, and keeps your API stable when the database model changes.

How do I paginate results in a Spring Boot REST API?

Accept a Pageable parameter in the controller, return Page results from the repository, and wrap them in PagedModel for a stable JSON structure. Clients pass page, size and sort as query parameters.

How do I add Swagger documentation to Spring Boot 4?

Add springdoc-openapi-starter-webmvc-ui (version 3.x for Spring Boot 4). The OpenAPI JSON is served at /v3/api-docs and Swagger UI at /swagger-ui.html.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts