26. Spring Data JPA and H2
Until now we used in-memory `Map`s. Now we add a **persistence layer** with **Spring Data JPA** and the **H2 in-memory database** so data survives between calls. JPA is the ORM standard that automates the mapping between Java objects and SQL.
What you'll learn
- 1Map a class to a table with `@Entity`
- 2Get CRUD for free by defining a `JpaRepository`
- 3Inspect the actual SQL via the H2 console
- 4Wire up a small CRUD notepad API end-to-end
Overview
Until now we used in-memory `Map`s. Now we add a **persistence layer** with **Spring Data JPA** and the **H2 in-memory database** so data survives between calls. JPA is the ORM standard that automates the mapping between Java objects and SQL.
Core Concepts
1) Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>2) `application.properties`
spring.datasource.url=jdbc:h2:mem:demo
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=trueVisit `http://localhost:8080/h2-console` and connect with `jdbc:h2:mem:demo` to see the data.
3) `@Entity`
import jakarta.persistence.*;
@Entity
@Table(name = "notes")
public class Note {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(columnDefinition = "TEXT")
private String body;
protected Note() {} // JPA needs a no-arg ctor
public Note(String title, String body) { this.title = title; this.body = body; }
public Long getId() { return id; }
public String getTitle() { return title; }
public String getBody() { return body; }
}4) `JpaRepository`
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface NoteRepository extends JpaRepository<Note, Long> {
List<Note> findByTitleContaining(String keyword); // query method
}Spring generates the implementation at startup β no SQL needed for basic CRUD.
5) Standard CRUD methods
| Method | Effect |
|---|---|
| `save(entity)` | INSERT or UPDATE |
| `findById(id)` | SELECT one (returns `Optional`) |
| `findAll()` | SELECT all |
| `deleteById(id)` | DELETE |
| `count()` | COUNT |
Examples
Example 1 β `Note.java` (entity)
package com.example.demo;
import jakarta.persistence.*;
@Entity
@Table(name = "notes")
public class Note {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200) private String title;
@Column(columnDefinition = "TEXT") private String body;
protected Note() {}
public Note(String title, String body) { this.title = title; this.body = body; }
public Long getId() { return id; }
public String getTitle() { return title; }
public String getBody() { return body; }
}Example 2 β `NoteRepository.java`
package com.example.demo;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface NoteRepository extends JpaRepository<Note, Long> {
List<Note> findByTitleContaining(String keyword);
}Example 3 β `NoteController.java`
package com.example.demo;
import java.util.List;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/notes")
public class NoteController {
private final NoteRepository repo;
public NoteController(NoteRepository repo) { this.repo = repo; }
record CreateNoteRequest(String title, String body) {}
record NoteResponse(Long id, String title, String body) {}
@GetMapping
public List<NoteResponse> all() {
return repo.findAll().stream()
.map(n -> new NoteResponse(n.getId(), n.getTitle(), n.getBody()))
.toList();
}
@GetMapping("/{id}")
public ResponseEntity<NoteResponse> get(@PathVariable Long id) {
return repo.findById(id)
.map(n -> ResponseEntity.ok(new NoteResponse(n.getId(), n.getTitle(), n.getBody())))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<NoteResponse> create(@RequestBody CreateNoteRequest req) {
Note saved = repo.save(new Note(req.title(), req.body()));
return ResponseEntity.status(HttpStatus.CREATED)
.body(new NoteResponse(saved.getId(), saved.getTitle(), saved.getBody()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!repo.existsById(id)) return ResponseEntity.notFound().build();
repo.deleteById(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/search")
public List<NoteResponse> search(@RequestParam String q) {
return repo.findByTitleContaining(q).stream()
.map(n -> new NoteResponse(n.getId(), n.getTitle(), n.getBody()))
.toList();
}
}Example 4 β curl walkthrough
$ curl -X POST localhost:8080/notes -H 'Content-Type: application/json' \
-d '{"title":"shopping","body":"milk, bread"}'
{"id":1,"title":"shopping","body":"milk, bread"}
$ curl localhost:8080/notes
[{"id":1,"title":"shopping","body":"milk, bread"}]
$ curl 'localhost:8080/notes/search?q=shop'
[{"id":1,"title":"shopping","body":"milk, bread"}]
$ curl -X DELETE localhost:8080/notes/1Common Mistakes
- Forgetting a protected/public no-arg constructor on the entity β JPA refuses to map
- Returning entities directly to the client (lazy collections, security leaks) β map to DTO
- Using `spring.jpa.hibernate.ddl-auto=create-drop` in production (data wiped on restart)
- Holding the entity outside the transactional boundary and getting `LazyInitializationException`
- Writing a custom `save` that overrides `JpaRepository.save` and breaks Hibernate state management
Summary
- `@Entity` maps a class to a table; JPA handles the SQL
- `JpaRepository` gives you CRUD for free
- H2 + the H2 console are perfect for learning
Practice
# Practice - 26. Spring Data JPA
## Exercise 1 β `Book` entity + repository
- Map a `Book(id, title, author)` entity.
- Add `findByAuthor(String author)` to the repository.
## Exercise 2 β REST endpoints for `/books`
- Implement Create / List / Get / Delete using the repository.
Expected
$ curl localhost:8080/books
[{"id":1,"title":"Effective Java","author":"Joshua Bloch"}]## Solutions After trying it yourself, compare with [`answer/`](./answer/).
Solution code (homework/answer/)
answer/Book.java
package com.example.demo;
import jakarta.persistence.*;
@Entity
@Table(name = "books")
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200) private String title;
@Column(nullable = false, length = 100) 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; }
}
answer/BookRepository.java
package com.example.demo;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
List<Book> findByAuthor(String author);
}
answer/BookController.java
package com.example.demo;
import java.util.List;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/books")
public class BookController {
private final BookRepository repo;
public BookController(BookRepository repo) { this.repo = repo; }
record CreateBookRequest(String title, String author) {}
record BookResponse(Long id, String title, String author) {}
@GetMapping
public List<BookResponse> all() {
return repo.findAll().stream()
.map(b -> new BookResponse(b.getId(), b.getTitle(), b.getAuthor()))
.toList();
}
@PostMapping
public ResponseEntity<BookResponse> create(@RequestBody CreateBookRequest req) {
Book saved = repo.save(new Book(req.title(), req.author()));
return ResponseEntity.status(HttpStatus.CREATED)
.body(new BookResponse(saved.getId(), saved.getTitle(), saved.getAuthor()));
}
@GetMapping("/{id}")
public ResponseEntity<BookResponse> get(@PathVariable Long id) {
return repo.findById(id)
.map(b -> ResponseEntity.ok(new BookResponse(b.getId(), b.getTitle(), b.getAuthor())))
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
if (!repo.existsById(id)) return ResponseEntity.notFound().build();
repo.deleteById(id);
return ResponseEntity.noContent().build();
}
}
Try It Yourself
mvn spring-boot:run
# Then open http://localhost:8080/h2-console
# JDBC URL: jdbc:h2:mem:demoNext Lecture
Nice work β you've finished the course!
You completed all 26 lectures. Where to next?
- Authentication / authorization (Spring Security)
- Real databases (PostgreSQL / MySQL)
- Calling external APIs (RestTemplate / WebClient)
- Testing (Mockito + MockMvc)
- Deployment (Docker / Cloud)
Keep building. The mini-project workbook in the root [README](../../) is a good next step.
All lecture materials and example code are openly available on GitHub.
View on GitHub β