Data & JPA
Your FriendController works — until you restart the app and every friend vanishes, because an ArrayList is not a database. This module gives SplitEase real persistence, and it does it the de-magicked way: you’ll watch every SQL statement Hibernate writes on your behalf, count them, and catch it doing something silly. The developer who reads the SQL log owns the system; the one who trusts the annotations gets owned by it.
The Goal
By the end of this module you can:
- Explain the object-relational gap and name what JPA, Hibernate, and Spring Data JPA each actually do
- Map a Java class to a table with
@Entityand read the exact DDL it generates - Use
JpaRepositoryand derived query methods — and say precisely where the implementation comes from - Model SplitEase relationships with
@ManyToOneand@ManyToMany, knowing the fetch-type defaults cold - Diagnose the two classic ORM failures —
LazyInitializationExceptionand N+1 — from the actual log output - Run SplitEase on a real database that survives restarts
The Lesson
The gap: objects have references, tables have foreign keys
In Java, an Expense holds its payer — a reference, an arrow straight to the Friend object on the heap. In SQL, an expense row holds payer_id, a number, and the database joins it to the friend table when asked. Same fact, two completely different shapes. Lists of sharers? Java: a Set<Friend> inside the object. SQL: a separate join table with two foreign key columns, because relational tables have no “list” column type.
Someone has to translate between these two worlds on every read and write. You could do it by hand with JDBC — write INSERT strings, copy result-set columns into fields, forever. ORMs (object-relational mappers) automate exactly that translation. In the Java world, three names get thrown around interchangeably, and interviewers love asking the difference:
| Name | What it is | What it actually does |
|---|---|---|
| JPA | A specification — interfaces and annotations, no working code | Defines the contract: @Entity, @Id, EntityManager. A rulebook |
| Hibernate | The implementation of that spec | The actual engine: generates SQL, tracks changes, manages the session |
| Spring Data JPA | A convenience layer on top of JPA | Writes the repository boilerplate for you: save, findAll, derived queries |
So: JPA is the job description, Hibernate is the employee, Spring Data JPA is the assistant who handles the paperwork. When your SQL log says Hibernate: select ... — that’s why. Hibernate is the one doing the work.
flowchart TD
A["Your code calls friendRepository.save"] --> B["Spring Data JPA - generated repository implementation"]
B --> C["JPA contract - EntityManager"]
C --> D["Hibernate - builds the SQL, manages the session"]
D --> E["JDBC driver - ships SQL to PostgreSQL or H2"]
@Entity de-magicked — watch the DDL
Here is the SplitEase Friend, promoted from plain class to entity:
import jakarta.persistence.*;
@Entity
public class Friend {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false, unique = true)
private String name;
protected Friend() {} // JPA requires a no-arg constructor —
// Hibernate creates instances via reflection
public Friend(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
}
Three annotations, each with a concrete meaning:
@Entity— “this class maps to a table.” Default table name: the class name, lower-cased.@Id— “this field is the primary key.”@GeneratedValue— “the database assigns the id, not me.” That’s whyidhas no setter and why a newFriendhasid = nulluntil it’s saved.
None of this is magic — it generates DDL you can read. Put these in application.properties:
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
Start the app and the log shows you exactly what @Entity bought:
Hibernate: create table friend (id bigint generated by default as identity,
name varchar(255) not null, primary key (id))
Hibernate: alter table friend add constraint UK_... unique (name)
Your annotations became a CREATE TABLE. Keep show-sql=true for this entire track — it’s the single most valuable debugging habit in JPA work, and most of this module is unteachable without it.
Repositories de-magicked — who writes the implementation?
public interface FriendRepository extends JpaRepository<Friend, Long> {
}
That’s an interface. No class implements it anywhere in your project. Yet friendRepository.save(friend) works. Where’s the code?
Spring writes it at startup. During boot, Spring Data scans for interfaces extending JpaRepository, and for each one generates a proxy object backed by a ready-made class (SimpleJpaRepository) that does the EntityManager calls. The bean injected into your controller is that generated proxy. Same de-magic pattern as the whole track: the machinery is plain Java written for you, not magic.
It goes further. Add a method name and Spring derives the query from it:
public interface FriendRepository extends JpaRepository<Friend, Long> {
Optional<Friend> findByName(String name);
// → select f from Friend f where f.name = ?
}
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
List<Expense> findByAmountInPaiseGreaterThan(long paise);
// → ... where e.amountInPaise > ?
List<Expense> findByPayerNameOrderByIdDesc(String name);
// → joins to payer, filters on payer.name, newest first
}
The method name is parsed into a query: findBy + property + condition keywords. It works because name, amountInPaise, and payer.name are real properties of your entities. Which means a typo is not a silent bug — it’s a startup failure:
Optional<Friend> findByNme(String name); // typo — no property 'nme'
org.springframework.data.mapping.PropertyReferenceException:
No property 'nme' found for type 'Friend'
The app refuses to boot. That’s the deal with derived queries: parsed once at startup, so naming mistakes die before any request arrives. Far better than discovering a broken SQL string in production at 9pm.
Relationships — Expense meets Friend
The Core Java capstone’s Expense held a payer reference and a sharers set (capstone refresher). Here’s the entity version:
@Entity
public class Expense {
@Id
@GeneratedValue
private Long id;
private String description;
private long amountInPaise; // paise as long — bigint column, exact math
@ManyToOne(optional = false) // many expenses → one payer
private Friend payer;
@ManyToMany // many expenses ↔ many sharers
private Set<Friend> sharers = new HashSet<>();
protected Expense() {}
public Expense(String description, long amountInPaise,
Friend payer, Set<Friend> sharers) {
this.description = description;
this.amountInPaise = amountInPaise;
this.payer = payer;
this.sharers = sharers;
}
// getters for all fields
}
Restart and read the DDL: expense gets a payer_id foreign key column (that’s @ManyToOne — a reference becomes a column), and a whole new table appears — expense_sharers (expense_id, sharers_id) — because that’s how SQL expresses a many-to-many: a join table, not a list (that’s the object-relational gap made visible). Note the money column: amount_in_paise bigint. Paise in a long, rupees only at print time — the rule from the capstone holds for the rest of your career. double for money is how balances drift by one paisa and audits fail.
The fetch-type trap
When you load an Expense, does Hibernate also load its payer and sharers? Depends on the fetch type, and the defaults are uneven:
| Relationship | Default fetch | Meaning |
|---|---|---|
@ManyToOne, @OneToOne | EAGER | Related object loaded immediately with the owner |
@OneToMany, @ManyToMany | LAZY | Collection NOT loaded — a placeholder proxy sits there instead |
LAZY means: expense.getSharers() returns a proxy that fetches from the database the moment you first touch it — if the Hibernate session is still open. Sessions live as long as the transaction. Touch the collection after the session closed and you get the most famous exception in the JPA world:
org.hibernate.LazyInitializationException: failed to lazily initialize
a collection of role: com.splitease.api.Expense.sharers:
could not initialize proxy - no Session
Read it like a stack trace: what — a lazy collection couldn’t load; which one — Expense.sharers; why — no Session, the database conversation that loaded the expense already ended. The fix is never “make everything EAGER” (that loads the world on every query). The fix is fetching what you need while the session is open — which the next section’s JOIN FETCH does.
One honest footnote: you’ve seen this warning in your logs since module 02 — spring.jpa.open-in-view is enabled by default. Boot keeps the session open for the whole web request, which hides lazy crashes inside controllers. It does not protect anything outside a request — startup runners, scheduled jobs, async tasks — and most experienced teams switch it off because of the next problem.
The N+1 problem — count the queries
Seed 5 expenses, then load them all and print each payer’s name. Watch the log:
Hibernate: select e1_0.id,e1_0.amount_in_paise,... from expense e1_0
Hibernate: select f1_0.id,f1_0.name from friend f1_0 where f1_0.id=?
Hibernate: select f1_0.id,f1_0.name from friend f1_0 where f1_0.id=?
Hibernate: select f1_0.id,f1_0.name from friend f1_0 where f1_0.id=?
One query for the expenses, then one more per payer. 5 expenses → up to 6 queries. 200 expenses → up to 201. That’s N+1: each lazy (or query-time) association touch fires its own SELECT. On your laptop with 5 rows it’s invisible. In production with real data it’s the page that takes 8 seconds and the database that falls over at month-end. Week 5 covers this same disease at the SQL level — this is what it looks like from the Java side.
The fix: tell Hibernate to fetch everything in one joined query:
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
@Query("select distinct e from Expense e join fetch e.payer join fetch e.sharers")
List<Expense> findAllWithEveryone();
}
@Query takes JPQL — SQL written against your entities (Expense e, e.payer) instead of tables. join fetch means “join AND load into the objects.” Rerun: one query, everything populated, no lazy proxies left to explode. The diagnostic habit matters more than the fix: when a JPA endpoint is slow, read the SQL log and count.
ddl-auto honesty
spring.jpa.hibernate.ddl-auto controls what Hibernate does to your schema at startup:
| Value | What it does | When |
|---|---|---|
create-drop | Creates schema, drops it on shutdown | Throwaway demos, some tests |
create | Drops and recreates at every startup — data gone | Almost never |
update | Adds missing tables/columns; never drops or renames anything | Learning, early prototypes — us, now |
validate | Touches nothing; fails startup if schema and entities disagree | Production |
none | Touches nothing, checks nothing | Production |
Use update for this track and know its limits: rename a field and Hibernate adds a new column, leaving the old one orphaned with all its data. It never deletes, never renames, never migrates data. Real teams version their schema with migration tools (Flyway/Liquibase) where every change is an explicit, reviewed SQL file — you’ve already met this world in migrations: basics to full. ddl-auto=update against a production database is a fireable-offence-grade mistake; say “validate plus Flyway” in an interview and mean it.
Check The Concept
How This Shows Up At Work
- The slow-page incident. A dashboard endpoint that was fine in dev takes 9 seconds in production. Whoever turns on SQL logging and counts 1+N queries finds it in ten minutes; whoever doesn’t blames the database team for a week. This is among the most common real-world Spring performance bugs, full stop.
- The 2am batch job. A scheduled job (no web request, so no open-in-view safety net) dies with
LazyInitializationExceptionon data that “worked fine” when tested through the browser. Knowing why the session boundary differs is the whole diagnosis. - The ddl-auto horror story. Someone leaves
ddl-autooncreatein a profile that reaches a shared environment, and a restart erases the schema. Every senior Java engineer has either seen this or cleaned it up.validate+ migrations is the grown-up answer. - The interview staple. “What’s the difference between JPA and Hibernate?” and “Explain the N+1 problem” appear in practically every Java backend interview in India. After this module you answer both with a log excerpt, not a definition.
- The code-review comment. “This calls
getPayer()inside a loop overfindAll()— that’s N+1, add a fetch join.” Being the person who writes that comment is a seniority signal.
Build This
Where you are: after module 03, splitease-api runs with PingController and a FriendController keeping friends in an in-memory ArrayList — restart and they’re gone. The starter already includes data-jpa, h2, and postgresql from module 02; until now Boot quietly gave you an in-memory H2 database you never used.
What gets added: real entities, real repositories, a database that survives restarts, and the in-memory list dies.
-
Create
Friend.javaandExpense.javaas shown in the Lesson (the@Entityversions, with getters — type them out, including theprotectedno-arg constructors). -
Create the repositories:
public interface FriendRepository extends JpaRepository<Friend, Long> {
Optional<Friend> findByName(String name);
}
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
@Query("select distinct e from Expense e join fetch e.payer join fetch e.sharers")
List<Expense> findAllWithEveryone();
}
- Point H2 at a file so data survives restarts (zero installs, this is the proof that Boot + JPA needs no setup), in
src/main/resources/application.properties:
spring.datasource.url=jdbc:h2:file:./data/splitease
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
- Rewrite
FriendController— theArrayListis replaced by the repository (constructor injection, as always):
@RestController
@RequestMapping("/api/v1/friends")
public class FriendController {
private final FriendRepository friendRepository;
public FriendController(FriendRepository friendRepository) {
this.friendRepository = friendRepository;
}
@PostMapping
public Friend create(@RequestBody Friend friend) {
return friendRepository.save(friend);
}
@GetMapping
public List<Friend> all() {
return friendRepository.findAll();
}
}
Yes — we’re accepting and returning the entity straight through the controller. It works, and it’s a vulnerability. Module 05 exists because of this line; for today, persistence is the lesson.
- Run
./mvnw spring-boot:runand read the startup log: thecreate tableDDL forfriend,expense, andexpense_sharers. Then prove persistence:
curl -X POST http://localhost:8080/api/v1/friends -H "Content-Type: application/json" -d '{"name":"Asha"}'
curl -X POST http://localhost:8080/api/v1/friends -H "Content-Type: application/json" -d '{"name":"Rohit"}'
curl http://localhost:8080/api/v1/friends
(PowerShell: use curl.exe and swap to -d "{\"name\":\"Asha\"}".) Expected:
[{"id":1,"name":"Asha"},{"id":2,"name":"Rohit"}]
Now stop the app, start it again, GET again. Same JSON. That moment — data outliving the process — is the whole point of this module.
- Seed expenses to make the SQL log interesting. Add a runner class:
@Component
public class SeedData implements CommandLineRunner {
private final FriendRepository friends;
private final ExpenseRepository expenses;
public SeedData(FriendRepository friends, ExpenseRepository expenses) {
this.friends = friends;
this.expenses = expenses;
}
@Override
public void run(String... args) {
if (expenses.count() > 0) return; // seed once
Friend asha = friends.findByName("Asha").orElseGet(() -> friends.save(new Friend("Asha")));
Friend rohit = friends.findByName("Rohit").orElseGet(() -> friends.save(new Friend("Rohit")));
expenses.save(new Expense("dinner", 300000, asha, Set.of(asha, rohit)));
expenses.save(new Expense("cab", 60000, rohit, Set.of(asha, rohit)));
expenses.save(new Expense("homestay",840000, asha, Set.of(asha, rohit)));
}
}
Restart. Then add this temporary line at the end of run and restart again — count the select lines in the log when you load all expenses and touch each payer:
expenses.findAll().forEach(e -> System.out.println(e.getPayer().getName()));
Now swap findAll() for findAllWithEveryone() and restart: one fat joined select. You just watched N+1 appear and die. Delete the temporary line.
-
PostgreSQL — two honest paths. H2 file mode is real persistence but nobody deploys on it. Pick one:
Path A — local PostgreSQL (recommended, ~20 min once): install from postgresql.org (remember the password you set for the
postgresuser), then create the database —psql -U postgres -c "CREATE DATABASE splitease;"— and swap the properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/splitease
spring.datasource.username=postgres
spring.datasource.password=<your-local-password>
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
Restart, re-POST your friends, GET them back. Same code, different database — that’s the JPA abstraction earning its keep. (The seed runner repopulates expenses automatically.)
Path B — stay on H2 file mode for now. Completely fine for this track; nothing in modules 05–08 requires Postgres specifically. You’ll be forced onto Postgres by Month 3 (Docker + deployment) anyway. Choose consciously, don’t drift.
- Break it on purpose #1 — the lazy crash. In
SeedData.run, add at the end:
Expense first = expenses.findAllWithEveryone().get(0); // works
Expense again = expenses.findAll().get(0);
System.out.println(again.getSharers().size()); // boom
Restart and read the failure: LazyInitializationException ... Expense.sharers: could not initialize proxy - no Session. The runner is outside any web request, so no open-in-view safety net — exactly like a production batch job. Fix: use the fetch-join method (the first line already proves it works), then delete both lines.
- Break it on purpose #2 — the unique constraint. POST
{"name":"Asha"}twice. Second response: a 500. The log showsDataIntegrityViolationExceptionwrapping a unique-constraint violation naming the index onfriend.name. The database just enforced a rule your Java code never checked — that’s defence in depth, and the ugly 500 is module 06’s problem to turn into a clean error response. Read the constraint name in the message so it’s familiar when you meet it at work.
Where to Practice
| Resource | What to do there | How long |
|---|---|---|
| spring.io/guides — “Accessing Data with JPA” | Run the official guide in a throwaway project; compare with what you built | 30 min |
| docs.spring.io — Spring Data JPA reference, “Defining Query Methods” | Skim the derived-query keyword table; try two new keywords on FriendRepository | 20 min |
| Baeldung — search “LazyInitializationException” (free article) | Read the causes and fixes; check each against what you saw in step 8 | 15 min |
| Baeldung — search “N+1 problem in Hibernate” (free article) | Read it after your own step 6 experiment, not before | 15 min |
Check Yourself
- JPA, Hibernate, Spring Data JPA — one sentence each on who does what.
- What does
@GeneratedValuemean for theidfield of a brand-new, unsavedFriend? - Nobody implements
FriendRepository. What exactly is the object that gets injected into your controller? - When does a typo in a derived query method name surface, and why is that timing a feature?
- What are the default fetch types for
@ManyToOneand@ManyToMany, and what is the proxy trick LAZY uses? - Read this from memory: what does
could not initialize proxy - no Sessiontell you, and what are two legitimate fixes? - An endpoint fires 1+N queries. How do you detect it, and what does
join fetchchange? - Why is
ddl-auto=updateacceptable for this track but banned in production, and what replaces it?
Answers
- JPA is the specification (annotations + interfaces, no engine). Hibernate implements it — generates and runs the SQL, manages sessions. Spring Data JPA sits on top and generates repository implementations and derived queries.
- The database will assign the id at insert time, so before saving,
idisnull. Aftersave, the returned entity carries the database-assigned id. - A proxy generated by Spring Data at startup, backed by
SimpleJpaRepository, which talks to theEntityManager. Plain machinery, written for you. - At application startup, when Spring Data parses the method name against entity properties —
PropertyReferenceException, app refuses to boot. A feature because the bug can never reach a live request. @ManyToOneis EAGER,@ManyToMany(and@OneToMany) are LAZY. LAZY puts a proxy placeholder in the field that runs a query on first access — which requires the session to still be open.- A lazy proxy was touched after the Hibernate session closed. Fixes: load the data while the session is open (fetch join /
@Query), or restructure so the access happens inside the transaction. “Make everything EAGER” is the wrong fix. - Detect by turning on
spring.jpa.show-sql=trueand counting theselectlines for one request.join fetchloads the association in the same single query as the owner, collapsing 1+N into 1. updateonly ever adds — it never renames, drops, or migrates, so schemas rot and surprises accumulate; on prod data that’s unacceptable risk. Production usesvalidate/noneplus explicit, versioned migrations (Flyway/Liquibase).
Explain it out loud: Explain to an empty chair what happens — in SQL — when SplitEase saves an expense paid by Asha and shared with Rohit: which tables get rows, where the foreign keys point, what is and isn’t loaded back when you later call findById, and what exact mistake makes getSharers() explode. If you can’t name the join table, reread the relationships section.
Still Unclear?
I have a Spring Boot app with spring.jpa.show-sql=true. Here is the log from
one request: [paste it]. Walk me through what each query is, which line of
my code likely triggered it, and whether this is an N+1 pattern. Then give
me a different fictional log and quiz me on diagnosing it myself.
Explain the Hibernate session/persistence context lifecycle in a Spring Boot
app: when it opens, when it closes, and exactly why a lazy collection works
inside a @Transactional method but throws LazyInitializationException in a
CommandLineRunner. Use a timeline, not code.
Argue both sides: ddl-auto=update versus Flyway migrations for a two-person
startup shipping weekly. Then tell me at what team size or data value the
argument stops being close. Challenge me if my answers are vague.
Why AI Can’t Do This For You
AI writes flawless @Entity classes and repositories on demand — that part was never the skill. The skill is reading your SQL log at 11pm and seeing that the expense list fires 201 queries, or recognizing no Session and knowing the boundary that closed. AI can’t see your log, your data volume, or the scheduled job that has no web request wrapped around it.
And the day someone proposes “just set everything EAGER” or “ddl-auto=update is fine for prod,” the person who has watched the query count with their own eyes pushes back with evidence. That judgment — fetched from experience, not generated — is what this module bought you.
Module done? Add it to today’s tracker