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.
Assumes you know
After this you should be able to
- Map an entity
- Use a repository for CRUD
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.
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.
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.
- Start with identity and one scalar field per entity.
- Add the owning relationship and inspect generated SQL.
- 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#
- Which side usually owns a many-to-one foreign key?
- What separates an entity from a DTO?
- Why load before an update?
- What creates an N+1 query?
- 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
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.
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#
| Type | Annotation | Example |
|---|---|---|
| 1:1 | @OneToOne | Person ↔ Passport |
| 1:N | @OneToMany / @ManyToOne | Customer → 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#
| Op | Method | Returns |
|---|---|---|
| C | save(e) | saved entity |
| R | findById(id) | Optional<E> |
| R | findAll() | List<E> |
| U | save(existing) | updated entity |
| D | deleteById(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
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.
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.
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
Employeeclass, get anemployeetable. - 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.
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.
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.
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=updateThe 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.
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:
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.
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#
| Annotation | Applied to | Meaning |
|---|---|---|
@Entity | class | This class maps to a table. |
@Table(name=…) | class | Names the table (defaults to the class name). |
@Id | field | Marks the primary key. |
@GeneratedValue | field | Auto-generates the key; defaults to AUTO. |
@Column(name=…) | field | Customizes the column mapping / name. |
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.
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:
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.
The relationship annotations#
| Annotation | Side it goes on |
|---|---|
@OneToOne | either entity |
@OneToMany | the "one" side (e.g. Customer holds many Orders) |
@ManyToOne | the "many" side (e.g. Order belongs to one Customer) |
@ManyToMany | both 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. [+]
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.
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:
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.
the request path through a Spring Boot app
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.
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.
| Letter | Method | What it does |
|---|---|---|
| C | save(entity) | Inserts a new row, returns the saved entity. |
| R | findById(id) | Fetches one row as Optional<E>. |
| R | findAll() | Fetches every row as a List<E>. |
| U | save(existing) | Updates a row that already has an id. |
| D | deleteById(id) | Removes the row with that id. |
// 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 (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);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.
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#
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#
- One stack, distinct roles: Spring Data JPA (convenience) → JPA (spec) → Hibernate (provider) → JDBC (SQL) → MySQL.
- Entities are annotated plain classes.
@Entity+@Idis 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.
savecovers both create and update;findByIdreturns anOptional; the request flows Controller → Service → Repository → DB.
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#
Write a JPA Order entity for BuyBuddy and a repository for it.
Requirements:
- Map it to a table named
orders(note:orderis a SQL keyword). - Auto-generated
Longprimary key. - A
Many-to-Onelink toCustomer(an order belongs to one customer). - A
Many-to-Manylink toProduct. - 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.
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;
}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#
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.
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.
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.