Study Notes
Spring Boot Frameworkcore-concepts

Spring Data JPA — From Object to Table

The persistence stack from controller to database, wiring MySQL to Spring Boot, turning a class into a table with @Entity, mapping relationships, and getting free CRUD from JpaRepository. Includes interview Q&A.

45 min read·verified Spring Boot 4.x·reviewed 2026-08-28

After this you should be able to

  • Map an entity
  • Use a repository for CRUD
@EntityJpaRepositoryRelationships
Java Backend · Spring Data JPA

How a Spring Boot application talks to a relational database without you writing the SQL, the JDBC, or the mapping by hand, using the BuyBuddy e-commerce backend as the running example.

Session 4 · JAVA-301 · Updated August 2026 · Read ~18 min

Choose your route#

Problem: an object model and relational tables disagree in useful, expensive ways. Budget 45 minutes to map a small model or 15 minutes to explain the trade-offs.

Learning goal

Recall first: sketch an object with an identifier and its table equivalent. Layered request flow is helpful; it is not required to open this page.

  • Map an entity.
  • Use a repository for CRUD.
  • Choose a relationship mapping deliberately.

Shared core: map meaning, not just fields#

JPA maps entity state to tables; Spring Data generates repository operations over that model. The annotations describe identity, columns, and relationships, while transactions define a coherent persistence boundary.

Closest confusion: an entity is a managed object with persistence identity; a DTO is a transport shape. They may look alike but should not inherit each other’s responsibilities.

Failure trace: creating a fresh object for an update drops the managed row’s identity and may insert or overwrite unexpectedly. Load the row, change intended fields, then save inside the right boundary.

Decision rule: model relationships from ownership and access patterns, not from the hope that every Java association should be eager and bidirectional.

Version note: annotations and examples were reviewed for Spring Boot 4.x on 2026-08-28; verify provider and database-specific defaults during upgrades.

Learn deeply: persist an order and its customer#

Mission (30 minutes): add Customer and PurchaseOrder entities with a many-to-one relationship, then expose one repository-backed create/read flow. Likely files: entities, repository, service, and H2 configuration.

Acceptance checks: schema starts cleanly; saving an order stores its customer key; reading the order returns the intended association without an accidental JSON loop.

  1. Start with identity and one scalar field per entity.
  2. Add the owning relationship and inspect generated SQL.
  3. Keep the controller response separate from the entity if the graph grows.
Solution shape, not a script

Let the many side hold the foreign key. Use a small response DTO once the endpoint needs a stable API contract.

Transfer: add a query for orders by customer and explain why query method names are not a substitute for understanding the SQL they imply.

Prepare for interviews: state the persistence trade-offs#

Two-minute summary: JPA manages entity state and relationships; Spring Data removes repetitive repository plumbing but does not remove SQL, transactions, or loading decisions.

Explain in 60 seconds: why should API DTOs usually differ from entities?

Primary question: explain @OneToMany and @ManyToOne ownership. Follow-ups: what causes N+1 queries, and what does lazy loading change?

Design exercise: an endpoint serializes a customer with thousands of orders and times out. Choose a projection, pagination, or fetch strategy and defend the trade-off.

Strong-answer outline

Start with relational ownership, name the foreign key side, separate persistence from API shapes, and admit that fetch strategy follows a query, not a universal annotation preference.

Timed mock: design a safe order-update flow in 90 seconds.

Exit check#

  1. Which side usually owns a many-to-one foreign key?
  2. What separates an entity from a DTO?
  3. Why load before an update?
  4. What creates an N+1 query?
  5. When does a repository method need a transaction boundary?

Learn next: Use map an entity as the base, then persist data with jpa in Real data with JPA, H2 & Lombok. Interview next: Use map an entity as the base, then distinguish test doubles in Test Doubles & Mockito.

Continue this path · Switch paths from this topic · Review first: layered flow

// the big idea

Spring Data JPA lets you treat a database like a collection of Java objects. You annotate a plain class, declare an empty repository interface, and the framework generates the SQL, the mapping, and the boilerplate for you, so you write intent (save, findById) instead of plumbing.

