Spring Boot by Building · Chapter 4

Real data with JPA, H2 & Lombok

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.

Chapter 4 · We do — fully guided · Hands-on ~40 min · H2 first (zero install)


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.

Learning goal

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.

// goal of this chapter

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:

JPA (Jakarta Persistence)

A rulebook/standard for mapping Java objects to table rows — the annotations @Entity, @Id, etc. It's a spec, not a library you run.

Hibernate

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:

Your Service→ Spring Data JPA→ JPA (spec)→ Hibernate→ JDBC→ H2 / MySQL
// you only touch the top and the 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.)

add to pom.xml <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>
what each buys you
  • spring-boot-starter-data-jpa → Spring Data JPA + Hibernate + the @Entity/ JpaRepository machinery.
  • h2 at runtime scope → 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:

src/main/resources/application.properties
# --- 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-console
the lines that matter
  • spring.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 @Entity classes 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.

model/Task.java — now an entity
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; }
}
object → table, decoded
  • @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 old AtomicLong id 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:

repository/TaskRepository.java — the whole file now
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> { }
how one empty interface replaces 30 lines
  • JpaRepository<Task, Long> means "a repository of Task whose id type is Long." Spring Data generates the implementation at startup — you never write it.
  • You inherit exactly the methods you hand-wrote before: findAll(), findById(), save(), deleteById() — plus count(), existsById(), and more, free.
  • Because the method names match Chapter 3, the service compiles unchanged. That's the seam holding.
// look at what you did NOT touch

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.

// one small service tweak (because ids changed)

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:

$ curl -X POST http://localhost:8080/tasks \ -H "Content-Type: application/json" -d '{"title":"Persist me","done":false}' {"id":1,"title":"Persist me","done":false} # in the app console: Hibernate: insert into tasks (done,title) values (?,?) Hibernate: select t1_0.id,t1_0.done,t1_0.title from tasks t1_0 where t1_0.id=?

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."

// pitfall — in-memory H2 still forgets on restart

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.

add to pom.xml
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
// one-time IDE step (or nothing compiles)

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
model/Task.java — Lombok version
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;
}
the common Lombok annotations
  • @Getter @Setter → all accessors. @NoArgsConstructor / @AllArgsConstructor → the two constructors.
  • @Data is a bundle (getters + setters + toString + equals/ hashCode) — convenient, but on JPA entities prefer the explicit set above, because generated equals/hashCode can misbehave with entity ids. A small pro-tip you'll be glad to know.
  • Since @AllArgsConstructor includes the id, either update service.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
what changed, what didn't
  • Changed: the driver dependency and the datasource URL/credentials. That's the whole diff.
  • Unchanged: Task (still @Entity), TaskRepository (still extends JpaRepository), the service, the controller. JPA abstracts over the specific database.
  • You do need a running MySQL with a taskflow database (CREATE DATABASE taskflow;). That's the "install a server" cost H2 saved you while learning — now you pay it once, deliberately.
// and MongoDB? — the one real fork

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

Exercise 4.1 we do — full solution shown

Add a derived query: find tasks by done-status

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:

TaskRepository.java
public interface TaskRepository extends JpaRepository<Task, Long> {
    List<Task> findByDone(boolean done);   // Spring writes: WHERE done = ?
}
TaskService.java
public List<Task> getAll(Boolean done) {
    return (done == null) ? repository.findAll() : repository.findByDone(done);
}
TaskController.java
@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.

Exercise 4.2 you do

Make tasks survive a restart, and prove it

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 ./data folder appears in your project.
Hint 1
Only application.properties changes. Look back at the "survive restarts" pitfall in Section 5.
Show solution
application.properties
spring.datasource.url=jdbc:h2:file:./data/taskflow

That 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

Entity annotations

Repository & queries

Swapping databases

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/@Setter over @Data on entities (equals/hashCode + ids).
  • 💡Turn on show-sql=true while learning — watching the generated SQL is the fastest way to trust JPA.
// next

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 /tasks after 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
Add an id before asking JPA to persist the entity.
Hint 2
Keep the service API unchanged while replacing its repository implementation.
Hint 3
In-memory H2 disappears by design; choose file-backed H2 to test restart persistence.

Before opening the solution: create a task, inspect its row, and restart deliberately.

Reference patch — JPA persistence
Annotate the model as an entity, give it an id strategy, and let the repository express CRUD through the JPA interface.

Recall before continuing

  1. What makes a Java class an entity?
  2. Why does the service not need SQL code?
  3. What distinguishes in-memory H2 from file-backed H2?

The next TaskFlow workshop protects this newly persistent model from untrusted HTTP input.