Career OS

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 @Entity and read the exact DDL it generates
  • Use JpaRepository and derived query methods — and say precisely where the implementation comes from
  • Model SplitEase relationships with @ManyToOne and @ManyToMany, knowing the fetch-type defaults cold
  • Diagnose the two classic ORM failures — LazyInitializationException and 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:

NameWhat it isWhat it actually does
JPAA specification — interfaces and annotations, no working codeDefines the contract: @Entity, @Id, EntityManager. A rulebook
HibernateThe implementation of that specThe actual engine: generates SQL, tracks changes, manages the session
Spring Data JPAA convenience layer on top of JPAWrites 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 why id has no setter and why a new Friend has id = null until 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:

RelationshipDefault fetchMeaning
@ManyToOne, @OneToOneEAGERRelated object loaded immediately with the owner
@OneToMany, @ManyToManyLAZYCollection 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 itif 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 oneExpense.sharers; whyno 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:

ValueWhat it doesWhen
create-dropCreates schema, drops it on shutdownThrowaway demos, some tests
createDrops and recreates at every startup — data goneAlmost never
updateAdds missing tables/columns; never drops or renames anythingLearning, early prototypes — us, now
validateTouches nothing; fails startup if schema and entities disagreeProduction
noneTouches nothing, checks nothingProduction

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 LazyInitializationException on 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-auto on create in 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 over findAll() — 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.

  1. Create Friend.java and Expense.java as shown in the Lesson (the @Entity versions, with getters — type them out, including the protected no-arg constructors).

  2. 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();
}
  1. 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
  1. Rewrite FriendController — the ArrayList is 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.

  1. Run ./mvnw spring-boot:run and read the startup log: the create table DDL for friend, expense, and expense_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.

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

  1. 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 postgres user), 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.

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

  1. Break it on purpose #2 — the unique constraint. POST {"name":"Asha"} twice. Second response: a 500. The log shows DataIntegrityViolationException wrapping a unique-constraint violation naming the index on friend.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

ResourceWhat to do thereHow long
spring.io/guides — “Accessing Data with JPA”Run the official guide in a throwaway project; compare with what you built30 min
docs.spring.io — Spring Data JPA reference, “Defining Query Methods”Skim the derived-query keyword table; try two new keywords on FriendRepository20 min
Baeldung — search “LazyInitializationException” (free article)Read the causes and fixes; check each against what you saw in step 815 min
Baeldung — search “N+1 problem in Hibernate” (free article)Read it after your own step 6 experiment, not before15 min

Check Yourself

  1. JPA, Hibernate, Spring Data JPA — one sentence each on who does what.
  2. What does @GeneratedValue mean for the id field of a brand-new, unsaved Friend?
  3. Nobody implements FriendRepository. What exactly is the object that gets injected into your controller?
  4. When does a typo in a derived query method name surface, and why is that timing a feature?
  5. What are the default fetch types for @ManyToOne and @ManyToMany, and what is the proxy trick LAZY uses?
  6. Read this from memory: what does could not initialize proxy - no Session tell you, and what are two legitimate fixes?
  7. An endpoint fires 1+N queries. How do you detect it, and what does join fetch change?
  8. Why is ddl-auto=update acceptable for this track but banned in production, and what replaces it?
Answers
  1. 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.
  2. The database will assign the id at insert time, so before saving, id is null. After save, the returned entity carries the database-assigned id.
  3. A proxy generated by Spring Data at startup, backed by SimpleJpaRepository, which talks to the EntityManager. Plain machinery, written for you.
  4. 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.
  5. @ManyToOne is 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.
  6. 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.
  7. Detect by turning on spring.jpa.show-sql=true and counting the select lines for one request. join fetch loads the association in the same single query as the owner, collapsing 1+N into 1.
  8. update only ever adds — it never renames, drops, or migrates, so schemas rot and surprises accumulate; on prod data that’s unacceptable risk. Production uses validate/none plus 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

Saves your progress on this device.