§ 1 — Notebook Summary

Notebook Summary#

Copy this into a notebook. Everything here is unpacked in the Detailed Notes below.

The layer stack (top → bottom)#

  • Spring Data JPA → your code. Reduces boilerplate in Data Access Layer
  • JPA → the spec (contract / interface). No behaviour on its own
  • Hibernate → the JPA provider (implements the spec). Does the ORM
  • ORM → translates Java objects ↔ relational tables
  • JDBC → sends the actual SQL to the DB
  • Database (e.g. MySQL) → stores the rows
  • ✎ doodle: 6 stacked boxes, one arrow down through all → "MySQL"

Mental model#

  • JPA = interface · Hibernate = class that implements it
  • ⭐ Spring Data JPA ≠ JPA ≠ Hibernate → 3 different things, not synonyms
  • aid: "Spec, Provider, Sugar" = JPA / Hibernate / Spring Data JPA

Entity = class ↔ table#

  • @Entity → this class is a table
  • @Table(name="…") → pick the table name
  • @Id → primary key · @GeneratedValue → auto-generate it (default AUTO)
  • @Column(name="…") → rename / customize a column
  • Field with no annotation → still a column (name = field name)
  • ⚠ annotations live in javax.persistence.* (older) / jakarta.persistence.* (newer)

Relationships#

TypeAnnotationExample
1:1@OneToOnePerson ↔ Passport
1:N@OneToMany / @ManyToOneCustomer → Orders
M:N@ManyToMany (+ join table)Order ↔ Product
  • BuyBuddy: Customer→Order = 1:N · Order↔Product = M:N
  • @JoinColumn → names the foreign-key column
  • ✎ doodle: two boxes with a small diamond box between them = M:N junction

Repository#

  • Interface that extends JpaRepository<Entity, IdType>
  • ⭐ No implementation written — Spring generates it at runtime
  • Free methods: save · findById · findAll · deleteById
  • Custom: findByEmail(String) → parsed from the method name

CRUD map#

OpMethodReturns
Csave(e)saved entity
RfindById(id)Optional<E>
RfindAll()List<E>
Usave(existing)updated entity
DdeleteById(id)void
  • ⭐ Update = load → mutate → save again (same method as create)
  • ⚠ findById returns Optional, not the entity → .get() only if you're sure it exists
  • ✎ doodle: control flow arrow chain — Controller→Service→Repository→DB

Setup essentials#

  • ⚠ Create the DB by hand first — Hibernate won't create the database, only the tables:
    CREATE DATABASE buybuddy_mysql_db;
  • spring.jpa.hibernate.ddl-auto=update → auto-build/alter tables
  • Set datasource url / username / password in application.properties
§ 2 — Detailed Study Notes

Detailed Study Notes#

You've been handed the backend for BuyBuddy, a small e-commerce app. It has to remember customers, products, and orders long after the server restarts, so all of it lives in a MySQL database. The instinct is to open an editor and start writing SQL: INSERT INTO customer…, then the code to open a connection, run the statement, read the result set row by row, close everything, map each column back into a Java field, and repeat that for every table and every operation. Do this for Customer, Product, and Order, times create/read/update/delete, and most of your codebase becomes plumbing that has nothing to do with e-commerce. Spring Data JPA exists to delete that plumbing.

💡 tip · start with clarity

The deck opens on a deliberate detour: before any code, it pins down the requirements (a customer places many orders; an order holds many products; a product appears in many orders). The quoted line is "the sooner you start to code, the longer the program will take." In an interview or a real project, restating the data relationships before touching a keyboard is the move that earns trust.

2.1 · The stack, who does what#

The database access ecosystem is a stack of layers, each one thinner and more convenient than the one below it. The confusion this lesson exists to clear up is that "Spring Data JPA," "JPA," and "Hibernate" get used as if they mean the same thing. They don't; they sit at different heights in the same tower.

Analogy · restaurant order

