Real data with JPA, H2 & Lombok
Replace the in-memory repository with Spring Data JPA, inspect generated SQL with H2, use Lombok carefully, and switch H2 from memory to file persistence.
Assumes you know
After this you should be able to
- Persist data with JPA
- Inspect generated SQL
Spring Boot by Building · Chapter 4
We give TaskFlow a real database by turning the model into an @Entity and the
repository into a JpaRepository. H2 starts in memory first, so it still resets on restart; then you
switch it to file mode to make data survive. The service and controller from Chapter 3 don't change one
line. Then Lombok deletes the model's boilerplate, and MySQL becomes a config/dependency swap.
Choose your route#
Study the same Real data with JPA, H2 & Lombok explanation with a different purpose: build and verify it in Learn deeply, or retrieve and defend it in Prepare for interviews.
- Persist data with JPA
- Inspect generated SQL
Shared core#
The chapter below remains the canonical explanation for both goals. Keep its model, examples, and trade-offs shared; only the deliberate practice changes with the route.
Learn deeply: build evidence#
Use this chapter’s TaskFlow work to make one small, observable change. Run the stated acceptance checks before recording practice.
Prepare for interviews: explain the trade-off#
Without notes, explain the boundary this chapter protects, one concrete implementation decision, and the cost of a tempting shortcut. Then answer one follow-up using the chapter’s own example.
Prove the seam. In Chapter 3 the repository was a Map; here it becomes a real database, and because
everything above depended on the repository's method names — not on how it stored things — nothing above
moves. That is the entire argument for layering, and you'll watch it pay off. We use H2 (the
Java in-app database) so there's nothing to install; MySQL is a config change at the end, not new code.
Section 1What actually sits between your code and the database#
Two names get muddled constantly. Pin them down before writing anything:
A rulebook/standard for mapping Java objects to table rows — the annotations
@Entity, @Id, etc. It's a spec, not a library you run.
The implementation of that rulebook that actually writes the SQL. Spring Boot bundles it by default. You rarely name it — but it's the engine.
And on top of both, Spring Data JPA is the convenience layer that gives you the repository for free — you declare an interface, it writes the class. The chain, top to bottom:
You write the @Entity and the repository interface (top), and you name the database in
application.properties (bottom). Everything in the middle — Hibernate generating SQL, JDBC
shipping it to the DB — Spring runs for you. Held this way, "JPA/Hibernate/Spring Data" stop being three scary
things and become one seam you configure at two ends.
Section 2Add two dependencies#
Open pom.xml and add the JPA starter and the H2 driver next to the web starter you already have.
(In a fresh project you'd tick these on start.spring.io; adding them by hand shows exactly what they are.)
<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>spring-boot-starter-data-jpa→ Spring Data JPA + Hibernate + the@Entity/JpaRepositorymachinery.h2atruntimescope → the database itself, needed only when the app runs, not when you compile. Swapping to MySQL later means swapping this one dependency.
Reload Maven (IntelliJ shows a little refresh icon). Now configure H2 in the settings sheet:
# --- H2 in-memory database ---
spring.datasource.url=jdbc:h2:mem:taskflow
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# let Hibernate create the tables from your @Entity classes at startup
spring.jpa.hibernate.ddl-auto=update
# handy while learning
spring.jpa.show-sql=true # print the SQL Hibernate runs
spring.h2.console.enabled=true # a web UI to browse the DB at /h2-consolespring.datasource.url— where the database is.h2:mem:taskflow= an H2 database living in memory. This is the one line that changes most when you switch databases.ddl-auto=update— Hibernate reads your@Entityclasses and creates/updates tables to match. Great for learning; in real production you'd manage schema more carefully.show-sql=true— prints every SQL statement to the console, so you can see the object → table translation happening. Turn this on; it's the best teacher in this chapter.
Section 3Turn the model into a table#
Take the Task from Chapter 3 and add four annotations. That's the whole change — the fields stay the
same, because they were always the columns-to-be.
package com.example.taskflow.model;
import jakarta.persistence.*;
@Entity // "this class is a table"
@Table(name = "tasks") // (optional) name the table explicitly
public class Task {
@Id // primary key
@GeneratedValue(strategy = GenerationType.IDENTITY) // DB auto-assigns the id
private Long id;
@Column(nullable = false) // NOT NULL column
private String title;
private boolean done; // maps to a column automatically; no annotation needed
public Task() { }
public Task(String title, boolean done) { // note: no id — the DB assigns it
this.title = title;
this.done = done;
}
// ...the same getters & setters as before...
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public boolean isDone() { return done; }
public void setDone(boolean done) { this.done = done; }
}@Entity→ the class maps to a table; each instance is one row.@Id+@GeneratedValue(IDENTITY)→ this field is the primary key, and the database hands out the value on insert (so our oldAtomicLongid counter is gone — the DB does it now).@Column(nullable=false)→ adds a NOT NULL constraint. Unannotated fields still become columns with sensible defaults; annotate only when you want to say more.- Constructor no longer takes an id — a brand-new task has no id until the DB assigns one. That mirrors reality.
Section 4Delete the repository, replace it with one interface#
This is the moment. Our Chapter 3 TaskRepository was ~30 lines of Map-juggling. Delete all of it and
replace the class with a one-line interface:
package com.example.taskflow.repository;
import com.example.taskflow.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> { }JpaRepository<Task, Long>means "a repository ofTaskwhose id type isLong." Spring Data generates the implementation at startup — you never write it.- You inherit exactly the methods you hand-wrote before:
findAll(),findById(),save(),deleteById()— pluscount(),existsById(), and more, free. - Because the method names match Chapter 3, the service compiles unchanged. That's the seam holding.
TaskService.java and TaskController.java — untouched. You swapped the entire storage
engine from a HashMap to a SQL database and the two layers above didn't notice, because they only ever spoke to
the repository's interface. Re-read that sentence when layering feels like pointless ceremony: this is
the return on it.
In Chapter 3, service.create() did new Task(null, title, false). Since the entity
constructor no longer takes an id, change that one line to new Task(title, false). That's the only
edit above the repository — and it's because of the entity's shape, not the database swap.
Section 5Run it and watch the SQL#
Start the app. Because show-sql=true, creating a task now prints the actual SQL Hibernate generated
from your entity:
You wrote no SQL. Hibernate read your @Entity and produced it. Now open
http://localhost:8080/h2-console in a browser, set JDBC URL to
jdbc:h2:mem:taskflow (must match the properties file), user sa, no password → Connect.
You'll see a TASKS table with your row in it. Seeing the actual table demystifies "where did my data
go."
h2:mem: lives in memory, so it resets when the app stops — same as Chapter 3, but now it's a real
SQL database while running. To make it survive restarts, switch to file mode:
spring.datasource.url=jdbc:h2:file:./data/taskflow. One line, and your tasks persist to disk.
Section 6Lombok — delete the boilerplate#
Your Task is mostly getters and setters. Lombok generates those at compile time
from a single annotation, so the source shows only what's interesting. It removes typing, not concepts — the
getters still exist in the compiled class.
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>Lombok works via annotation processing. In IntelliJ: install the Lombok plugin (bundled in recent versions) and enable Settings → Build → Compiler → Annotation Processors → Enable. Skip this and you'll get "cannot find method getTitle()" errors — a confusing failure that is only this missing switch.
Before — hand-written#
- ~25 lines
- Two constructors, six accessors
- Noise hides the three fields
After — Lombok#
- ~12 lines
- Accessors generated at compile time
- The fields are all you read
package com.example.taskflow.model;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "tasks")
@Getter @Setter // generate all getters & setters
@NoArgsConstructor // generate Task()
@AllArgsConstructor // generate Task(id, title, done)
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private boolean done;
}@Getter @Setter→ all accessors.@NoArgsConstructor/@AllArgsConstructor→ the two constructors.@Datais a bundle (getters + setters +toString+equals/hashCode) — convenient, but on JPA entities prefer the explicit set above, because generatedequals/hashCodecan misbehave with entity ids. A small pro-tip you'll be glad to know.- Since
@AllArgsConstructorincludes the id, either updateservice.create()to pass a null id, or add a small constructor — your call. Lombok is a convenience; you stay in charge of the API.
Section 7Swap H2 → MySQL (config, not code)#
Here's the promise from Chapter 1 made real. To move from H2 to MySQL you change one dependency and three properties. Not a single Java file changes — entity, repository, service, controller all stay.
Swap the driver in pom.xml#
<!-- remove h2, add: -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>Point at your MySQL in properties#
spring.datasource.url=\
jdbc:mysql://localhost:3306/taskflow
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.jpa.hibernate.ddl-auto=update- Changed: the driver dependency and the datasource URL/credentials. That's the whole diff.
- Unchanged:
Task(still@Entity),TaskRepository(stillextends JpaRepository), the service, the controller. JPA abstracts over the specific database. - You do need a running MySQL with a
taskflowdatabase (CREATE DATABASE taskflow;). That's the "install a server" cost H2 saved you while learning — now you pay it once, deliberately.
Mongo is not just a config change, because it isn't relational. You'd use
spring-boot-starter-data-mongodb, annotate the model with @Document instead of
@Entity, and have the repository extend MongoRepository instead of
JpaRepository. The method names stay the same (findAll/findById/save…), so your service
barely notices — but it's a genuine second persistence world, exactly as the map warned. The existing
Spring Boot Framework collection's MongoDB chapter goes deep here when
you want it.
Section 8Exercises#
Goal. GET /tasks?done=false returns only the open tasks. The magic:
Spring Data writes the query from the method name.
Add a method signature to the repository interface — no body — and Spring implements it by parsing the name:
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByDone(boolean done); // Spring writes: WHERE done = ?
}public List<Task> getAll(Boolean done) {
return (done == null) ? repository.findAll() : repository.findByDone(done);
}@GetMapping
public List<Task> all(@RequestParam(required = false) Boolean done) {
return service.getAll(done);
}Self-check: findByDone is a derived query — the name
findBy + field Done is a sentence Spring Data compiles into SQL. Try
findByTitleContaining(String q) next; the naming grammar is worth skimming in the docs.
Goal. Switch H2 to file mode so data persists, then verify a task created before a restart is still there after.
- Create a task, stop the app, start it again,
GET /tasks→ the task is still listed. - A
./datafolder appears in your project.
Hint 1
application.properties changes. Look back at the "survive restarts" pitfall
in Section 5.Show solution
spring.datasource.url=jdbc:h2:file:./data/taskflowThat single line moves H2 from memory to a file under ./data. No
Java changes — persistence is a config property, exactly like the database choice itself.
Notebook Summary#
Persistence on one page
The stack#
- JPA = the mapping spec · Hibernate = the engine that writes SQL · Spring Data JPA = free repositories
- You touch only two ends: the
@Entity+ repo interface (top), andapplication.properties(bottom)
Entity annotations#
@Entity→ table ·@Id+@GeneratedValue(IDENTITY)→ DB-assigned key@Column(nullable=false)→ constraints · unannotated fields still become columns
Repository & queries#
interface X extends JpaRepository<Task, Long>→ free CRUD, no implementation written- Derived queries:
findByDone(boolean),findByTitleContaining(String)— name → SQL
Swapping databases#
- H2 → MySQL = swap the driver dependency + 3 properties. No Java changes.
- H2
mem:resets on restart;file:persists - Mongo = the real fork:
@Document+MongoRepository
Box these#
- ⭐The seam held: swapping storage changed nothing in service/controller. That's why you layer.
- ⚠Lombok needs annotation processing enabled in the IDE, or getters "don't exist."
- ⚠Prefer explicit
@Getter/@Setterover@Dataon entities (equals/hashCode + ids). - 💡Turn on
show-sql=truewhile learning — watching the generated SQL is the fastest way to trust JPA.
Chapter 5 — Proving it works with Mockito. The last walkthrough: test the service in isolation by handing it a fake repository, and test the controller's HTTP layer alone. You'll finally see what a "mock" is and why constructor injection made it easy.
TaskFlow checkpoint#
Current state: tasks disappear on restart. Outcome: persisted tasks visible through H2. Prerequisite: the repository seam. Java bridge: annotations describe a class’s persistence role. Estimate: 45 minutes.
Mission: exchange the map for a database#
Turn Task into an entity, extend JPA’s repository, and prove a create/read flow with curl and the H2 console.
- A created task appears in
GET /tasksafter a restart when file-backed H2 is selected. - The repository extends
JpaRepository. - SQL or the H2 console shows the persisted row.
Likely files: model/Task.java, repository/TaskRepository.java, application.properties. Suggested commit: feat: persist taskflow tasks with h2.
Hint 1
Hint 2
Hint 3
Before opening the solution: create a task, inspect its row, and restart deliberately.
Reference patch — JPA persistence
Recall before continuing#
- What makes a Java class an entity?
- Why does the service not need SQL code?
- What distinguishes in-memory H2 from file-backed H2?
The next TaskFlow workshop protects this newly persistent model from untrusted HTTP input.