Lesson 73Phase 9: Data and the web
Saving data with Spring Data JPA
Mapping classes to tables with JPA entities, repositories that write the SQL for you, and a full create, read, update and delete API.
By BasithUpdated 55 min
- JPA
- @Entity
- @Id
- repository
- JpaRepository
- H2
The book API from the last lesson forgets every book the moment it stops, because the books live in a list in memory. This lesson stores them in a database. You will not write a line of SQL: you describe a book once, as a class, declare an interface, and Spring Data JPA writes the SQL for you, while you watch it do so.
- Explain what JPA does: a class becomes a table, and an object becomes a row.
- Write an entity with
@Entity,@Idand@GeneratedValue, and say why it is a class and not a record. - Get a working repository from an interface that extends
JpaRepository, including a query made from a method name. - Build the full create, read, update and delete API, with the right status codes.
- Reject bad input with validation annotations and
@Valid.
JPA in one picture#
Java keeps data in objects; a relational database keeps it in tables. Copying between them by hand is what you did with JDBC: read each column of each row and build an object. An ORM (object-relational mapper) does that copying for you. JPA, the Jakarta Persistence API, is Java's standard for ORMs, Hibernate is the implementation Spring Boot uses, and Spring Data JPA sits on top and writes whole repositories for you.
| Java | Database |
|---|---|
The class Book | The table book |
One Book object | One row |
The field title | The column title |
The field marked @Id | The primary key |
Three new dependencies#
In Spring Initializr, add Spring Data JPA, Validation and H2 Database to the project, or add them to the pom.xml by hand. H2 is a database written in Java that runs inside your program, in memory, so there is nothing to install; it starts empty on every run. Spring Boot sees it on the classpath and connects to it with no settings at all.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency><dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope></dependency>The entity#
An entity is a class whose objects are stored as rows. Book stops being a record and becomes a class:
package com.example.library; import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.Id; @Entitypublic class Book { @Id @GeneratedValue private Long id; private String title; private String author; protected Book() { } public Book(String title, String author) { this.title = title; this.author = author; } public Long getId() { return id; } public String getTitle() { return title; } public String getAuthor() { return author; } public void setTitle(String title) { this.title = title; } public void setAuthor(String author) { this.author = author; }}@Entitysays “store objects of this class in a table”. The table is named after the class,book, and each field becomes a column.@Idmarks the primary key, and@GeneratedValuelets the database choose it. It is aLong, not along, so a new book that is not saved yet can havenullas its id.- JPA builds objects when it reads rows, by calling a constructor with no arguments and then filling in the fields. That is why there is a
protected Book(), and why an entity cannot be a record, whose fields never change.
The repository: an interface, and nothing else#
package com.example.library; import java.util.List;import org.springframework.data.jpa.repository.JpaRepository; public interface BookRepository extends JpaRepository<Book, Long> { List<Book> findByAuthor(String author);}You never write a class that implements this interface. When the program starts, Spring Data creates one, and it can be injected anywhere, like the BookService in the last lesson. JpaRepository<Book, Long> (a Book repository, with Long ids) brings the usual methods:
| Method | Does |
|---|---|
findAll() | Every book, as a List |
findById(id) | The book, as an Optional, empty if there is none |
save(book) | Inserts a new book (no id yet) or updates an existing one, and returns it with its id |
existsById(id), count() | Whether a book exists; how many there are |
deleteById(id) | Removes the book |
And findByAuthor? Spring Data reads the method's name: find, by, the field author. From that it writes a query with where author = ?. Names like findByTitleContaining or findByAuthorOrderByTitle work the same way. These are derived queries.
Key ideaDescribe the data once as an entity, declare a repository interface, and Spring Data supplies the code and the SQL. You only write SQL for questions a method name cannot express.
The controller, now with a database#
The controller asks for a BookRepository instead of a BookService, and gains update and delete:
@RestController@RequestMapping("/books")public class BookController { private final BookRepository books; public BookController(BookRepository books) { this.books = books; } @GetMapping public List<Book> all(@RequestParam(required = false) String author) { return author == null ? books.findAll() : books.findByAuthor(author); } @GetMapping("/{id}") public ResponseEntity<Book> one(@PathVariable long id) { return books.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<Book> add(@Valid @RequestBody NewBook request) { Book saved = books.save(new Book(request.title(), request.author())); return ResponseEntity.status(HttpStatus.CREATED).body(saved); } @PutMapping("/{id}") public ResponseEntity<Book> update(@PathVariable long id, @Valid @RequestBody NewBook request) { return books.findById(id) .map(book -> { book.setTitle(request.title()); book.setAuthor(request.author()); return ResponseEntity.ok(books.save(book)); }) .orElse(ResponseEntity.notFound().build()); } @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable long id) { if (!books.existsById(id)) { return ResponseEntity.notFound().build(); } books.deleteById(id); return ResponseEntity.noContent().build(); }}Two books are added at start-up by a small CommandLineRunner, a bean whose code Spring runs once the application is ready:
@BeanCommandLineRunner seed(BookRepository books) { return args -> { books.save(new Book("Dune", "Frank Herbert")); books.save(new Book("Emma", "Jane Austen")); };}Running it, and watching the SQL#
Add spring.jpa.show-sql=true to application.properties and Hibernate prints every SQL statement it sends. At start-up it builds the table from the entity:
$ mvn spring-boot:run...Hibernate: drop table if exists book cascadeHibernate: drop sequence if exists book_seqHibernate: create sequence book_seq start with 1 increment by 50Hibernate: create table book (id bigint not null, author varchar(255), title varchar(255), primary key (id))...2026-10-19T15:55:44.645+03:00 INFO 59412 --- [library] [ main] com.example.library.LibraryApplication : Started LibraryApplication in 4.556 seconds (process running for 5.106)Hibernate: select next value for book_seqHibernate: insert into book (author,title,id) values (?,?,?)Hibernate: select next value for book_seqHibernate: insert into book (author,title,id) values (?,?,?)Hibernate made a book table with a column per field, and a sequence that hands out ids. The two inserts are the seed books. The question marks are the placeholders from JDBC: the values travel separately, so they are safe from SQL injection. Now the requests, each followed by the SQL it caused:
$ curl http://localhost:8080/books[{"title":"Dune","author":"Frank Herbert","id":1},{"title":"Emma","author":"Jane Austen","id":2}] Hibernate: select b1_0.id,b1_0.author,b1_0.title from book b1_0 $ curl "http://localhost:8080/books?author=Jane%20Austen"[{"title":"Emma","author":"Jane Austen","id":2}] Hibernate: select b1_0.id,b1_0.author,b1_0.title from book b1_0 where b1_0.author=? $ curl -i -X POST http://localhost:8080/books -H "Content-Type: application/json" \ -d '{"title": "Solaris", "author": "Stanislaw Lem"}'HTTP/1.1 201Content-Type: application/json {"title":"Solaris","author":"Stanislaw Lem","id":3} Hibernate: insert into book (author,title,id) values (?,?,?) $ curl -X PUT http://localhost:8080/books/2 -H "Content-Type: application/json" \ -d '{"title": "Persuasion", "author": "Jane Austen"}'{"title":"Persuasion","author":"Jane Austen","id":2} $ curl -i -X DELETE http://localhost:8080/books/1HTTP/1.1 204 Hibernate: select count(*) from book b1_0 where b1_0.id=? Hibernate: select b1_0.id,b1_0.author,b1_0.title from book b1_0 where b1_0.id=? Hibernate: delete from book where id=? $ curl http://localhost:8080/books[{"title":"Persuasion","author":"Jane Austen","id":2},{"title":"Solaris","author":"Stanislaw Lem","id":3}](The indented Hibernate:lines appeared in the server's terminal, not in curl's; they are placed under each request here to pair them up.) findByAuthor really became where author=?. The JSON is built from the entity's getters: getTitle, getAuthor and getId become title, author and id. DELETE answers 204 No Content, success with nothing to send back, and /books/1 is now a 404.
spring.datasource.url, spring.datasource.username and spring.datasource.password in application.properties (with the password kept out of the file in practice). The entity, repository and controller do not change.Refusing bad input#
Without checks, a client could add a book with an empty title. The validation starter adds annotations that state the rules on the data itself:
package com.example.library; import jakarta.validation.constraints.NotBlank; public record NewBook(@NotBlank String title, @NotBlank String author) {}@NotBlank means not null and not just spaces. Other common ones are @NotNull, @Size(max = 200), @Min(0) and @Email. The rules are only checked where a parameter is marked @Valid, as in add and update above. A request that breaks one never reaches your method:
$ curl -i -X POST http://localhost:8080/books -H "Content-Type: application/json" \ -d '{"title": "", "author": "Nobody"}'HTTP/1.1 400Content-Type: application/json... {"timestamp":"2026-10-19T12:55:58.021Z","status":400,"error":"Bad Request","path":"/books"}JpaRepository NOT give you for free, so that you must declare it yourself?Book stay a record once it is an @Entity?Try it
Answer each one in your head or on paper first, then open the answer.
save both inserts and updates. How does it know which to do?PUT /books/2 with {"title": " ", "author": "Jane Austen"}. What does it get back, and does the database change?Practice
Work through these on your own. Each one builds on this page.
year field to Book (and to NewBook, with @Min(0)), and a derived query that lists books published after a given year, newest first. Test it with curl, and read the SQL Hibernate prints for it.Member, with its own repository and controller at /members. Then add POST /books/{id}/borrow that records which member has a book. Think first about where that information should live, as the console app project did with its loans.spring.jpa.show-sql=false and instead set the log level of org.hibernate.SQL to DEBUG in application.properties. What changes in how the SQL is printed, and why would a team prefer that version?Further reading#
- Accessing Data with JPA, a Spring getting-started guide
- The Spring Data JPA reference, including the rules for derived query names
- Validation, in the Spring Boot reference
Short guides on this topic
Quick answers to what people search for about it, with code to run.
Spotted a mistake, or a part that did not make sense? Report it by email