Think of ordering food. JPA is the printed menu, a promise of what can be ordered but it cooks nothing. Hibernate is the chef who actually reads the menu and makes the dish. Spring Data JPA is the waiter who takes your one-line request ("the usual, saved") and handles the rest. JDBC is the kitchen doorway every plate physically passes through.

  • Spring Data Framework (the Spring Data family) — the parent project. It's an umbrella of sub-frameworks, each implementing a Data Access Layer for one kind of database.
  • Spring Data JPA — the family member for relational databases. It gives you high-level repository interfaces and removes the boilerplate.
  • JPA (Java Persistence API) — a specification: a contract describing how Java objects should map to database rows. It defines behaviour but implements none of it. Think of it as an interface.
  • ORM (Object-Relational Mapping) — the general technique of translating between objects and relational tables. Write an Employee class, get an employee table.
  • Hibernate — the most popular ORM framework, and here the JPA provider: the concrete class that implements the JPA interface and does the real mapping.
  • JDBC — the low-level Java API that sends SQL to the database and reads results. Hibernate uses JDBC under the hood.
Spring Data JPA · removes boilerplate JPA · the spec (interface / contract) Hibernate · JPA provider, does the ORM ORM · objects ↔ tables JDBC · sends SQL, reads results Database · MySQL your call travels down
fig 2.1 — one request falls through six layers to reach MySQL
⭐ trick · interface vs class

The one sentence that untangles all of it: JPA is the interface, Hibernate is a class that implements it. You could swap Hibernate for another provider (EclipseLink, OpenJPA) and your JPA-annotated code wouldn't change, exactly like swapping one implementation of a Java interface for another.

⚠ pitfall · treating them as synonyms

A very common interview stumble is calling Hibernate "the same as JPA," or thinking Spring Data JPA replaces Hibernate. It doesn't: Spring Data JPA sits on top of a JPA provider and still relies on Hibernate to do the actual object-to-table work.

2.2 · Wiring MySQL to Spring Boot#

Before any entities exist, the application needs to know which database to talk to and how to log in. That configuration lives in application.properties. Spring Boot reads it at startup and hands the connection details to Hibernate.

src/main/resources/application.properties
spring.application.name=buy-buddy-backend

spring.datasource.url=jdbc:mysql://localhost:3306/buybuddy_mysql_db
spring.datasource.username=root
spring.datasource.password=rootpassword

# How Hibernate handles DDL (table creation / alteration)
spring.jpa.hibernate.ddl-auto=update

The ddl-auto=update line is the one that feels magical: on startup, Hibernate inspects your entity classes and creates or alters the matching tables to fit. You get schema management for free, driven by your Java code.

⚠ pitfall · Hibernate won't make the database

Hibernate can create and alter tables, but it is not granted permission to create the database itself. You must do that once by hand before the first run, or startup fails with an "unknown database" error:

terminal — one-time setup
mysql -u root -p
CREATE DATABASE buybuddy_mysql_db;

Then run the app with ./gradlew bootrun. The distinction to hold onto: you create the empty database; Hibernate fills it with tables.

2.3 · Entities, turning a class into a table#

An entity is an ordinary Java class that you've marked so JPA will store its objects as rows in a table. You describe the mapping with annotations, and the ORM does the translation both ways: your object becomes an INSERT, and a row you read back becomes an object.

Analogy · a labelled shipping box

An entity class is a box with a printed label. @Entity says "this box gets shipped," @Table writes the destination on it, @Id is the tracking number, and each @Column is a line on the packing slip. Hibernate is the warehouse worker who reads the labels and files the box in the right shelf.

The core annotations#

AnnotationApplied toMeaning
@EntityclassThis class maps to a table.
@Table(name=…)classNames the table (defaults to the class name).
@IdfieldMarks the primary key.
@GeneratedValuefieldAuto-generates the key; defaults to AUTO.
@Column(name=…)fieldCustomizes the column mapping / name.
Customer.java
package com.example.buy_buddy_backend;

import javax.persistence.*;

@Entity
@Table(name = "customer")
public class Customer {

    @Id
    @GeneratedValue
    private Long id;

    @Column(name = "customer_name")
    private String name;

    private String email;   // no annotation -> column "email"
}

