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=falseTurning 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();
}
}POSTreturns201 Createdwith aLocationheader;DELETEreturns204 No Content.@Validtriggers the validation annotations on the request record.Pageablereadspage,sizeandsortfrom the query string. Wrapping the result inPagedModelproduces a stable JSON shape (contentplus apageobject) instead of serializing Spring’s internalPagetype.
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: 0Step 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.propertiesin source control. - Add
spring-boot-starter-actuatorfor health checks and metrics, and expose only the endpoints you need. - Package with
./mvnw packageand 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.








