Repository Pattern Deep Dive
What is the Repository Pattern?
The Repository pattern is a design pattern that abstracts data access behind a clean interface. The idea is simple: your service layer should not know (or care) whether data comes from a database, a REST API, a file, or an in-memory list. It just calls methods on a repository interface.
In Lectures 2 and 3, we wrote repositories manually with in-memory storage. In Lecture 5, we replaced them with Spring Data JPA interfaces. But the service layer code barely changed — that is the power of this pattern.
Service Layer: "Give me user #42"
↓
Repository: findById(42) ← The service doesn't know HOW this works
↓
Spring Data: Generates SQL → Executes via Hibernate → Returns User object
The Spring Data Repository Hierarchy (Revisited)
Let us look at the hierarchy more carefully, because understanding what each level provides helps you choose the right base interface:
// Level 1: Repository — empty marker interface
public interface Repository<T, ID> { }
// Level 2: CrudRepository — basic CRUD operations
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
Optional<T> findById(ID id);
Iterable<T> findAll();
long count();
void deleteById(ID id);
boolean existsById(ID id);
// ... and a few more
}
// Level 3: ListCrudRepository — same as CrudRepository but returns List instead of Iterable
public interface ListCrudRepository<T, ID> extends CrudRepository<T, ID> {
List<T> findAll();
List<T> findAllById(Iterable<ID> ids);
<S extends T> List<S> saveAll(Iterable<S> entities);
}
// Level 4: PagingAndSortingRepository — adds pagination and sorting
public interface PagingAndSortingRepository<T, ID> extends Repository<T, ID> {
Iterable<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
}
// Level 5: JpaRepository — JPA-specific methods
public interface JpaRepository<T, ID> extends
ListCrudRepository<T, ID>,
ListPagingAndSortingRepository<T, ID> {
void flush();
<S extends T> S saveAndFlush(S entity);
void deleteAllInBatch();
List<T> findAll(Sort sort);
Page<T> findAll(Pageable pageable);
// ... and more
}
Always extend JpaRepository. It includes everything from all parent interfaces, plus JPA-specific batch operations and flush() support.
Built-in Methods — save(), findById(), findAll(), deleteById()
Let us explore each built-in method in detail, including the SQL it generates and edge cases you should know about.
save() — Create and Update
The save() method handles both INSERT and UPDATE. It checks whether the entity is new (id is null) or existing (id has a value):
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// CREATE — when id is null, save() generates an INSERT
public User createUser(String username, String email) {
User user = new User(username, email, "tempHash");
user.setFullName(username);
// user.getId() is null → Hibernate generates:
// INSERT INTO users (username, email, password_hash, ...) VALUES (?, ?, ?, ...)
User saved = userRepository.save(user);
// After save(), the id is populated by the database (AUTO_INCREMENT)
System.out.println("Created user with id: " + saved.getId()); // e.g., id = 1
return saved;
}
// UPDATE — when id has a value, save() generates an UPDATE
public User updateUserBio(Long id, String newBio) {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found"));
user.setBio(newBio);
// user.getId() is NOT null → Hibernate generates:
// UPDATE users SET username=?, email=?, bio=?, ... WHERE id=?
return userRepository.save(user);
}
}
Important detail about save(): When updating, Hibernate sends ALL columns in the UPDATE statement, not just the ones you changed. This means if your entity has 20 fields and you change 1 field, the generated SQL still updates all 20. This is usually fine, but for very wide tables it can be optimized with @DynamicUpdate (covered in Lecture 16).
saveAll() — Batch Save
// Save multiple entities at once
public List<User> createBulkUsers(List<User> users) {
// Hibernate generates multiple INSERT statements.
// With proper batching configuration, these can be sent in a single round trip.
return userRepository.saveAll(users);
}
To enable actual JDBC batch inserts, add this to application.properties:
# Batch insert/update — sends multiple SQL statements in one network call
spring.jpa.properties.hibernate.jdbc.batch_size=25
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
findById() — Read One
public User getUserById(Long id) {
// Generates: SELECT * FROM users WHERE id = ?
// Returns Optional<User> — forces you to handle the "not found" case
Optional<User> optionalUser = userRepository.findById(id);
// Three ways to handle the Optional:
// Way 1: orElseThrow (recommended for "must exist" scenarios)
return optionalUser.orElseThrow(
() -> new RuntimeException("User not found with id: " + id));
// Way 2: orElse (provide a default value)
// return optionalUser.orElse(new User("default", "default@example.com", "hash"));
// Way 3: ifPresent (do something only if present)
// optionalUser.ifPresent(user -> System.out.println("Found: " + user.getUsername()));
}
findAll() — Read All
public List<User> getAllUsers() {
// Generates: SELECT * FROM users
// WARNING: This loads ALL users into memory.
// For large tables, use pagination (Section 6)!
return userRepository.findAll();
}
findAllById() — Read Multiple by IDs
public List<User> getUsersByIds(List<Long> ids) {
// Generates: SELECT * FROM users WHERE id IN (?, ?, ?)
return userRepository.findAllById(ids);
}
existsById() — Check Existence
public boolean userExists(Long id) {
// Generates: SELECT COUNT(*) FROM users WHERE id = ?
// More efficient than findById() when you only need to know if it exists
return userRepository.existsById(id);
}
count() — Count Rows
public long getTotalUsers() {
// Generates: SELECT COUNT(*) FROM users
return userRepository.count();
}
deleteById() — Delete
public void deleteUser(Long id) {
// Generates: SELECT * FROM users WHERE id = ? (first loads the entity)
// DELETE FROM users WHERE id = ? (then deletes it)
// Note: Hibernate loads the entity before deleting by default.
// This allows cascade operations and lifecycle callbacks to work.
userRepository.deleteById(id);
}
deleteAllInBatch() — Efficient Bulk Delete
public void deleteAllUsers() {
// Generates a single: DELETE FROM users
// Much faster than deleteAll() which loads and deletes entities one by one
userRepository.deleteAllInBatch();
}
flush() and saveAndFlush()
Hibernate does not execute SQL immediately. It collects changes and writes them to the database at specific moments (usually at the end of a transaction). flush() forces Hibernate to write pending changes immediately:
// save() — queues the INSERT but may not execute immediately
User user = userRepository.save(new User("alice", "alice@example.com", "hash"));
// The INSERT might not have been executed yet!
// saveAndFlush() — executes the INSERT immediately
User user = userRepository.saveAndFlush(new User("alice", "alice@example.com", "hash"));
// The INSERT has been executed — the row is in the database right now
In most cases, you do not need saveAndFlush(). Hibernate flushes automatically at the right time. Use it when you need the database-generated ID immediately or when you need to ensure data is written before executing a native query.
Derived Query Methods — Naming Convention Magic
This is one of the most impressive features of Spring Data JPA. You define a method in your repository interface following a specific naming convention, and Spring automatically generates the SQL query.
The Pattern
findBy + FieldName + Condition
Spring parses the method name, identifies the entity fields and conditions, and generates the corresponding SQL.
Basic Examples
public interface UserRepository extends JpaRepository<User, Long> {
// findByEmail → SELECT * FROM users WHERE email = ?
Optional<User> findByEmail(String email);
// findByUsername → SELECT * FROM users WHERE username = ?
Optional<User> findByUsername(String username);
// findByRole → SELECT * FROM users WHERE role = ?
List<User> findByRole(String role);
// findByActive → SELECT * FROM users WHERE is_active = ?
// Note: Spring maps the "active" field to the "is_active" column automatically
List<User> findByActive(boolean active);
}
Combining Multiple Fields
public interface PostRepository extends JpaRepository<Post, Long> {
// And — combines two conditions with AND
// SELECT * FROM posts WHERE status = ? AND author_id = ?
List<Post> findByStatusAndAuthorId(String status, Long authorId);
// Or — combines two conditions with OR
// SELECT * FROM posts WHERE status = ? OR status = ?
List<Post> findByStatusOrStatus(String status1, String status2);
}
Comparison Operators
public interface PostRepository extends JpaRepository<Post, Long> {
// After — date comparison
// SELECT * FROM posts WHERE created_at > ?
List<Post> findByCreatedAtAfter(LocalDateTime date);
// Before — date comparison
// SELECT * FROM posts WHERE created_at < ?
List<Post> findByCreatedAtBefore(LocalDateTime date);
// Between — range
// SELECT * FROM posts WHERE created_at BETWEEN ? AND ?
List<Post> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
// GreaterThan, LessThan
// SELECT * FROM posts WHERE id > ?
List<Post> findByIdGreaterThan(Long id);
}
String Operations
public interface PostRepository extends JpaRepository<Post, Long> {
// Containing → LIKE '%keyword%'
// SELECT * FROM posts WHERE title LIKE '%keyword%'
List<Post> findByTitleContaining(String keyword);
// IgnoreCase — case-insensitive comparison
// SELECT * FROM posts WHERE LOWER(title) LIKE LOWER('%keyword%')
List<Post> findByTitleContainingIgnoreCase(String keyword);
// StartingWith → LIKE 'prefix%'
List<Post> findByTitleStartingWith(String prefix);
// EndingWith → LIKE '%suffix'
List<Post> findByTitleEndingWith(String suffix);
// Not
// SELECT * FROM posts WHERE status != ?
List<Post> findByStatusNot(String status);
}
Collection Operations
public interface PostRepository extends JpaRepository<Post, Long> {
// In — matches any value in a list
// SELECT * FROM posts WHERE status IN (?, ?, ?)
List<Post> findByStatusIn(List<String> statuses);
// NotIn
// SELECT * FROM posts WHERE status NOT IN (?, ?)
List<Post> findByStatusNotIn(List<String> statuses);
}
Null Checks
public interface PostRepository extends JpaRepository<Post, Long> {
// IsNull
// SELECT * FROM posts WHERE published_at IS NULL
List<Post> findByPublishedAtIsNull();
// IsNotNull
// SELECT * FROM posts WHERE published_at IS NOT NULL
List<Post> findByPublishedAtIsNotNull();
}
Ordering Results
public interface PostRepository extends JpaRepository<Post, Long> {
// OrderBy — append to any query
// SELECT * FROM posts WHERE status = ? ORDER BY created_at DESC
List<Post> findByStatusOrderByCreatedAtDesc(String status);
// Multiple order clauses
// SELECT * FROM posts WHERE status = ? ORDER BY category_id ASC, created_at DESC
List<Post> findByStatusOrderByCategoryIdAscCreatedAtDesc(String status);
}
Limiting Results
public interface PostRepository extends JpaRepository<Post, Long> {
// Top/First — limits the number of results
// SELECT * FROM posts ORDER BY created_at DESC LIMIT 5
List<Post> findTop5ByOrderByCreatedAtDesc();
// First — same as Top
// SELECT * FROM posts WHERE status = ? ORDER BY created_at DESC LIMIT 1
Optional<Post> findFirstByStatusOrderByCreatedAtDesc(String status);
}
Count and Exists
public interface PostRepository extends JpaRepository<Post, Long> {
// countBy — returns a count instead of entities
// SELECT COUNT(*) FROM posts WHERE status = ?
long countByStatus(String status);
// existsBy — returns boolean
// SELECT EXISTS(SELECT 1 FROM posts WHERE slug = ?)
boolean existsBySlug(String slug);
}
Delete by Condition
public interface PostRepository extends JpaRepository<Post, Long> {
// deleteBy — deletes matching entities
// SELECT * FROM posts WHERE status = ? (loads entities first)
// DELETE FROM posts WHERE id = ? (then deletes each one)
void deleteByStatus(String status);
// Return the count of deleted entities
long deleteByStatusAndCreatedAtBefore(String status, LocalDateTime date);
}
When Derived Queries Get Too Long
Method names can become unwieldy:
// This works but is painful to read and maintain
List<Post> findByStatusAndCategoryIdAndTitleContainingIgnoreCaseOrderByCreatedAtDesc(
String status, Long categoryId, String keyword);
When a method name gets this long, switch to @Query (next section). Derived queries are best for simple conditions — one or two fields.
@Query with JPQL (Java Persistence Query Language)
When derived query methods become too complex or you need more control, use @Query with JPQL.
What is JPQL?
JPQL (Java Persistence Query Language) is a query language similar to SQL but operates on entities instead of tables. Instead of writing SELECT * FROM posts, you write SELECT p FROM Post p — using the Java class name and field names.
Basic @Query Examples
public interface PostRepository extends JpaRepository<Post, Long> {
// JPQL uses entity names (Post) and field names (title), not table/column names
// Note: "Post" is the Java class name, "p" is an alias
@Query("SELECT p FROM Post p WHERE p.status = :status ORDER BY p.createdAt DESC")
List<Post> findPublishedPosts(@Param("status") String status);
// :keyword is a named parameter — matched by @Param("keyword")
@Query("SELECT p FROM Post p WHERE LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Post> searchByTitle(@Param("keyword") String keyword);
// Search in both title and content
@Query("SELECT p FROM Post p WHERE " +
"LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
"LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Post> searchByKeyword(@Param("keyword") String keyword);
}
The @Param("status") annotation binds the method parameter to the named parameter :status in the query. This is clearer and safer than positional parameters.
JOIN Queries in JPQL
JPQL makes joins easy because it understands entity relationships:
public interface PostRepository extends JpaRepository<Post, Long> {
// JOIN using the entity relationship — "p.author" follows the @ManyToOne field
// Hibernate generates: SELECT p.* FROM posts p JOIN users u ON p.author_id = u.id
// WHERE u.username = ?
@Query("SELECT p FROM Post p JOIN p.author a WHERE a.username = :username")
List<Post> findByAuthorUsername(@Param("username") String username);
// LEFT JOIN — include posts even if they have no category
@Query("SELECT p FROM Post p LEFT JOIN p.category c " +
"WHERE p.status = :status " +
"ORDER BY p.createdAt DESC")
List<Post> findPublishedWithCategory(@Param("status") String status);
// JOIN with multiple conditions
@Query("SELECT p FROM Post p " +
"JOIN p.author a " +
"LEFT JOIN p.category c " +
"WHERE p.status = 'PUBLISHED' " +
"AND a.active = true " +
"ORDER BY p.publishedAt DESC")
List<Post> findAllPublishedByActiveAuthors();
}
Aggregate Functions in JPQL
public interface PostRepository extends JpaRepository<Post, Long> {
// Count posts per status
@Query("SELECT p.status, COUNT(p) FROM Post p GROUP BY p.status")
List<Object[]> countByStatusGrouped();
// The result is a List of Object arrays:
// [["DRAFT", 5], ["PUBLISHED", 12], ["ARCHIVED", 3]]
// We will learn cleaner ways to handle this with projections (Section 7)
}
Update and Delete with @Query
You can also write UPDATE and DELETE queries. These require the @Modifying annotation:
public interface PostRepository extends JpaRepository<Post, Long> {
// @Modifying tells Spring this query changes data (not just reads)
// @Transactional is required for modifying queries
@Modifying
@Transactional
@Query("UPDATE Post p SET p.status = :status WHERE p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") String status);
// Returns the number of rows affected
// Bulk update — publish all draft posts by a specific author
@Modifying
@Transactional
@Query("UPDATE Post p SET p.status = 'PUBLISHED', p.publishedAt = CURRENT_TIMESTAMP " +
"WHERE p.author.id = :authorId AND p.status = 'DRAFT'")
int publishAllDraftsByAuthor(@Param("authorId") Long authorId);
// Bulk delete — remove old archived posts
@Modifying
@Transactional
@Query("DELETE FROM Post p WHERE p.status = 'ARCHIVED' AND p.updatedAt < :cutoffDate")
int deleteOldArchivedPosts(@Param("cutoffDate") LocalDateTime cutoffDate);
}
Important: @Modifying queries bypass Hibernate’s cache (the persistence context). If you modify an entity via @Modifying and then read it in the same transaction, you might get stale data. Add clearAutomatically = true to fix this:
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.status = :status WHERE p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") String status);
Native SQL Queries with @Query(nativeQuery = true)
Sometimes JPQL is not enough — you need database-specific features, complex joins, or raw SQL for performance. Use nativeQuery = true for this.
When to Use Native Queries
- Database-specific functions not available in JPQL
- Complex queries that are easier to write in SQL
- Performance-critical queries where you need exact control over the generated SQL
- Queries involving tables not mapped as entities
Basic Native Queries
public interface PostRepository extends JpaRepository<Post, Long> {
// Native SQL — uses table and column names, not entity and field names
@Query(value = "SELECT * FROM posts WHERE status = :status ORDER BY created_at DESC",
nativeQuery = true)
List<Post> findByStatusNative(@Param("status") String status);
// MariaDB-specific: FULLTEXT search (not available in JPQL)
@Query(value = "SELECT * FROM posts WHERE MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)",
nativeQuery = true)
List<Post> fullTextSearch(@Param("keyword") String keyword);
// Complex aggregation
@Query(value = """
SELECT u.full_name AS author_name,
COUNT(p.id) AS post_count,
MAX(p.created_at) AS latest_post
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
GROUP BY u.id, u.full_name
ORDER BY post_count DESC
""",
nativeQuery = true)
List<Object[]> getAuthorStatistics();
}
Native Queries with Pagination
Native queries work with pagination, but you need to provide a separate count query:
@Query(value = "SELECT * FROM posts WHERE status = :status ORDER BY created_at DESC",
countQuery = "SELECT COUNT(*) FROM posts WHERE status = :status",
nativeQuery = true)
Page<Post> findByStatusNativePaged(@Param("status") String status, Pageable pageable);
The countQuery is required because Spring Data needs to know the total number of records to build the Page object. JPQL handles this automatically, but native queries cannot.
JPQL vs Native SQL — When to Use Which
| Scenario | Use JPQL | Use Native SQL |
|---|---|---|
| Simple CRUD queries | ✅ | |
| Queries involving entity relationships | ✅ | |
| Portable across databases | ✅ | |
| Database-specific functions | ✅ | |
| Complex analytics/reporting queries | ✅ | |
| Performance-critical queries | ✅ | |
| Queries on unmapped tables | ✅ |
Start with derived query methods. If they get too complex, move to JPQL. Only use native SQL when JPQL cannot express what you need. This progression keeps your code portable and maintainable.
Pagination and Sorting with Pageable
Why Pagination Matters
Imagine your blog has 100,000 posts. Calling findAll() loads all 100,000 posts into memory — instantly crashing your application or making it unbearably slow. Pagination solves this by loading data in small chunks (pages).
The Pageable Interface
Spring Data’s Pageable interface represents a page request — which page, how many items per page, and how to sort:
// Create a Pageable: page 0 (first page), 10 items per page, sorted by createdAt descending
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
// Page numbers are 0-indexed:
// Page 0 = items 1-10
// Page 1 = items 11-20
// Page 2 = items 21-30
Using Pageable in Repositories
Any repository method can accept a Pageable parameter:
public interface PostRepository extends JpaRepository<Post, Long> {
// Built-in method with pagination
// findAll(Pageable) is already provided by JpaRepository
// Derived query with pagination
Page<Post> findByStatus(String status, Pageable pageable);
// JPQL with pagination
@Query("SELECT p FROM Post p WHERE p.status = :status AND p.author.active = true")
Page<Post> findPublishedByActiveAuthors(@Param("status") String status, Pageable pageable);
}
The Page Object
The Page object contains not just the data, but also metadata about the pagination:
@Service
public class PostService {
private final PostRepository postRepository;
public PostService(PostRepository postRepository) {
this.postRepository = postRepository;
}
public Page<Post> getPublishedPosts(int page, int size) {
// Create the page request
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
// Execute the query
Page<Post> postPage = postRepository.findByStatus("PUBLISHED", pageable);
// The Page object contains useful metadata:
List<Post> posts = postPage.getContent(); // The actual data
long totalElements = postPage.getTotalElements(); // Total posts across all pages
int totalPages = postPage.getTotalPages(); // Total number of pages
int currentPage = postPage.getNumber(); // Current page number (0-indexed)
int pageSize = postPage.getSize(); // Items per page
boolean isFirst = postPage.isFirst(); // Is this the first page?
boolean isLast = postPage.isLast(); // Is this the last page?
boolean hasNext = postPage.hasNext(); // Is there a next page?
return postPage;
}
}
Pagination in the Controller
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// GET /api/posts?page=0&size=10&sort=createdAt,desc
@GetMapping
public Page<Post> getPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
Sort sort = direction.equalsIgnoreCase("asc")
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, size, sort);
return postService.getPostsByPage(pageable);
}
}
The response includes both data and pagination metadata:
{
"content": [
{ "id": 10, "title": "Latest Post", "status": "PUBLISHED", ... },
{ "id": 9, "title": "Previous Post", "status": "PUBLISHED", ... }
],
"pageable": {
"pageNumber": 0,
"pageSize": 10,
"sort": { "sorted": true, "direction": "DESC", "property": "createdAt" }
},
"totalElements": 45,
"totalPages": 5,
"last": false,
"first": true,
"numberOfElements": 10,
"size": 10,
"number": 0,
"empty": false
}
This response tells the client everything it needs to build pagination controls: “Page 1 of 5, showing 10 of 45 posts, there is a next page.”
Sorting with Multiple Fields
// Sort by status ascending, then by createdAt descending
Sort sort = Sort.by(
Sort.Order.asc("status"),
Sort.Order.desc("createdAt")
);
Pageable pageable = PageRequest.of(0, 10, sort);
Slice vs Page
If you do not need the total count (which requires an extra COUNT query), use Slice instead of Page:
// Slice — does NOT execute a COUNT query (faster)
// Only knows if there is a next page or not
Slice<Post> findByStatus(String status, Pageable pageable);
// Page — executes both SELECT and COUNT queries
// Knows total elements and total pages
Page<Post> findByStatus(String status, Pageable pageable);
Use Slice for infinite scroll UIs (where you just need “load more”) and Page for traditional pagination with page numbers.
Projection — Returning Partial Data
The Problem
When you load a Post entity, Hibernate fetches ALL columns:
SELECT id, title, slug, content, excerpt, status, author_id, category_id,
meta_title, meta_description, published_at, created_at, updated_at
FROM posts WHERE id = ?
For a list page that only shows title, status, and date, loading the full content (which could be thousands of characters) is wasteful. Projections let you load only the columns you need.
Interface-Based Projection (Recommended)
Define an interface with getter methods for the fields you want:
// This interface defines the "shape" of the data you want.
// Only declare getters for the fields you need.
public interface PostSummary {
Long getId();
String getTitle();
String getSlug();
String getStatus();
String getExcerpt();
LocalDateTime getCreatedAt();
}
Use the projection in your repository:
public interface PostRepository extends JpaRepository<Post, Long> {
// Return PostSummary instead of Post — only selected columns are fetched
// Hibernate generates: SELECT id, title, slug, status, excerpt, created_at
// FROM posts WHERE status = ?
// Notice: "content" is NOT loaded!
List<PostSummary> findByStatus(String status);
// Projections work with pagination too
Page<PostSummary> findByStatus(String status, Pageable pageable);
// Projections work with @Query
@Query("SELECT p FROM Post p WHERE p.author.id = :authorId")
List<PostSummary> findSummariesByAuthorId(@Param("authorId") Long authorId);
}
Nested Projections
You can include related entity data in projections:
public interface PostDetail {
Long getId();
String getTitle();
String getContent();
String getStatus();
LocalDateTime getCreatedAt();
// Nested projection — includes author data
AuthorInfo getAuthor();
// Nested projection — includes category data
CategoryInfo getCategory();
interface AuthorInfo {
Long getId();
String getUsername();
String getFullName();
}
interface CategoryInfo {
Long getId();
String getName();
String getSlug();
}
}
public interface PostRepository extends JpaRepository<Post, Long> {
// Returns post with nested author and category info
Optional<PostDetail> findDetailById(Long id);
Page<PostDetail> findDetailByStatus(String status, Pageable pageable);
}
Class-Based Projection (DTO Projection)
You can also project into a regular class using a constructor:
// A simple DTO class
public class PostSummaryDto {
private final Long id;
private final String title;
private final String status;
// The constructor parameter names must match the entity field names
public PostSummaryDto(Long id, String title, String status) {
this.id = id;
this.title = title;
this.status = status;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getStatus() { return status; }
}
public interface PostRepository extends JpaRepository<Post, Long> {
// Use "new" in JPQL to create DTO instances directly from the query
@Query("SELECT new com.example.blogapi.dto.PostSummaryDto(p.id, p.title, p.status) " +
"FROM Post p WHERE p.status = :status")
List<PostSummaryDto> findSummaryDtos(@Param("status") String status);
}
When to Use Projections
| Scenario | Use Full Entity | Use Projection |
|---|---|---|
| Creating or updating data | ✅ | |
| Loading data you will modify | ✅ | |
| List pages with limited columns | ✅ | |
| API responses that expose a subset of fields | ✅ | |
| Read-only reports and dashboards | ✅ | |
| Large TEXT/BLOB columns you don’t always need | ✅ |
We will formalize projections and DTOs in Lecture 8 with a proper DTO pattern.
Auditing — @CreatedDate, @LastModifiedDate
In Lecture 5, we used @PrePersist and @PreUpdate to set timestamps manually. Spring Data JPA provides a cleaner approach with auditing annotations.
Setting Up Auditing
Step 1: Enable JPA Auditing
Add @EnableJpaAuditing to your main application class:
@SpringBootApplication
@EnableJpaAuditing // Enable Spring Data JPA auditing
public class BlogApiApplication {
public static void main(String[] args) {
SpringApplication.run(BlogApiApplication.class, args);
}
}
Step 2: Create a Base Entity
Create an abstract class with the audit fields. All entities that need timestamps can extend this class:
package com.example.blogapi.model;
import jakarta.persistence.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
// @MappedSuperclass means: this class is not an entity itself,
// but its fields are inherited by child entities
@MappedSuperclass
// @EntityListeners tells JPA to use AuditingEntityListener for lifecycle events.
// This listener is what makes @CreatedDate and @LastModifiedDate work.
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
// @CreatedDate — Spring automatically sets this when the entity is first saved.
// No manual code needed!
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
// @LastModifiedDate — Spring automatically updates this on every save.
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
Step 3: Extend BaseEntity in Your Entities
@Entity
@Table(name = "users")
public class User extends BaseEntity {
// No need for createdAt/updatedAt fields or @PrePersist/@PreUpdate!
// They are inherited from BaseEntity and managed automatically.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
// ... rest of the fields
}
@Entity
@Table(name = "posts")
public class Post extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 500)
private String title;
// ... rest of the fields
}
Now every entity that extends BaseEntity automatically gets createdAt and updatedAt — set and updated by Spring without any manual code.
@CreatedBy and @LastModifiedBy
You can also track WHO created or modified a record:
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
// Track who created/modified the record
@CreatedBy
@Column(name = "created_by", updatable = false)
private String createdBy;
@LastModifiedBy
@Column(name = "updated_by")
private String updatedBy;
// getters...
}
For @CreatedBy and @LastModifiedBy to work, you need to provide an AuditorAware bean that returns the current user. We will implement this in Lecture 11 when we add Spring Security.
Understanding LazyInitializationException
This is the most common JPA error beginners encounter, and it deserves its own section.
What Causes It?
In Lecture 5, we defined @ManyToOne(fetch = FetchType.LAZY) on the Post entity:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
FetchType.LAZY means: do not load the User object when loading the Post. The author field is a proxy — a placeholder that loads the real data only when you access it.
The problem arises when the Hibernate session (database connection) is closed before you access the lazy field:
@Service
public class PostService {
public String getAuthorName(Long postId) {
Post post = postRepository.findById(postId).orElseThrow();
// The Hibernate session is still open here — lazy loading works
return post.getAuthor().getUsername();
// If the session is closed by this point → LazyInitializationException!
}
}
When Does It Happen?
The exception typically happens when:
- You load an entity in a service method
- The method returns the entity to the controller
- Jackson tries to serialize the entity to JSON
- Jackson accesses a lazy field (
post.getAuthor()) - The Hibernate session is already closed → LazyInitializationException
Controller calls Service.getPost()
→ Service loads Post (session open)
→ Service returns Post to Controller (session closes)
→ Controller returns Post to Jackson for JSON serialization
→ Jackson calls post.getAuthor() to serialize the author field
→ Session is closed → BOOM! LazyInitializationException
Solutions
There are several ways to solve this. Here are the most common, from best to worst:
Solution 1: Use DTOs (Best — Lecture 8)
Instead of returning the entity directly, map it to a DTO in the service layer while the session is still open:
@Service
public class PostService {
@Transactional(readOnly = true)
public PostDto getPost(Long id) {
Post post = postRepository.findById(id).orElseThrow();
// Map to DTO while session is open — access all lazy fields here
PostDto dto = new PostDto();
dto.setId(post.getId());
dto.setTitle(post.getTitle());
dto.setAuthorName(post.getAuthor().getUsername()); // Safe — session is open
return dto;
// After this method returns, the session closes.
// But we already extracted all the data we need into the DTO.
}
}
Solution 2: Use @Transactional(readOnly = true)
Ensure the Hibernate session stays open throughout the service method:
@Service
public class PostService {
// @Transactional keeps the session open for the entire method
@Transactional(readOnly = true)
public Post getPost(Long id) {
Post post = postRepository.findById(id).orElseThrow();
// Force lazy loading while session is open
post.getAuthor().getUsername(); // Triggers the SQL: SELECT * FROM users WHERE id = ?
return post;
}
}
Solution 3: Use JOIN FETCH in JPQL
Load the related entity eagerly in a specific query:
public interface PostRepository extends JpaRepository<Post, Long> {
// JOIN FETCH loads the author together with the post in a single query
// Generates: SELECT p.*, u.* FROM posts p JOIN users u ON p.author_id = u.id WHERE p.id = ?
@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
Optional<Post> findByIdWithAuthor(@Param("id") Long id);
// Fetch author and category in one query
@Query("SELECT p FROM Post p JOIN FETCH p.author LEFT JOIN FETCH p.category")
List<Post> findAllWithAuthorAndCategory();
}
Solution 4: Use @EntityGraph
A declarative way to specify which related entities to load eagerly:
public interface PostRepository extends JpaRepository<Post, Long> {
// @EntityGraph tells JPA to load the specified associations eagerly
@EntityGraph(attributePaths = {"author", "category"})
List<Post> findByStatus(String status);
@EntityGraph(attributePaths = {"author"})
Optional<Post> findWithAuthorById(Long id);
}
What NOT to Do
# DON'T use open-in-view (it keeps the session open during HTTP response rendering)
# It works but creates hidden performance problems and is considered an anti-pattern
spring.jpa.open-in-view=false
Spring Boot enables open-in-view by default (with a warning). We recommend disabling it and using proper solutions (DTOs, JOIN FETCH, @EntityGraph) instead. Add this to your application.properties:
spring.jpa.open-in-view=false
Hands-on: Complete Blog CRUD with Pagination & Search
Let us build a complete, production-quality blog CRUD API that uses everything from this lecture.
Step 1: Define the PostSummary Projection
File: src/main/java/com/example/blogapi/projection/PostSummary.java
package com.example.blogapi.projection;
import java.time.LocalDateTime;
// Interface-based projection — only loads the columns we need for list pages
public interface PostSummary {
Long getId();
String getTitle();
String getSlug();
String getStatus();
String getExcerpt();
LocalDateTime getCreatedAt();
LocalDateTime getPublishedAt();
}
Step 2: Enhance the Post Repository
File: src/main/java/com/example/blogapi/repository/PostRepository.java
package com.example.blogapi.repository;
import com.example.blogapi.model.Post;
import com.example.blogapi.projection.PostSummary;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
public interface PostRepository extends JpaRepository<Post, Long> {
// ===== Derived Query Methods =====
// Paginated list of post summaries by status (no content loaded)
Page<PostSummary> findByStatus(String status, Pageable pageable);
// Find by slug (for URL routing like /blog/my-first-post)
Optional<Post> findBySlug(String slug);
// Check if a slug already exists
boolean existsBySlug(String slug);
// Count by status
long countByStatus(String status);
// ===== JPQL Queries =====
// Search posts by keyword in title or content
@Query("SELECT p FROM Post p WHERE " +
"LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
"LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
Page<Post> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);
// Load a single post with its author and category eagerly (avoids LazyInitializationException)
@Query("SELECT p FROM Post p " +
"JOIN FETCH p.author " +
"LEFT JOIN FETCH p.category " +
"WHERE p.id = :id")
Optional<Post> findByIdWithDetails(@Param("id") Long id);
// Load all posts with authors (for list pages that need author info)
@EntityGraph(attributePaths = {"author"})
@Query("SELECT p FROM Post p WHERE p.status = :status")
Page<Post> findByStatusWithAuthor(@Param("status") String status, Pageable pageable);
// ===== Modifying Queries =====
// Publish a post (update status + set published_at)
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Post p SET p.status = 'PUBLISHED', " +
"p.publishedAt = CURRENT_TIMESTAMP " +
"WHERE p.id = :id AND p.status = 'DRAFT'")
int publishPost(@Param("id") Long id);
// Archive a post
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Post p SET p.status = 'ARCHIVED' WHERE p.id = :id")
int archivePost(@Param("id") Long id);
}
Step 3: Build the Post Service
File: src/main/java/com/example/blogapi/service/PostService.java
package com.example.blogapi.service;
import com.example.blogapi.model.Category;
import com.example.blogapi.model.Post;
import com.example.blogapi.model.User;
import com.example.blogapi.projection.PostSummary;
import com.example.blogapi.repository.CategoryRepository;
import com.example.blogapi.repository.PostRepository;
import com.example.blogapi.repository.UserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PostService {
private final PostRepository postRepository;
private final UserRepository userRepository;
private final CategoryRepository categoryRepository;
public PostService(PostRepository postRepository,
UserRepository userRepository,
CategoryRepository categoryRepository) {
this.postRepository = postRepository;
this.userRepository = userRepository;
this.categoryRepository = categoryRepository;
}
// CREATE
@Transactional
public Post createPost(String title, String content, String excerpt,
Long authorId, Long categoryId) {
User author = userRepository.findById(authorId)
.orElseThrow(() -> new RuntimeException("Author not found: " + authorId));
// Generate slug from title
String slug = generateSlug(title);
// Ensure slug uniqueness by appending a number if needed
String uniqueSlug = slug;
int counter = 1;
while (postRepository.existsBySlug(uniqueSlug)) {
uniqueSlug = slug + "-" + counter++;
}
Post post = new Post(title, uniqueSlug, content);
post.setExcerpt(excerpt);
post.setAuthor(author);
if (categoryId != null) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new RuntimeException("Category not found: " + categoryId));
post.setCategory(category);
}
return postRepository.save(post);
}
// READ — single post with all details
@Transactional(readOnly = true)
public Post getPostById(Long id) {
return postRepository.findByIdWithDetails(id)
.orElseThrow(() -> new RuntimeException("Post not found: " + id));
}
// READ — single post by slug
@Transactional(readOnly = true)
public Post getPostBySlug(String slug) {
return postRepository.findBySlug(slug)
.orElseThrow(() -> new RuntimeException("Post not found: " + slug));
}
// READ — paginated post summaries (lightweight, no content loaded)
@Transactional(readOnly = true)
public Page<PostSummary> getPostSummaries(String status, Pageable pageable) {
if (status != null && !status.isEmpty()) {
return postRepository.findByStatus(status, pageable);
}
// For all posts, we need a separate method or use findAll with projection
return postRepository.findByStatus("PUBLISHED", pageable);
}
// SEARCH — paginated keyword search
@Transactional(readOnly = true)
public Page<Post> searchPosts(String keyword, Pageable pageable) {
if (keyword == null || keyword.trim().isEmpty()) {
throw new IllegalArgumentException("Search keyword cannot be empty");
}
return postRepository.searchByKeyword(keyword.trim(), pageable);
}
// UPDATE — full update
@Transactional
public Post updatePost(Long id, String title, String content,
String excerpt, Long categoryId) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Post not found: " + id));
if (title != null && !title.trim().isEmpty()) {
post.setTitle(title.trim());
// Regenerate slug if title changed
post.setSlug(generateSlug(title));
}
if (content != null) {
post.setContent(content.trim());
}
if (excerpt != null) {
post.setExcerpt(excerpt.trim());
}
if (categoryId != null) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new RuntimeException("Category not found: " + categoryId));
post.setCategory(category);
}
// No need to call save() explicitly!
// The post is a "managed entity" within this @Transactional method.
// Hibernate automatically detects changes and generates an UPDATE
// when the transaction commits. This is called "dirty checking."
return post;
}
// PUBLISH
@Transactional
public boolean publishPost(Long id) {
int updated = postRepository.publishPost(id);
return updated > 0;
}
// ARCHIVE
@Transactional
public boolean archivePost(Long id) {
int updated = postRepository.archivePost(id);
return updated > 0;
}
// DELETE
@Transactional
public void deletePost(Long id) {
if (!postRepository.existsById(id)) {
throw new RuntimeException("Post not found: " + id);
}
postRepository.deleteById(id);
}
// STATS
@Transactional(readOnly = true)
public PostStats getStats() {
long total = postRepository.count();
long published = postRepository.countByStatus("PUBLISHED");
long drafts = postRepository.countByStatus("DRAFT");
long archived = postRepository.countByStatus("ARCHIVED");
return new PostStats(total, published, drafts, archived);
}
// Helper: generate URL-friendly slug from title
private String generateSlug(String title) {
return title.toLowerCase()
.replaceAll("[^a-z0-9\\s-]", "")
.replaceAll("\\s+", "-")
.replaceAll("-+", "-")
.replaceAll("^-|-$", "");
}
// Simple inner class for stats — in a real app, this would be a proper DTO
public record PostStats(long total, long published, long drafts, long archived) { }
}
Notice the comment about dirty checking — one of Hibernate’s most powerful features. Within a @Transactional method, any changes to managed entities are automatically detected and persisted when the transaction commits. You do not need to call save() explicitly for updates.
Step 4: Build the Post Controller
File: src/main/java/com/example/blogapi/controller/PostController.java
package com.example.blogapi.controller;
import com.example.blogapi.model.Post;
import com.example.blogapi.projection.PostSummary;
import com.example.blogapi.service.PostService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/posts")
public class PostController {
private final PostService postService;
public PostController(PostService postService) {
this.postService = postService;
}
// POST /api/posts
@PostMapping
public ResponseEntity<Post> createPost(@RequestBody Map<String, Object> request) {
String title = (String) request.get("title");
String content = (String) request.get("content");
String excerpt = (String) request.get("excerpt");
Long authorId = Long.valueOf(request.get("authorId").toString());
Long categoryId = request.get("categoryId") != null
? Long.valueOf(request.get("categoryId").toString()) : null;
Post created = postService.createPost(title, content, excerpt, authorId, categoryId);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
// GET /api/posts?page=0&size=10&sort=createdAt&direction=desc&status=PUBLISHED
@GetMapping
public Page<PostSummary> listPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sort,
@RequestParam(defaultValue = "desc") String direction,
@RequestParam(required = false) String status) {
Sort sortOrder = direction.equalsIgnoreCase("asc")
? Sort.by(sort).ascending()
: Sort.by(sort).descending();
Pageable pageable = PageRequest.of(page, size, sortOrder);
return postService.getPostSummaries(status, pageable);
}
// GET /api/posts/42
@GetMapping("/{id}")
public ResponseEntity<Post> getPost(@PathVariable Long id) {
try {
return ResponseEntity.ok(postService.getPostById(id));
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
// GET /api/posts/slug/getting-started-with-spring-boot
@GetMapping("/slug/{slug}")
public ResponseEntity<Post> getPostBySlug(@PathVariable String slug) {
try {
return ResponseEntity.ok(postService.getPostBySlug(slug));
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
// GET /api/posts/search?keyword=spring&page=0&size=10
@GetMapping("/search")
public ResponseEntity<?> searchPosts(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
try {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return ResponseEntity.ok(postService.searchPosts(keyword, pageable));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
// PUT /api/posts/42
@PutMapping("/{id}")
public ResponseEntity<?> updatePost(
@PathVariable Long id,
@RequestBody Map<String, Object> request) {
try {
String title = (String) request.get("title");
String content = (String) request.get("content");
String excerpt = (String) request.get("excerpt");
Long categoryId = request.get("categoryId") != null
? Long.valueOf(request.get("categoryId").toString()) : null;
Post updated = postService.updatePost(id, title, content, excerpt, categoryId);
return ResponseEntity.ok(updated);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
// PATCH /api/posts/42/publish
@PatchMapping("/{id}/publish")
public ResponseEntity<?> publishPost(@PathVariable Long id) {
boolean published = postService.publishPost(id);
if (published) {
return ResponseEntity.ok(Map.of("message", "Post published successfully"));
}
return ResponseEntity.badRequest()
.body(Map.of("error", "Post not found or already published"));
}
// PATCH /api/posts/42/archive
@PatchMapping("/{id}/archive")
public ResponseEntity<?> archivePost(@PathVariable Long id) {
boolean archived = postService.archivePost(id);
if (archived) {
return ResponseEntity.ok(Map.of("message", "Post archived successfully"));
}
return ResponseEntity.notFound().build();
}
// DELETE /api/posts/42
@DeleteMapping("/{id}")
public ResponseEntity<Void> deletePost(@PathVariable Long id) {
try {
postService.deletePost(id);
return ResponseEntity.noContent().build();
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}
// GET /api/posts/stats
@GetMapping("/stats")
public PostService.PostStats getStats() {
return postService.getStats();
}
}
Step 5: Test the Complete API
# Create posts
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"Spring Data JPA Guide","content":"Complete guide to Spring Data JPA...","excerpt":"Learn JPA","authorId":1,"categoryId":1}'
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"REST API Design","content":"Best practices for REST APIs...","excerpt":"REST tips","authorId":1,"categoryId":1}'
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"Docker Containers","content":"Running apps in Docker...","excerpt":"Docker basics","authorId":1}'
# Publish the first post
curl -X PATCH http://localhost:8080/api/posts/1/publish
# Get paginated post summaries (page 0, 2 per page)
curl "http://localhost:8080/api/posts?page=0&size=2&status=PUBLISHED"
# Search posts with pagination
curl "http://localhost:8080/api/posts/search?keyword=spring&page=0&size=10"
# Get a post by slug
curl http://localhost:8080/api/posts/slug/spring-data-jpa-guide
# Get post statistics
curl http://localhost:8080/api/posts/stats
# Archive a post
curl -X PATCH http://localhost:8080/api/posts/2/archive
# Delete a post
curl -X DELETE http://localhost:8080/api/posts/3
Step 6: Exercises
- Add category CRUD: Build a
CategoryServiceandCategoryControllerwith full CRUD operations, including aGET /api/categories/{slug}endpoint that returns a category with the count of posts in it. - Add user search: In
UserRepository, add a derived query methodfindByUsernameContainingIgnoreCaseOrFullNameContainingIgnoreCaseand expose it as aGET /api/users/search?q=aliceendpoint with pagination. - Add a “recent posts” endpoint: Create
GET /api/posts/recentthat returns the 5 most recently published posts usingfindTop5ByStatusOrderByPublishedAtDesc. - Experiment with SQL logging: Watch the console output for different operations. Compare the SQL generated by
findAll()vsfindByStatus()vssearchByKeyword(). Pay attention to how pagination addsLIMITandOFFSETclauses. - Try the dirty checking feature: In the
updatePostmethod, remove the comment about dirty checking and add a log statement. Verify that the UPDATE SQL is generated even without callingsave().
Summary
This lecture gave you complete mastery over CRUD operations with Spring Data JPA:
- Built-in repository methods:
save()handles both INSERT and UPDATE,findById()returnsOptional,deleteById()loads then deletes,saveAndFlush()executes immediately. - Derived query methods: Spring generates SQL from method names —
findByStatus,findByTitleContainingIgnoreCase,countByStatus,existsBySlug. Powerful for simple queries. - JPQL (@Query): Write queries using entity/field names instead of table/column names. Use
@Paramfor named parameters. Use@Modifyingfor UPDATE/DELETE operations. - Native SQL: Use
nativeQuery = truefor database-specific features. ProvidecountQueryfor pagination support. - Pagination:
Pageable+Pageprovides complete pagination with metadata. UseSlicefor infinite scroll. Support sorting withSort.by(). - Projections: Interface-based projections load only the columns you need. Use for list pages and API responses to avoid loading unnecessary data.
- Auditing:
@CreatedDateand@LastModifiedDatewith@EnableJpaAuditingautomate timestamp management. Use@MappedSuperclassfor shared audit fields. - LazyInitializationException: Caused by accessing lazy-loaded fields after the session closes. Fix with DTOs,
@Transactional,JOIN FETCH, or@EntityGraph. - Dirty checking: Within
@Transactional, Hibernate automatically detects and persists changes to managed entities — no explicitsave()needed for updates.
What is Next
In Lecture 7, we will dive deep into Entity Relationships and Advanced Mapping — @OneToOne, @OneToMany, @ManyToOne, @ManyToMany, cascade operations, fetch strategies, and the notorious N+1 problem with its solutions.
Quick Reference
| Concept | Description |
|---|---|
save(entity) |
INSERT if new (id is null), UPDATE if existing |
saveAndFlush(entity) |
Same as save() but executes SQL immediately |
findById(id) |
Returns Optional — must handle not-found case |
findAll() |
Returns all rows — use pagination for large tables |
deleteById(id) |
Loads entity first, then deletes (enables cascades) |
deleteAllInBatch() |
Single DELETE SQL — faster but skips cascade/callbacks |
existsById(id) |
Efficient existence check (COUNT query) |
| Derived queries | findByFieldCondition — Spring generates SQL from method name |
@Query (JPQL) |
Custom query using entity names (SELECT p FROM Post p) |
@Query (native) |
Raw SQL with nativeQuery = true |
@Modifying |
Required for UPDATE/DELETE @Query methods |
Pageable |
Encapsulates page number, size, and sort |
Page |
Result with data + pagination metadata (totalElements, totalPages) |
Slice |
Lighter than Page — no COUNT query, only knows hasNext |
| Projection | Interface with getters — loads only selected columns |
@CreatedDate |
Auto-sets timestamp on first save |
@LastModifiedDate |
Auto-updates timestamp on every save |
@MappedSuperclass |
Shared fields inherited by child entities |
@EntityGraph |
Declarative eager loading for specific queries |
JOIN FETCH |
JPQL keyword to eagerly load related entities |
| Dirty checking | Hibernate auto-detects changes within @Transactional |
@Transactional(readOnly = true) |
Optimized read-only transaction, keeps session open |