Notice email has no annotation and still becomes a column named email: annotations are for overriding defaults, not for switching mapping on. The one you almost always want explicitly is @Id, because every table needs a primary key.

💡 tip · javax vs jakarta

The deck imports from javax.persistence. Since the Jakarta EE rename, newer Spring Boot 3.x projects use jakarta.persistence instead. Same annotations, different package. [+] If your @Entity "isn't recognised," a mismatched import package is the usual culprit.

2.4 · Mapping relationships#

A relationship is a connection between two tables that says how their rows relate. Relationships are how you organize data without repeating it: instead of copying a customer's details onto every order, an order just points back to one customer.

There are three shapes, and picking the right one is the whole game:

@OneToOne

1 : 1#

One row here ↔ exactly one row there. Person ↔ Passport.

@OneToMany / @ManyToOne

1 : N#

One row relates to many. Customer → Orders.

@ManyToMany

M : N#

Many ↔ many, needs a junction table. Students ↔ Courses.

Mapping BuyBuddy's requirements onto these shapes:

  • "A customer can place many orders" → Customer to Order is One-to-Many (and Order to Customer is Many-to-One, the mirror image).
  • "An order contains many products and a product appears in many orders" → Order to Product is Many-to-Many, which needs a junction table linking the two.
Customer id · name · email Order id · customerId order_product orderId · productId Product id · name · price 1 : N M : N
fig 2.4 — Customer→Order is 1:N; Order↔Product is M:N through a join table

The relationship annotations#

AnnotationSide it goes on
@OneToOneeither entity
@OneToManythe "one" side (e.g. Customer holds many Orders)
@ManyToOnethe "many" side (e.g. Order belongs to one Customer)
@ManyToManyboth entities of an M:N pair

Two more you'll see in the entity code: @JoinColumn names the foreign-key column in a relationship, and on the many-to-many, a join annotation names the columns pointing into the junction table. The deck also notes Lombok annotations (like @Getter/@Setter) generate the getters and setters so the entity class stays short. [+]

⭐ trick · read the two sentences, not the diagram

You never have to guess the relationship type. Read the requirement as two sentences and check both directions: "one customer → many orders" and "one order → one customer" gives 1:N. "one order → many products" and "one product → many orders" — many on both sides — gives M:N, which is the only case that needs a junction table.

2.5 · Repositories, database access with no code#

A repository is an interface that gives you CRUD operations for one entity, and Spring writes the implementation for you. This is the payoff of the whole framework: you declare what you want, never how.

Analogy · a vending machine you design by its buttons

You describe the buttons you want on the front of the machine (save, findByEmail). You never build the mechanism behind the glass. Spring Data JPA fabricates the whole machine at runtime from your button labels, matching each button to a real database operation.

You get a repository by writing an interface that extends JpaRepository, parameterized with the entity type and its ID type:

CustomerRepository.java
import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, Long> {

    // Custom query, no body needed.
    // Spring parses the method name into a query.
    Customer findByEmail(String email);
}

That's the entire file. No class, no SQL, no connection handling. By extending JpaRepository<Customer, Long> you inherit save, findById, findAll, delete, and more. The <Customer, Long> pair tells Spring which entity and which primary-key type it's working with.

Controller→ Service→ Repository→ Database

the request path through a Spring Boot app

⭐ trick · derived query methods

The method name is the query. findByEmail(String email) becomes SELECT * FROM customer WHERE email = ? because Spring parses the name: findBy + a field name. You can chain them, findByNameAndEmail(...), and never write SQL for the common cases.

💡 tip · why interfaces for services too

The deck's code demo puts a contract (interface) in front of each service class, e.g. CustomerService with a CustomerServiceImpl. The point is the same as the repository: callers depend on the interface, so you can swap the implementation without touching them. It's the standard "program to an interface" best practice.

2.6 · CRUD operations in practice#

CRUD is the four things you ever do to stored data: Create, Read, Update, Delete. Spring Data JPA ships ready-made methods for all four, so a whole service method is often one line.

