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.
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.
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.
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.
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.
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.
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.
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.
Copy this into a notebook. Everything here is unpacked in the Detailed Notes below.
| Type | Annotation | Example |
|---|---|---|
| 1:1 | @OneToOne | Person ↔ Passport |
| 1:N | @OneToMany / @ManyToOne | Customer → Orders |
| M:N | @ManyToMany (+ join table) | Order ↔ Product |
| 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 |
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.
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.
Employee class, get an employee table.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.
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=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.
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.
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.
| 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.
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:
One row here ↔ exactly one row there. Person ↔ Passport.
One row relates to many. Customer → Orders.
Many ↔ many, needs a junction table. Students ↔ Courses.
Mapping BuyBuddy's requirements onto these shapes:
| 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.
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.
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.
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.
@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.
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.
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.
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.
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(...).
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.
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.
@Entity + @Id is the minimum;
other annotations only override defaults.JpaRepository<E, Id> and Spring
generates the code, including derived query methods from method names.save covers both create and update; findById
returns an Optional; the request flows Controller → Service → Repository → DB.You can recognise these annotations now. These drills prove you can write them. Attempt each before opening the walkthrough.
Write a JPA Order entity for BuyBuddy and a repository for it.
Requirements:
orders (note: order is a SQL keyword).Long primary key.Many-to-One link to Customer (an order belongs to one customer).Many-to-Many link to Product.Expected result: two files that compile with no method bodies, exposing
save/findById/findAll/deleteById for free plus your
custom finder.
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.
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.
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.