CRUD Operations with Spring Data JPA

Table of Contents

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.

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:

  1. You load an entity in a service method
  2. The method returns the entity to the controller
  3. Jackson tries to serialize the entity to JSON
  4. Jackson accesses a lazy field (post.getAuthor())
  5. 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

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

  1. Add category CRUD: Build a CategoryService and CategoryController with full CRUD operations, including a GET /api/categories/{slug} endpoint that returns a category with the count of posts in it.
  2. Add user search: In UserRepository, add a derived query method findByUsernameContainingIgnoreCaseOrFullNameContainingIgnoreCase and expose it as a GET /api/users/search?q=alice endpoint with pagination.
  3. Add a “recent posts” endpoint: Create GET /api/posts/recent that returns the 5 most recently published posts using findTop5ByStatusOrderByPublishedAtDesc.
  4. Experiment with SQL logging: Watch the console output for different operations. Compare the SQL generated by findAll() vs findByStatus() vs searchByKeyword(). Pay attention to how pagination adds LIMIT and OFFSET clauses.
  5. Try the dirty checking feature: In the updatePost method, remove the comment about dirty checking and add a log statement. Verify that the UPDATE SQL is generated even without calling save().

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() returns Optional, 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 @Param for named parameters. Use @Modifying for UPDATE/DELETE operations.
  • Native SQL: Use nativeQuery = true for database-specific features. Provide countQuery for pagination support.
  • Pagination: Pageable + Page provides complete pagination with metadata. Use Slice for infinite scroll. Support sorting with Sort.by().
  • Projections: Interface-based projections load only the columns you need. Use for list pages and API responses to avoid loading unnecessary data.
  • Auditing: @CreatedDate and @LastModifiedDate with @EnableJpaAuditing automate timestamp management. Use @MappedSuperclass for 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 explicit save() 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

Leave a Reply

Your email address will not be published. Required fields are marked *