LetterMethodWhat it does
Csave(entity)Inserts a new row, returns the saved entity.
RfindById(id)Fetches one row as Optional<E>.
RfindAll()Fetches every row as a List<E>.
Usave(existing)Updates a row that already has an id.
DdeleteById(id)Removes the row with that id.
Create & Read
// Create (C)
User user = new User("John", "john.doe@example.com");
userRepository.save(user);

// Read (R)
Optional<User> found = userRepository.findById(1L);
List<User> allUsers = userRepository.findAll();
Update & Delete
// Update (U): load, mutate, save again
User user = userRepository.findById(1L).get();
user.setEmail("new.email@example.com");
userRepository.save(user);

// Delete (D)
userRepository.deleteById(1L);
⭐ trick · save() does both create and update

There is no separate update(). save() decides: if the entity has no id (or an id not yet in the table) it inserts; if it already carries a known id it updates that row. So the update recipe is always load → change fields → save again.

⚠ pitfall · findById returns an Optional

findById hands back an Optional<User>, not a User, because the row might not exist. Calling .get() on an empty Optional throws NoSuchElementException. The safe habit is .orElseThrow(...) or .orElse(null); reach for .get() only when you've truly guaranteed the row is there.

Tying it together, the deck's Postman demo posts a customer, two products, then an order referencing them by id ({"customerId": 1, "productIds": [1, 2]}), and the request travels Controller → Service → Repository → Database. That single POST exercises the M:N mapping, the repository layer, and a save in one shot.

§ 2.7 — Interview & Practice Q&A

2.7 · Interview & Practice Q&A#

Are JPA, Hibernate, and Spring Data JPA the same thing?

No. JPA is a specification, an interface defining how ORM should work. Hibernate is a JPA provider, a concrete implementation of that spec that does the actual object-to-table mapping. Spring Data JPA sits on top of a JPA provider and removes repository boilerplate. They are three layers, not synonyms.

What does @GeneratedValue do if you don't pass a strategy?

It defaults to AUTO, letting the JPA provider pick a generation strategy appropriate to the database. It marks the primary key as auto-generated so you don't set the id yourself when creating a new entity.

How is BuyBuddy's Order-to-Product relationship modelled, and why?

As Many-to-Many, because an order can contain many products and a product can appear in many orders, many on both sides. That requires a junction (join) table holding pairs of orderId and productId, which is what @ManyToMany generates.

You extend JpaRepository but write no implementation. Who does?

Spring Data JPA generates the implementation at runtime. From the interface's type parameters and method names it produces a proxy backed by the JPA provider, so declaring the interface is enough to get working CRUD and derived query methods.

How do you update an existing record?

Load it (usually with findById), change the fields on the returned object, then call save on it again. Because the entity already carries a known id, save performs an update rather than an insert. There is no separate update method.

Why does findById return an Optional?

Because a row with that id may not exist. Optional forces the caller to handle the empty case explicitly instead of silently returning null. Blindly calling .get() on an empty Optional throws NoSuchElementException, so prefer .orElseThrow(...).

What is spring.jpa.hibernate.ddl-auto=update doing?

It tells Hibernate how to handle Data Definition Language on startup. With update, Hibernate inspects your entities and creates or alters tables to match them, without dropping existing data. It manages tables only; you still create the database itself by hand first.

Where does JDBC fit in this stack?

At the bottom. Hibernate uses JDBC to send the generated SQL to the database and read results back. Spring Data JPA and JPA are higher-level abstractions; JDBC is the layer that actually communicates with the database engine.

§ 2.8 — Key Takeaways

2.8 · Key Takeaways#

  • One stack, distinct roles: Spring Data JPA (convenience) → JPA (spec) → Hibernate (provider) → JDBC (SQL) → MySQL.
  • Entities are annotated plain classes. @Entity + @Id is the minimum; other annotations only override defaults.
  • Relationships come from the requirements. Many on both sides = M:N + junction table; one-to-many otherwise.
  • Repositories are empty interfaces. Extend JpaRepository<E, Id> and Spring generates the code, including derived query methods from method names.
  • CRUD is one-liners. save covers both create and update; findById returns an Optional; the request flows Controller → Service → Repository → DB.
