1. JPA & Hibernate Basics
An ORM (Object-Relational Mapper) translates between two worlds
that don't naturally agree: Java objects, with fields, references, and inheritance;
and relational tables, with rows, columns, and foreign keys. Instead of hand-writing
SELECT and INSERT statements and manually copying result
set columns into object fields, you describe the mapping once with annotations, and
the ORM generates the SQL for you.
JPA (Jakarta Persistence API) is the specification — a set of
interfaces and annotations like @Entity and EntityManager
that define what an ORM for Java must support. Hibernate is the
implementation: the actual library that reads those annotations, generates SQL,
talks to the JDBC driver, and executes queries against the database. Spring Boot
uses Hibernate as its default JPA provider — when you write JPA annotations in this
course, Hibernate is what's doing the work underneath.
The persistence context
At the center of how JPA works is the persistence context — a
first-level cache of managed entities tied to the current
EntityManager (Spring wires one per transaction for you). When you
load an entity by ID, JPA keeps a reference to it in the persistence context. Load
the same row again within the same transaction and JPA returns the identical
in-memory object without hitting the database a second time. Change a field on a
managed entity, and JPA detects that change and writes it back at the end of the
transaction — a mechanism called dirty checking — without you ever
calling an explicit "update" method.
<dependencies>
<!-- Spring Data JPA + Hibernate, pulled in as one starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL JDBC driver, used at runtime only -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
spring-boot-starter-data-jpa is itself a bundle: Hibernate, the
Spring Data JPA repository abstraction you'll use in Section 3, the JPA API
annotations, and Spring's transaction management, all pinned to compatible versions
by spring-boot-starter-parent.
2. Mapping Entities
An entity is a plain Java class annotated @Entity that
Hibernate maps to a database table — one instance per row. Take the Task
class from Week 3's in-memory exercise and turn it into a real entity:
package com.codeverse.week04;
import jakarta.persistence.*;
import java.time.Instant;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(length = 2000)
private String description;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TaskStatus status;
/** 1 (low) through 5 (urgent). */
@Column(nullable = false)
private int priority;
@Column(nullable = false, updatable = false)
private Instant createdAt = Instant.now();
protected Task() {
// JPA requires a no-arg constructor -- it uses reflection to build
// instances before populating fields from the result set.
}
public Task(String title, String description, TaskStatus status, int priority) {
this.title = title;
this.description = description;
this.status = status;
this.priority = priority;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public TaskStatus getStatus() { return status; }
public int getPriority() { return priority; }
public Instant getCreatedAt() { return createdAt; }
public void setTitle(String title) { this.title = title; }
public void setDescription(String description) { this.description = description; }
public void setStatus(TaskStatus status) { this.status = status; }
public void setPriority(int priority) { this.priority = priority; }
}
enum TaskStatus { TODO, IN_PROGRESS, DONE }
@Id marks the primary key field; @GeneratedValue(strategy =
GenerationType.IDENTITY) delegates ID generation to the database's own
auto-increment column, which is the natural fit for PostgreSQL's
SERIAL/IDENTITY columns. @Column is optional
— Hibernate infers a column from the field name and type by default — but it lets
you set constraints like nullable and length that become
part of the generated CREATE TABLE DDL. @Enumerated(EnumType.STRING)
stores the enum's name ("TODO") rather than its ordinal position, which
is what you want: reordering the enum's constants later won't silently corrupt
existing rows.
Pointing Spring Boot at PostgreSQL
With Docker running a local PostgreSQL instance (docker run -d -p 5432:5432
-e POSTGRES_PASSWORD=postgres postgres:16 is enough for development), point
application.properties at it:
spring.datasource.url=jdbc:postgresql://localhost:5432/taskdb
spring.datasource.username=task_app
spring.datasource.password=change-me
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.hibernate.ddl-auto=update tells Hibernate to inspect your
entities on startup and alter the schema to match — create the tasks
table if it's missing, add columns it doesn't recognize. It's convenient for local
development because you never hand-write a CREATE TABLE statement, but
it is not a real schema management strategy: it can't rename or drop columns
safely, it has no history of what changed and when, and running it against a
production database is how tables quietly diverge from what your code expects.
Week 5 replaces it with Flyway, versioned SQL migration files that are the actual
answer for any project beyond a local sandbox.
Keep ddl-auto=update for this week's local exercise so you're not blocked on schema tooling before you've even seen an entity work. In Week 5, once you've felt how fragile it is, you'll switch to Flyway-managed migration scripts — the approach every production Spring Boot service in this course's later modules actually uses.
3. Spring Data Repositories
Writing an EntityManager query by hand for every basic operation —
save this, find that by ID, delete this — is repetitive enough that Spring Data
eliminates it entirely. Declare an interface extending JpaRepository<T,
ID>, and Spring generates a working implementation at startup. No class
body required:
package com.codeverse.week04;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
// Everything below is inherited -- nothing else needed yet.
}
That single line hands you a working set of persistence operations for free, generated and proxied in by Spring at application startup:
Task save(Task task); // INSERT or UPDATE
Optional<Task> findById(Long id); // SELECT ... WHERE id = ?
List<Task> findAll(); // SELECT * FROM tasks
boolean existsById(Long id);
long count();
void deleteById(Long id);
void delete(Task task);
// ...plus batch variants: saveAll, findAllById, deleteAll
Compare that to Week 3's in-memory version, which needed a hand-rolled
Map or List, manual ID generation, and explicit
synchronization if you cared about thread safety. TaskRepository
replaces all of it with an interface Spring implements at runtime by generating a
proxy class — you never write the implementation, and it talks to a real,
durable, shared database instead of a field in one JVM's memory.
It's tempting now that Task is a real, richer class to just return it straight from your @RestController methods. Don't -- keep the TaskResponse record-based DTO shape from Week 3's controllers and map Task entities into it before serializing. Returning entities directly risks leaking lazy-loaded fields, Hibernate proxy internals, and JPA-specific JSON quirks into your public API contract.
4. Derived Query Methods & JPQL
Beyond the free CRUD methods, Spring Data can generate query implementations from a method's name alone. Name a method following its keyword conventions, and Spring Data parses it into a query at startup:
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByStatus(TaskStatus status);
List<Task> findByStatusAndPriorityGreaterThan(TaskStatus status, int priority);
List<Task> findByTitleContainingIgnoreCase(String keyword);
}
findByStatusAndPriorityGreaterThan(status, priority) becomes, roughly,
SELECT * FROM tasks WHERE status = ? AND priority > ? — Spring Data
parses And, GreaterThan, ContainingIgnoreCase
and dozens of other keywords directly out of the method name and builds the
matching query. It's genuinely convenient for simple lookups, but it doesn't scale
past two or three conditions: a method like
findByStatusAndPriorityGreaterThanAndCreatedAtBeforeOrderByPriorityDesc
is technically valid and technically works, but nobody can read it at a glance, and
a typo in the middle silently produces a different query — or a startup failure —
rather than a compile error.
Switching to @Query and JPQL
Once a query needs a join, an aggregate, or just more conditions than a method name
can stay readable with, write it explicitly with @Query using
JPQL (Jakarta Persistence Query Language) — SQL-like syntax that
operates on entities and their fields rather than tables and columns directly:
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface TaskRepository extends JpaRepository<Task, Long> {
@Query("""
SELECT t FROM Task t
WHERE t.status = :status
AND LOWER(t.title) LIKE LOWER(CONCAT('%', :keyword, '%'))
ORDER BY t.priority DESC
""")
List<Task> search(@Param("status") TaskStatus status, @Param("keyword") String keyword);
}
Note that the query references Task and t.status — the
entity class and its Java fields — not a tasks table or a
status column; Hibernate translates that into real SQL against the
mapped table. Named parameters (:status, bound with
@Param("status")) keep the query readable and safe from the kind of
string-concatenation mistakes that lead to SQL injection in hand-written JDBC code.
5. Pagination & Sorting
findAll() returning every row was harmless with Week 3's tiny
in-memory list. Against a real database with thousands or millions of rows, an
unpaginated list endpoint is a liability: one slow query, one enormous JSON
response, one client that just wanted the first 20 results. Once there's a real
database behind an endpoint, you paginate every list endpoint by default.
Spring Data builds pagination into the repository layer itself.
JpaRepository extends PagingAndSortingRepository, which
adds an overload of findAll that accepts a Pageable:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
Pageable pageable = PageRequest.of(0, 20, Sort.by("priority").descending());
Page<Task> page = taskRepository.findAll(pageable);
page.getContent(); // the 20 Task rows for this page
page.getTotalElements(); // total matching rows across all pages
page.getTotalPages();
page.getNumber(); // current page index (0-based)
page.hasNext();
Pageable bundles a page number, page size, and an optional
Sort into a single object; Page<T> is what comes
back — not just the slice of results, but the metadata a client needs to render
pagination controls, without a second COUNT(*) query written by hand.
Wiring it into a controller means turning query parameters into a
Pageable and mapping each entity in the page to a DTO before it's
serialized:
@GetMapping("/tasks")
public Page<TaskResponse> listTasks(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt,desc") String sort) {
String[] sortParts = sort.split(",");
Sort.Direction direction = sortParts.length > 1 && sortParts[1].equalsIgnoreCase("asc")
? Sort.Direction.ASC
: Sort.Direction.DESC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortParts[0]));
return taskRepository.findAll(pageable).map(TaskMapper::toResponse);
}
Page<T>.map(...) transforms each entity into a DTO while keeping
the page metadata (total elements, total pages, current page number) intact —
exactly the shape you want to hand back as JSON without leaking Task
entities to the client.
Right now the only way to be sure your derived queries and pagination behave correctly is running the app and poking it manually. Week 8 introduces Testcontainers, which spins up a real, disposable PostgreSQL instance for your test suite -- so tests exercise the actual SQL Hibernate generates instead of trusting a mocked repository.
6. Hands-on Exercise
Move the Week 3 Task API onto PostgreSQL with Spring Data JPA
Take the in-memory Task API you built in Week 3 and back it with a real database, using everything covered this week: entity mapping, a Spring Data repository, derived and JPQL queries, and pagination.
Requirements:
- In package
com.codeverse.week04, turnTaskinto a proper@Entitymapped to ataskstable, withid,title,description, aTaskStatusenum field stored with@Enumerated(EnumType.STRING), an integerpriority, and acreatedAttimestamp — replacing Week 3's in-memory list or map entirely. - Point
application.propertiesat a local PostgreSQL instance (Docker is the easiest route) withspring.jpa.hibernate.ddl-auto=update, and confirm thetaskstable is created automatically the first time the app starts. - Create
TaskRepository extends JpaRepository<Task, Long>and add a derived query method,findByStatusAndPriorityGreaterThan(TaskStatus status, int priority), that your controller can call to filter urgent open tasks. - Add one
@Querymethod using JPQL with named parameters — a case-insensitive keyword search acrosstitleis a good fit — and expose it through aGET /tasks/search?keyword=...endpoint. - Add a paginated
GET /tasksendpoint acceptingpage,size, andsortquery parameters, returning aPage<TaskResponse>built by mappingTaskentities to Week 3's DTO shape — never serialize the entity directly.
Your existing TaskRequest/TaskResponse DTOs and validation from Week 3 don't need to change shape — only the controller's plumbing changes, from reading and writing a Map to calling TaskRepository. If a test breaks, it's almost always in the mapping between Task and its DTOs, not in JPA itself.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What is the persistence context, and why is it sometimes called a "first-level cache"?
What is the persistence context, and why is it sometimes called a "first-level cache"?
The persistence context is the set of entity instances an EntityManager is currently tracking, or "managing," within a transaction. When you load an entity by ID, JPA registers it in the persistence context; loading the same ID again in the same transaction returns the identical cached object instead of issuing a second query, which is why it's called a first-level cache. It's more than a cache, though — JPA also uses it to perform dirty checking, comparing a managed entity's current field values against the state it had when loaded, and automatically issuing an UPDATE for any changes when the transaction commits, without an explicit save call.
Q2
Why do derived query method names get unwieldy, and when should you switch to @Query?
Why do derived query method names get unwieldy, and when should you switch to @Query?
Derived query methods work by encoding the entire query — every condition, operator and sort order — into the method name itself, using keywords like And, GreaterThan and OrderBy. That's readable for one or two conditions, but past that it turns into a long, densely-packed identifier that's hard to parse visually and easy to get subtly wrong, since a mistyped keyword either changes the query silently or fails at startup with a cryptic error rather than a compile-time one. The rule of thumb is to switch to an explicit @Query with JPQL once a method name needs more than two or three conditions, needs a join or aggregate, or once you find yourself squinting at the method signature to figure out what it actually queries.
Q3
Why is spring.jpa.hibernate.ddl-auto=update unsafe for a real project, even though it's convenient locally?
Why is spring.jpa.hibernate.ddl-auto=update unsafe for a real project, even though it's convenient locally?
ddl-auto=update lets Hibernate infer schema changes from your entity classes and apply them automatically on startup, which is genuinely useful early on because you never hand-write DDL. The problem is that it's a one-way, best-effort guess: it can add columns and tables but won't safely rename or drop them, it has no record of what changed or when, it can behave differently across Hibernate versions, and running it against a shared or production database means every developer's entity changes get applied live with no review step and no rollback path. Real projects need a schema history they can review, test, and roll back — which is exactly what Week 5's Flyway migrations provide, and why ddl-auto should never be enabled outside local development.
Q4
When a repository method returns Page<Task>, what does the client actually receive, and why is that more than just a list?
When a repository method returns Page<Task>, what does the client actually receive, and why is that more than just a list?
A Pageable passed into a query method carries the requested page number, page size, and sort order; the Page<T> returned bundles the matching slice of results together with pagination metadata — total element count, total page count, current page number, and whether a next or previous page exists. That's what makes it more useful than a plain List<T>: a client rendering "Page 3 of 40" or a "Load more" button needs that metadata, and without it, the client (or the server, separately) would have to run a second query just to count the total rows. Spring Data computes that count query alongside the content query automatically, so serializing a Page to JSON hands the client everything it needs to build pagination controls in one response.