spec vs provider @Entity = table @Id + @GeneratedValue 1:N vs M:N junction table extends JpaRepository derived queries save = create+update findById → Optional ddl-auto=update
§ 3 — Microexercises

Microexercises#

You can recognise these annotations now. These drills prove you can write them. Attempt each before opening the walkthrough.

3.1 · Build the Order entity and its repository#

// your task

Write a JPA Order entity for BuyBuddy and a repository for it.

Requirements:

  • Map it to a table named orders (note: order is a SQL keyword).
  • Auto-generated Long primary key.
  • A Many-to-One link to Customer (an order belongs to one customer).
  • A Many-to-Many link to Product.
  • A repository interface giving CRUD plus a method to find all orders for a given customer id.

Expected result: two files that compile with no method bodies, exposing save/findById/findAll/deleteById for free plus your custom finder.

Show the full walkthrough

Step 1 — mark the class as an entity and fix the table name. order is reserved in SQL, so map to orders with @Table. Add the auto-generated key.

Step 2 — model the two relationships from the requirement sentences. "An order belongs to one customer" is the many side of Customer→Orders, so @ManyToOne with a @JoinColumn naming the foreign key. "An order has many products, a product is in many orders" is @ManyToMany.

Step 3 — declare the repository. Extend JpaRepository<Order, Long>. The custom finder walks a relationship, so name it after the path: findByCustomerId.

Order.java
package com.example.buy_buddy_backend.model;

import javax.persistence.*;
import java.util.List;

@Entity
@Table(name = "orders")   // "order" is a reserved SQL word
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @ManyToMany
    @JoinTable(name = "order_product", joinColumns = @JoinColumn(name = "order_id"), inverseJoinColumns = @JoinColumn(name = "product_id"))
    private List<Product> products;
}
OrderRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {

    // Derived query: SELECT * FROM orders WHERE customer_id = ?
    List<Order> findByCustomerId(Long customerId);
}

Why: the two relationship annotations are a direct translation of the two requirement sentences, and the empty repository proves the framework's core promise, you declared the buttons and Spring built the machine.

3.2 · Diagnose the broken update#

// your task

This service method is supposed to change a customer's email. It compiles, but it can crash at runtime and it can also silently create a duplicate. Predict the exact failure, name the root cause, and fix it.

CustomerServiceImpl.java — buggy
public Customer updateEmail(Long id, String newEmail) {
    Customer c = customerRepository.findById(id).get();
    Customer updated = new Customer();
    updated.setEmail(newEmail);
    return customerRepository.save(updated);
}

Expected answer: which exception fires when the id is missing, and why even a present id produces the wrong row.

Show the full walkthrough

Root cause — two bugs. First, .get() is called on the Optional from findById with no empty check. Second, and worse, the method builds a brand-new Customer and saves that instead of the loaded one.

Predicted failure 1 (missing id): if no customer has that id, findById(id) returns an empty Optional and .get() throws NoSuchElementException.

Predicted failure 2 (id exists): even on the happy path, updated has no id, so save treats it as new and inserts a second customer with only an email set, name and everything else null. The original row is never touched. You wanted an update; you got an accidental create.

Step 1 — handle the empty Optional with orElseThrow instead of .get().

Step 2 — mutate the loaded entity so its existing id makes save perform an update.

CustomerServiceImpl.java — fixed
public Customer updateEmail(Long id, String newEmail) {
    Customer c = customerRepository.findById(id).orElseThrow(() -> new RuntimeException("Customer not found: " + id));
    c.setEmail(newEmail);          // mutate the loaded entity
    return customerRepository.save(c);  // has an id -> UPDATE
}

Why: save decides insert-vs-update purely from whether the entity carries a known id, so the update recipe is always load the real row → change it → save it. Building a fresh object throws that id away.

@Table for reserved words @ManyToOne = many side @JoinTable for M:N findByCustomerId orElseThrow not .get() mutate, don't re-new save needs the id to update

Built from the JAVA-301 Session 4 deck (44 slides). No separate voice transcript was provided, so scope follows the full deck. Items marked [+] are added context beyond the slides; none change the taught material.