Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

spring data jpa

Tung Bui Thanh (TungDaDev)34 mins read

Spring data jpa.webp

# giới thiệu

org.springframework.data.jpa là một lớp abstraction nằm trên JPA (mà implementation phổ biến nhất là Hibernate), giúp bớt hẳn boilerplate ở tầng data access. Thay vì tự viết DAO/Repository, bạn chỉ cần khai báo interface, Spring sẽ sinh implementation lúc khởi động (thực chất là một proxy bọc quanh SimpleJpaRepository).

Code trong bài viết theo Spring Boot 3.x (Jakarta Persistence 3.1, Hibernate 6). Phần lớn vẫn dùng được trên Spring Boot 4 / Spring Data JPA 4.x, chỗ nào khác mình sẽ ghi chú.

# dependency

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
   <groupId>org.postgresql</groupId>
   <artifactId>postgresql</artifactId>
   <scope>runtime</scope>
</dependency>

# cấu hình cơ bản

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/csp_db
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
  jpa:
    hibernate:
      ddl-auto: validate # production: validate hoặc none
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        default_schema: public
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true

# repository hierarchy

Repository<T, ID>                                (marker interface)
 |-- CrudRepository<T, ID>                       (CRUD co ban)
 |    `-- ListCrudRepository<T, ID>              (tra List thay Iterable)
 `-- PagingAndSortingRepository<T, ID>           (phan trang + sap xep)
      `-- ListPagingAndSortingRepository<T, ID>  (tra List)
 
JpaRepository<T, ID> extends ListCrudRepository,
                             ListPagingAndSortingRepository,
                             QueryByExampleExecutor<T>
                             (JPA-specific: flush, batch, Example)
 
JpaSpecificationExecutor<T>   (mixin rieng, KHONG nam duoi JpaRepository)

Từ Spring Data 3.0, PagingAndSortingRepository không còn kế thừa CrudRepository nữa, nó chỉ kế thừa Repository. Còn JpaSpecificationExecutor là một interface độc lập: muốn dùng Specification thì repository phải extends thêm nó (xem phần Specification bên dưới).

# so sánh

InterfacePhương thức chínhKhi nào dùng
CrudRepositorysave, findById, findAll, delete, count, existsByIdCRUD đơn giản
ListCrudRepositoryGiống Crud nhưng trả List (không Iterable)Default choice
PagingAndSortingRepositoryfindAll(Pageable), findAll(Sort)Cần phân trang
JpaRepositoryflush, saveAndFlush, deleteAllInBatch, findAll(Example)Full JPA features
JpaSpecificationExecutorfindAll(Specification), count(Specification)Dynamic queries

# ví dụ cơ bản

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
   // Spring tự generate implementation cho tất cả method của JpaRepository
   // + derived query methods bạn khai báo thêm
}

# entity mapping

# base entity với auditing

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
public abstract class BaseEntity {
 
   @Id
   @GeneratedValue(strategy = GenerationType.UUID)
   private UUID id;
 
   @CreatedDate
   @Column(name = "created_at", nullable = false, updatable = false)
   private LocalDateTime createdAt;
 
   @LastModifiedDate
   @Column(name = "updated_at")
   private LocalDateTime updatedAt;
 
   @CreatedBy
   @Column(name = "created_by", updatable = false)
   private String createdBy;
 
   @LastModifiedBy
   @Column(name = "updated_by")
   private String updatedBy;
 
   @Version  // Optimistic locking
   private Long version;
}

# entity đầy đủ

@Entity
@Table(name = "products", indexes = {
   @Index(name = "idx_product_code", columnList = "code", unique = true),
   @Index(name = "idx_product_category", columnList = "category_id"),
   @Index(name = "idx_product_status_created", columnList = "status, created_at")
})
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Product extends BaseEntity {
 
   @Column(name = "code", nullable = false, unique = true, length = 50)
   private String code;
 
   @Column(name = "name", nullable = false, length = 255)
   private String name;
 
   @Column(name = "description", columnDefinition = "TEXT")
   private String description;
 
   @Column(name = "price", precision = 15, scale = 2)
   private BigDecimal price;
 
   @Enumerated(EnumType.STRING)
   @Column(name = "status", nullable = false, length = 20)
   private ProductStatus status;
 
   @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "category_id", nullable = false)
   private Category category;
 
   @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
   @Builder.Default
   private List<ProductImage> images = new ArrayList<>();
 
   @ManyToMany
   @JoinTable(
       name = "product_tags",
       joinColumns = @JoinColumn(name = "product_id"),
       inverseJoinColumns = @JoinColumn(name = "tag_id")
   )
   @Builder.Default
   private Set<Tag> tags = new HashSet<>();
 
   @ElementCollection
   @CollectionTable(name = "product_attributes", joinColumns = @JoinColumn(name = "product_id"))
   @MapKeyColumn(name = "attr_key")
   @Column(name = "attr_value")
   private Map<String, String> attributes;
 
   // Soft delete
   @Column(name = "deleted_at")
   private LocalDateTime deletedAt;
 
   // Helper methods
   public void addImage(ProductImage image) {
       images.add(image);
       image.setProduct(this);
   }
 
   public void removeImage(ProductImage image) {
       images.remove(image);
       image.setProduct(null);
   }
}

# enum

public enum ProductStatus {
   DRAFT,
   ACTIVE,
   INACTIVE,
   DISCONTINUED
}

# embedded & value objects

@Embeddable
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Money {
   @Column(name = "amount", precision = 15, scale = 2)
   private BigDecimal amount;
 
   @Column(name = "currency", length = 3)
   private String currency;
}
 
@Embeddable
@Data
public class Address {
   private String street;
   private String city;
   private String state;
 
   @Column(name = "zip_code", length = 10)
   private String zipCode;
}
 
@Entity
public class Order extends BaseEntity {
 
   @Embedded
   @AttributeOverrides({
       @AttributeOverride(name = "amount", column = @Column(name = "total_amount")),
       @AttributeOverride(name = "currency", column = @Column(name = "total_currency"))
   })
   private Money totalMoney;
 
   @Embedded
   @AttributeOverrides({
       @AttributeOverride(name = "street", column = @Column(name = "shipping_street")),
       @AttributeOverride(name = "city", column = @Column(name = "shipping_city")),
       @AttributeOverride(name = "state", column = @Column(name = "shipping_state")),
       @AttributeOverride(name = "zipCode", column = @Column(name = "shipping_zip"))
   })
   private Address shippingAddress;
}

# derived query methods (query từ tên method)

Spring Data JPA đọc tên method, tách thành các phần (subject, property, keyword) rồi sinh JPQL tương ứng. Việc này diễn ra lúc khởi động app, nên đặt sai tên property là app fail ngay khi start chứ không đợi tới lúc gọi. Tên method dài quá ba, bốn điều kiện thì nên chuyển sang @Query cho dễ đọc.

# cú pháp

find/read/get/query/search/stream + [Distinct] + By + <Condition> + [OrderBy + <Property> + Asc|Desc]
count + By + <Condition>
exists + By + <Condition>
delete/remove + By + <Condition>

# bảng keywords

KeywordSQLVí dụ method
AndANDfindByNameAndStatus
OrORfindByNameOrCode
Is, Equals=findByStatusIs
Not!=findByStatusNot
BetweenBETWEENfindByPriceBetween
LessThan<findByPriceLessThan
LessThanEqual<=findByPriceLessThanEqual
GreaterThan>findByPriceGreaterThan
GreaterThanEqual>=findByPriceGreaterThanEqual
IsNullIS NULLfindByDeletedAtIsNull
IsNotNullIS NOT NULLfindByDeletedAtIsNotNull
LikeLIKEfindByNameLike
NotLikeNOT LIKEfindByNameNotLike
StartingWithLIKE 'x%'findByNameStartingWith
EndingWithLIKE '%x'findByNameEndingWith
ContainingLIKE '%x%'findByNameContaining
InINfindByStatusIn
NotInNOT INfindByStatusNotIn
True= truefindByActiveTrue
False= falsefindByActiveFalse
OrderByORDER BYfindByStatusOrderByCreatedAtDesc
IgnoreCaseUPPER()findByNameIgnoreCase
Top/FirstLIMITfindTop5ByStatus

# ví dụ đầy đủ

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // === FIND methods ===
 
   // SELECT * FROM products WHERE code = ?
   Optional<Product> findByCode(String code);
 
   // SELECT * FROM products WHERE status = ? AND deleted_at IS NULL
   List<Product> findByStatusAndDeletedAtIsNull(ProductStatus status);
 
   // SELECT * FROM products WHERE name LIKE '%keyword%' (case insensitive)
   List<Product> findByNameContainingIgnoreCase(String keyword);
 
   // SELECT * FROM products WHERE price BETWEEN ? AND ?
   List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);
 
   // SELECT * FROM products WHERE status IN (?, ?)
   List<Product> findByStatusIn(Collection<ProductStatus> statuses);
 
   // SELECT * FROM products WHERE category_id = ? ORDER BY price DESC
   List<Product> findByCategoryIdOrderByPriceDesc(UUID categoryId);
 
   // SELECT * FROM products WHERE status = ? ORDER BY created_at DESC LIMIT 10
   List<Product> findTop10ByStatusOrderByCreatedAtDesc(ProductStatus status);
 
   // SELECT DISTINCT * FROM products WHERE name = ?
   Optional<Product> findDistinctByName(String name);
 
   // === Pagination ===
 
   // SELECT * FROM products WHERE status = ? (with pagination)
   Page<Product> findByStatus(ProductStatus status, Pageable pageable);
 
   // Slice (không chạy câu COUNT, nhẹ hơn Page)
   Slice<Product> findByCategory(Category category, Pageable pageable);
 
   // === COUNT, EXISTS, DELETE ===
 
   // SELECT COUNT(*) FROM products WHERE status = ?
   long countByStatus(ProductStatus status);
 
   // SELECT COUNT(*) > 0 FROM products WHERE code = ?
   boolean existsByCode(String code);
 
   // SELECT COUNT(*) > 0 FROM products WHERE code = ? AND id != ?
   boolean existsByCodeAndIdNot(String code, UUID id);
 
   // Lưu ý: KHÔNG phải một câu DELETE duy nhất. Spring Data SELECT các entity khớp điều kiện
   // rồi gọi EntityManager.remove() cho từng cái (có cascade, có lifecycle callback).
   // Cần chạy trong transaction (@Transactional). Muốn xoá hàng loạt bằng 1 câu thì dùng @Modifying @Query.
   @Transactional
   int deleteByStatusAndDeletedAtBefore(ProductStatus status, LocalDateTime before);
 
   // === Stream (large datasets) ===
   // Phải gọi trong @Transactional và đóng stream (try-with-resources) sau khi dùng.
   // HINT_FETCH_SIZE: static import từ org.hibernate.jpa.HibernateHints
 
   @QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "50"))
   Stream<Product> findByStatusIs(ProductStatus status);
 
   // === Nested property ===
 
   // JOIN category → WHERE category.name = ?
   List<Product> findByCategoryName(String categoryName);
 
   // WHERE category.id = ? AND status = ?
   Page<Product> findByCategoryIdAndStatus(UUID categoryId, ProductStatus status, Pageable pageable);
}

# @Query: jpql & native sql

# jpql (java persistence query language)

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // JPQL cơ bản — tham chiếu entity name, field name (không phải table/column name)
   @Query("SELECT p FROM Product p WHERE p.status = :status AND p.deletedAt IS NULL")
   List<Product> findActiveByStatus(@Param("status") ProductStatus status);
 
   // JOIN FETCH — giải quyết N+1 problem
   @Query("SELECT p FROM Product p JOIN FETCH p.category WHERE p.id = :id")
   Optional<Product> findByIdWithCategory(@Param("id") UUID id);
 
   @Query("SELECT p FROM Product p JOIN FETCH p.category JOIN FETCH p.images WHERE p.id = :id")
   Optional<Product> findByIdWithCategoryAndImages(@Param("id") UUID id);
 
   // Pagination với JPQL (cần countQuery riêng khi có JOIN FETCH)
   @Query(value = "SELECT p FROM Product p JOIN FETCH p.category WHERE p.status = :status",
          countQuery = "SELECT COUNT(p) FROM Product p WHERE p.status = :status")
   Page<Product> findByStatusWithCategory(@Param("status") ProductStatus status, Pageable pageable);
 
   // Search với nhiều điều kiện optional
   @Query("""
       SELECT p FROM Product p
       WHERE (:keyword IS NULL OR LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%')))
       AND (:status IS NULL OR p.status = :status)
       AND (:categoryId IS NULL OR p.category.id = :categoryId)
       AND p.deletedAt IS NULL
       ORDER BY p.createdAt DESC
       """)
   Page<Product> search(
       @Param("keyword") String keyword,
       @Param("status") ProductStatus status,
       @Param("categoryId") UUID categoryId,
       Pageable pageable);
 
   // Aggregate functions
   @Query("SELECT COUNT(p) FROM Product p WHERE p.category.id = :categoryId AND p.status = 'ACTIVE'")
   long countActiveByCategoryId(@Param("categoryId") UUID categoryId);
 
   @Query("SELECT AVG(p.price) FROM Product p WHERE p.category.id = :categoryId")
   BigDecimal getAveragePriceByCategoryId(@Param("categoryId") UUID categoryId);
 
   @Query("SELECT MAX(p.price) FROM Product p WHERE p.status = :status")
   BigDecimal getMaxPrice(@Param("status") ProductStatus status);
 
   // DTO Projection (JPQL constructor expression)
   @Query("""
       SELECT new com.tungdadev.csp.product.dto.ProductSummaryDTO(
           p.id, p.name, p.code, p.price, p.status, p.category.name, p.createdAt
       )
       FROM Product p WHERE p.status = :status
       """)
   Page<ProductSummaryDTO> findSummaryByStatus(@Param("status") ProductStatus status, Pageable pageable);
 
   // IN clause
   @Query("SELECT p FROM Product p WHERE p.id IN :ids AND p.deletedAt IS NULL")
   List<Product> findByIds(@Param("ids") Collection<UUID> ids);
 
   // CASE expression
   @Query("""
       SELECT p.status, COUNT(p)
       FROM Product p
       WHERE p.deletedAt IS NULL
       GROUP BY p.status
       """)
   List<Object[]> countByStatusGrouped();
}

# native sql

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // Native SQL — dùng table/column name thực tế
   @Query(value = """
       SELECT p.* FROM products p
       INNER JOIN categories c ON p.category_id = c.id
       WHERE p.status = :status
       AND c.is_active = true
       AND p.deleted_at IS NULL
       ORDER BY p.created_at DESC
       LIMIT :limit OFFSET :offset
       """, nativeQuery = true)
   List<Product> findActiveProductsNative(
       @Param("status") String status,
       @Param("limit") int limit,
       @Param("offset") int offset);
 
   // Native with pagination
   @Query(value = "SELECT * FROM products WHERE status = :status AND deleted_at IS NULL",
          countQuery = "SELECT COUNT(*) FROM products WHERE status = :status AND deleted_at IS NULL",
          nativeQuery = true)
   Page<Product> findByStatusNative(@Param("status") String status, Pageable pageable);
 
   // Full-text search (PostgreSQL)
   @Query(value = """
       SELECT * FROM products
       WHERE to_tsvector('english', name || ' ' || COALESCE(description, ''))
             @@ plainto_tsquery('english', :query)
       AND deleted_at IS NULL
       ORDER BY ts_rank(to_tsvector('english', name || ' ' || COALESCE(description, '')),
                       plainto_tsquery('english', :query)) DESC
       """, nativeQuery = true)
   List<Product> fullTextSearch(@Param("query") String query);
 
   // JSON operations (PostgreSQL)
   // Giả sử bảng products có cột attributes kiểu jsonb (khác với mapping @ElementCollection ở trên)
   @Query(value = """
       SELECT * FROM products
       WHERE attributes->>'brand' = :brand
       AND deleted_at IS NULL
       """, nativeQuery = true)
   List<Product> findByAttributeBrand(@Param("brand") String brand);
 
   // Bulk operations
   @Modifying
   @Transactional
   @Query(value = """
       UPDATE products SET status = :newStatus, updated_at = NOW()
       WHERE status = :oldStatus AND created_at < :before
       """, nativeQuery = true)
   int bulkUpdateStatus(
       @Param("oldStatus") String oldStatus,
       @Param("newStatus") String newStatus,
       @Param("before") LocalDateTime before);
}

# @Modifying: UPDATE, DELETE queries

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // JPQL update
   @Modifying(clearAutomatically = true, flushAutomatically = true)
   @Transactional
   @Query("UPDATE Product p SET p.status = :status, p.updatedAt = :now WHERE p.id = :id")
   int updateStatus(@Param("id") UUID id, @Param("status") ProductStatus status,
                    @Param("now") LocalDateTime now);
 
   // Soft delete
   @Modifying
   @Transactional
   @Query("UPDATE Product p SET p.deletedAt = :now WHERE p.id = :id AND p.deletedAt IS NULL")
   int softDelete(@Param("id") UUID id, @Param("now") LocalDateTime now);
 
   // Bulk soft delete
   @Modifying
   @Transactional
   @Query("UPDATE Product p SET p.deletedAt = :now WHERE p.id IN :ids AND p.deletedAt IS NULL")
   int softDeleteByIds(@Param("ids") Collection<UUID> ids, @Param("now") LocalDateTime now);
 
   // Hard delete
   @Modifying
   @Transactional
   @Query("DELETE FROM Product p WHERE p.status = :status AND p.deletedAt < :before")
   int purgeOldDeleted(@Param("status") ProductStatus status, @Param("before") LocalDateTime before);
}

Lưu ý @Modifying:

  • clearAutomatically = true: clear persistence context sau khi chạy, để lần đọc sau không lấy nhầm entity cũ còn nằm trong cache cấp 1.
  • flushAutomatically = true: flush những thay đổi đang chờ xuống DB trước khi chạy câu UPDATE/DELETE.
  • Phải chạy trong transaction (@Transactional trên method hoặc ở tầng service gọi nó), không thì Hibernate ném TransactionRequiredException.

Một điều hay bị quên: câu UPDATE/DELETE bằng JPQL đi thẳng xuống DB, bỏ qua persistence context. Nghĩa là nó không tăng @Version, không chạy @PreUpdate, không kích hoạt auditing (updatedAt, updatedBy) và không cascade. Vì vậy ở ví dụ trên updatedAt phải set tay qua tham số :now.

# projections: chỉ lấy fields cần thiết

# interface projection (closed)

// Closed projection: chỉ khai báo getter trùng tên field → Spring Data chỉ SELECT đúng các cột đó
// (áp dụng cho derived query; với @Query thì câu query quyết định cột nào được lấy)
public interface ProductSummaryProjection {
   UUID getId();
   String getName();
   String getCode();
   BigDecimal getPrice();
 
   // Nested projection
   CategoryInfo getCategory();
 
   interface CategoryInfo {
       String getName();
   }
 
   // SpEL expression, computed field
   // Lưu ý: chỉ cần một method @Value là interface trở thành OPEN projection,
   // Spring Data sẽ load nguyên entity rồi mới tính, mất luôn lợi ích tối ưu ở trên.
   // Muốn giữ closed projection thì dùng default method: default String getDisplayName() { ... }
   @Value("#{target.name + ' (' + target.code + ')'}")
   String getDisplayName();
}
 
// Repository
@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   List<ProductSummaryProjection> findByStatus(ProductStatus status);
 
   Page<ProductSummaryProjection> findByStatusAndDeletedAtIsNull(
       ProductStatus status, Pageable pageable);
 
   // Kết hợp @Query
   @Query("SELECT p FROM Product p WHERE p.category.id = :categoryId")
   List<ProductSummaryProjection> findSummaryByCategoryId(@Param("categoryId") UUID categoryId);
}

# class-based projection (dto)

// DTO class — phải có constructor matching
@Data
@AllArgsConstructor
public class ProductSummaryDTO {
   private UUID id;
   private String name;
   private String code;
   private BigDecimal price;
   private ProductStatus status;
   private String categoryName;
   private LocalDateTime createdAt;
}
 
// Repository — dùng với JPQL constructor expression
@Query("""
   SELECT new com.tungdadev.csp.product.dto.ProductSummaryDTO(
       p.id, p.name, p.code, p.price, p.status, c.name, p.createdAt
   )
   FROM Product p JOIN p.category c
   WHERE p.status = :status
   """)
Page<ProductSummaryDTO> findSummaryDTOByStatus(@Param("status") ProductStatus status, Pageable pageable);

# dynamic projections: cùng method, nhiều return types

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // Gọi với type khác nhau → return khác nhau
   <T> List<T> findByStatus(ProductStatus status, Class<T> type);
 
   <T> Optional<T> findById(UUID id, Class<T> type);
}
 
// Usage
List<ProductSummaryProjection> summaries = repo.findByStatus(ACTIVE, ProductSummaryProjection.class);
List<Product> fullEntities = repo.findByStatus(ACTIVE, Product.class);

# specification: dynamic queries (criteria api wrapper)

# khai báo

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID>,
                                          JpaSpecificationExecutor<Product> {
   // JpaSpecificationExecutor cung cấp:
   // findAll(Specification, Pageable)
   // findAll(Specification, Sort)
   // findOne(Specification)
   // count(Specification)
   // exists(Specification)
}

# specification class

public class ProductSpecification {
 
   public static Specification<Product> hasName(String name) {
       return (root, query, cb) -> {
           if (name == null || name.isBlank()) return cb.conjunction(); // no-op
           return cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
       };
   }
 
   public static Specification<Product> hasStatus(ProductStatus status) {
       return (root, query, cb) -> {
           if (status == null) return cb.conjunction();
           return cb.equal(root.get("status"), status);
       };
   }
 
   public static Specification<Product> hasCategoryId(UUID categoryId) {
       return (root, query, cb) -> {
           if (categoryId == null) return cb.conjunction();
           return cb.equal(root.get("category").get("id"), categoryId);
       };
   }
 
   public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
       return (root, query, cb) -> {
           if (min == null && max == null) return cb.conjunction();
           if (min != null && max != null) return cb.between(root.get("price"), min, max);
           if (min != null) return cb.greaterThanOrEqualTo(root.get("price"), min);
           return cb.lessThanOrEqualTo(root.get("price"), max);
       };
   }
 
   public static Specification<Product> createdAfter(LocalDateTime date) {
       return (root, query, cb) -> {
           if (date == null) return cb.conjunction();
           return cb.greaterThanOrEqualTo(root.get("createdAt"), date);
       };
   }
 
   public static Specification<Product> isNotDeleted() {
       return (root, query, cb) -> cb.isNull(root.get("deletedAt"));
   }
 
   // JOIN qua quan hệ many-to-many
   public static Specification<Product> hasTag(String tagName) {
       return (root, query, cb) -> {
           if (tagName == null) return cb.conjunction();
           query.distinct(true); // join collection có thể nhân bản dòng Product
           Join<Product, Tag> tagJoin = root.join("tags", JoinType.INNER);
           return cb.equal(cb.lower(tagJoin.get("name")), tagName.toLowerCase());
       };
   }
 
   // IN clause
   public static Specification<Product> statusIn(Collection<ProductStatus> statuses) {
       return (root, query, cb) -> {
           if (statuses == null || statuses.isEmpty()) return cb.conjunction();
           return root.get("status").in(statuses);
       };
   }
}

# service sử dụng specification

@Service
@RequiredArgsConstructor
public class ProductService {
 
   private final ProductRepository productRepository;
 
   public Page<ProductDTO> search(ProductSearchRequest request, Pageable pageable) {
 
       Specification<Product> spec = Specification
           .where(ProductSpecification.isNotDeleted())
           .and(ProductSpecification.hasName(request.getKeyword()))
           .and(ProductSpecification.hasStatus(request.getStatus()))
           .and(ProductSpecification.hasCategoryId(request.getCategoryId()))
           .and(ProductSpecification.priceBetween(request.getMinPrice(), request.getMaxPrice()))
           .and(ProductSpecification.createdAfter(request.getCreatedAfter()));
 
       return productRepository.findAll(spec, pageable).map(this::toDTO);
   }
}

# SearchRequest dto

@Data
public class ProductSearchRequest {
   private String keyword;
   private ProductStatus status;
   private UUID categoryId;
   private BigDecimal minPrice;
   private BigDecimal maxPrice;
   private LocalDateTime createdAfter;
   private List<ProductStatus> statuses;
}

# pagination & sorting

# pageable: phân trang

// Controller
@GetMapping
public ResponseEntity<APIResponse<Page<ProductDTO>>> list(
       @RequestParam(defaultValue = "0") int page,
       @RequestParam(defaultValue = "20") int size,
       @RequestParam(defaultValue = "createdAt,desc") String[] sort) {
 
   Pageable pageable = PageRequest.of(page, size, parseSort(sort));
   Page<ProductDTO> result = productService.findAll(pageable);
   return ResponseEntity.ok(APIResponse.success(result));
}
 
// Hoặc dùng Spring auto-resolve
@GetMapping
public Page<ProductDTO> list(Pageable pageable) {
   // Auto from: ?page=0&size=20&sort=name,asc&sort=createdAt,desc
   // Từ Spring Data 3.3, trả thẳng PageImpl ra JSON sẽ bị cảnh báo vì cấu trúc JSON không ổn định.
   // Nên bật @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) hoặc trả PagedModel.
   return productService.findAll(pageable);
}
 
// Sort helper
private Sort parseSort(String[] sortParams) {
   List<Sort.Order> orders = new ArrayList<>();
   for (String param : sortParams) {
       String[] parts = param.split(",");
       String property = parts[0];
       Sort.Direction direction = parts.length > 1 && parts[1].equalsIgnoreCase("asc")
           ? Sort.Direction.ASC : Sort.Direction.DESC;
       orders.add(new Sort.Order(direction, property));
   }
   return Sort.by(orders);
}

# sort: sắp xếp

// Các cách tạo Sort
Sort sort = Sort.by("name");                           // ASC
Sort sort = Sort.by(Sort.Direction.DESC, "createdAt"); // DESC
Sort sort = Sort.by("status").ascending()              // Multiple fields
   .and(Sort.by("createdAt").descending());
 
// Sort.Order với null handling
Sort sort = Sort.by(
   Sort.Order.asc("status"),
   Sort.Order.desc("createdAt").nullsLast(),
   Sort.Order.asc("name").ignoreCase()
);
 
// Repository usage
List<Product> products = repo.findByStatus(ProductStatus.ACTIVE, Sort.by("name"));

# page vs slice

// Page — biết total count (thêm 1 query COUNT)
Page<Product> page = repo.findByStatus(ACTIVE, PageRequest.of(0, 20));
page.getContent();        // List<Product>
page.getTotalElements();  // Tổng record (VD: 150)
page.getTotalPages();     // Tổng trang (VD: 8)
page.getNumber();         // Trang hiện tại (0)
page.getSize();           // Kích thước trang (20)
page.hasNext();           // Có trang tiếp?
page.isFirst();           // Là trang đầu?
 
// Slice — KHÔNG biết total count (performance tốt hơn)
Slice<Product> slice = repo.findByCategory(category, PageRequest.of(0, 20));
slice.getContent();       // List<Product>
slice.hasNext();          // Có phần tử tiếp? (Spring lấy dư 1 bản ghi: size + 1)
slice.getNumber();
// KHÔNG có getTotalElements(), getTotalPages()

# cấu hình pageable defaults

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100
        one-indexed-parameters: false # page bắt đầu từ 0
        page-parameter: page
        size-parameter: size
      sort:
        sort-parameter: sort

# auditing: tự động track ai/khi nào thay đổi

# cấu hình

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaAuditingConfig {
 
   @Bean
   public AuditorAware<String> auditorProvider() {
       return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
           .filter(Authentication::isAuthenticated)
           .map(auth -> {
               if (auth.getPrincipal() instanceof Jwt jwt) {
                   return jwt.getClaimAsString("preferred_username");
               }
               return auth.getName();
           });
   }
}

# annotations

AnnotationColumnAuto-fill khi
@CreatedDatecreated_atINSERT
@LastModifiedDateupdated_atINSERT + UPDATE
@CreatedBycreated_byINSERT
@LastModifiedByupdated_byINSERT + UPDATE

# entity callback (thay thế auditing cho logic phức tạp)

Spring Data JPA không hỗ trợ Entity Callback API (BeforeConvertCallback, BeforeSaveCallback...) của Spring Data Commons; API đó dành cho Spring Data JDBC, MongoDB, R2DBC... Với JPA, chỗ tương đương là JPA entity listener (hoặc callback ngay trong entity, xem phần events bên dưới):

public class ProductCodeListener {
 
   @PrePersist
   public void beforeInsert(Product product) {
       if (product.getCode() == null) {
           product.setCode(generateCode());
       }
   }
}
 
@Entity
@EntityListeners(ProductCodeListener.class) // listener của BaseEntity (auditing) vẫn được kế thừa
public class Product extends BaseEntity { ... }

Listener do Hibernate khởi tạo. Từ Spring Boot 2.1 trở đi, Hibernate được cấu hình dùng SpringBeanContainer, nên listener vẫn inject được bean Spring qua constructor nếu cần.

# @Transactional: transaction management

# cơ bản

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)  // Default cho cả class: read-only
public class ProductService {
 
   private final ProductRepository productRepository;
   private final CategoryRepository categoryRepository;
 
   // Read operations — dùng class-level @Transactional(readOnly = true)
   public ProductDTO getById(UUID id) {
       return productRepository.findById(id)
           .map(this::toDTO)
           .orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
   }
 
   // Write operations — override với readOnly = false
   @Transactional  // readOnly = false (default)
   public ProductDTO create(CreateProductRequest request) {
       Category category = categoryRepository.findById(request.getCategoryId())
           .orElseThrow(() -> new EntityNotFoundException("Category not found"));
 
       Product product = Product.builder()
           .code(request.getCode())
           .name(request.getName())
           .price(request.getPrice())
           .category(category)
           .status(ProductStatus.DRAFT)
           .build();
 
       return toDTO(productRepository.save(product));
   }
 
   // Rollback rules
   @Transactional(rollbackFor = Exception.class)  // Rollback cho mọi exception
   public void importProducts(List<CreateProductRequest> requests) {
       requests.forEach(this::create);
   }
 
   // Isolation level
   @Transactional(isolation = Isolation.SERIALIZABLE)
   public void transferStock(UUID fromId, UUID toId, int quantity) {
       // Critical section — needs highest isolation
   }
 
   // Timeout
   @Transactional(timeout = 30)  // 30 giây
   public void longRunningOperation() { ... }
}

# propagation levels

PropagationBehavior
REQUIRED (default)Join existing TX, hoặc tạo mới nếu chưa có
REQUIRES_NEWLuôn tạo TX mới (suspend existing)
NESTEDTạo savepoint trong TX hiện tại
SUPPORTSJoin TX nếu có, không thì chạy không TX
NOT_SUPPORTEDSuspend TX hiện tại, chạy không TX
MANDATORYBắt buộc phải có TX sẵn, exception nếu không
NEVERBắt buộc KHÔNG có TX, exception nếu có
@Service
@RequiredArgsConstructor
public class OrderService {
 
   private final OrderRepository orderRepository;
   private final AuditLogService auditLogService;
 
   @Transactional
   public OrderDTO createOrder(CreateOrderRequest request) {
       Order order = buildOrder(request);
       order = orderRepository.save(order);
 
       // Audit log trong TX riêng — không rollback nếu main TX fail
       auditLogService.logOrderCreated(order.getId());
 
       return toDTO(order);
   }
}
 
@Service
public class AuditLogService {
 
   @Transactional(propagation = Propagation.REQUIRES_NEW)
   public void logOrderCreated(UUID orderId) {
       // TX riêng — commit ngay cả khi caller TX rollback
       auditLogRepository.save(new AuditLog("ORDER_CREATED", orderId));
   }
}

# lưu ý quan trọng về @Transactional

// ❌ SAI — self-invocation bypass proxy
@Service
public class ProductService {
 
   public void doSomething() {
       this.internalMethod(); // KHÔNG qua proxy → @Transactional bị bỏ qua
   }
 
   @Transactional
   public void internalMethod() {
       // Transaction KHÔNG hoạt động khi gọi từ cùng class
   }
}
 
// ✅ ĐÚNG — inject service khác hoặc dùng ApplicationContext
@Service
@RequiredArgsConstructor
public class ProductService {
 
   private final AnotherService anotherService; // Gọi qua bean khác
 
   public void doSomething() {
       anotherService.internalMethod(); // Qua proxy → @Transactional hoạt động
   }
}
 
// ❌ SAI — @Transactional trên private method
@Transactional
private void secretMethod() { } // Proxy không thể override private
// (Từ Spring 6.0, protected và package-private thì được với class-based proxy; private vẫn không)
 
// ❌ SAI — catch exception trong TX method
@Transactional
public void create() {
   try {
       repo.save(entity);
       externalCall(); // throws RuntimeException
   } catch (Exception e) {
       log.error("Error", e); // Swallow exception → Spring không biết có lỗi
       // → TX vẫn COMMIT, entity đã save nằm lại trong DB dù luồng xử lý hỏng giữa chừng
   }
}

Trường hợp cuối hay bị hiểu nhầm. Spring chỉ rollback khi exception bay ra khỏi proxy của method @Transactional. Nuốt exception trong method thì Spring thấy method kết thúc bình thường và commit. UnexpectedRollbackException chỉ xuất hiện ở biến thể khác: exception bay ra từ một method @Transactional khác (gọi qua proxy, cùng transaction với propagation REQUIRED), proxy đó đã đánh dấu transaction là rollback-only, rồi bạn mới catch ở ngoài. Lúc commit, Spring phát hiện cờ rollback-only và ném UnexpectedRollbackException. Muốn vừa catch vừa rollback thì gọi TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() hoặc ném lại exception.

# n+1 problem & solutions

# vấn đề

// Entity
@Entity
public class Product {
   @ManyToOne(fetch = FetchType.LAZY)
   private Category category;
}
 
// Code gây N+1
List<Product> products = productRepository.findAll(); // 1 query
for (Product p : products) {
   p.getCategory().getName(); // N queries (mỗi product 1 query lấy category)
}
// Tổng: 1 + N queries

# solution 1: join fetch (jpql)

@Query("SELECT p FROM Product p JOIN FETCH p.category WHERE p.status = :status")
List<Product> findByStatusWithCategory(@Param("status") ProductStatus status);
// 1 query duy nhất với JOIN

# solution 2: @EntityGraph

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // Attribute paths to eagerly fetch
   @EntityGraph(attributePaths = {"category"})
   List<Product> findByStatus(ProductStatus status);
 
   @EntityGraph(attributePaths = {"category", "images"})
   Optional<Product> findWithDetailsById(UUID id);
 
   // Kết hợp @Query
   // Cẩn thận: fetch collection (tags) + phân trang → Hibernate không LIMIT được ở SQL,
   // nó load hết rồi cắt trang trong memory (log cảnh báo HHH90003004).
   // Với Page, nên chỉ fetch quan hệ *-to-one, collection thì để batch fetch lo.
   @EntityGraph(attributePaths = {"category", "tags"})
   @Query("SELECT p FROM Product p WHERE p.deletedAt IS NULL")
   Page<Product> findAllActive(Pageable pageable);
}

# solution 3: named EntityGraph (trên entity)

@Entity
@NamedEntityGraph(
   name = "Product.withCategoryAndImages",
   attributeNodes = {
       @NamedAttributeNode("category"),
       @NamedAttributeNode("images")
   }
)
@NamedEntityGraph(
   name = "Product.full",
   attributeNodes = {
       @NamedAttributeNode("category"),
       @NamedAttributeNode("images"),
       @NamedAttributeNode(value = "tags")
   }
)
public class Product extends BaseEntity { ... }
 
// Repository
@EntityGraph("Product.withCategoryAndImages")
List<Product> findByStatus(ProductStatus status);

# solution 4: batch size (hibernate config)

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 20
        # Khi access lazy collection → load 20 items per batch
        # Giảm từ N queries → N/20 queries

Hoặc per-entity:

@OneToMany(mappedBy = "product")
@BatchSize(size = 20)
private List<ProductImage> images;

# so sánh solutions

SolutionKhi nào dùngTrade-off
JOIN FETCHBiết chính xác cần gìCartesian product với multiple collections; fetch 2 List cùng lúc sẽ ném MultipleBagFetchException
@EntityGraphFlexible per-queryGiống JOIN FETCH (nhưng là LEFT JOIN) và declarative
Batch SizeMặc định an toànKhông tối ưu bằng JOIN FETCH
DTO ProjectionRead-only, performance criticalMất flexibility

# custom repository implementation

# khi derived queries & @Query không đủ

// 1. Tạo custom interface
public interface ProductRepositoryCustom {
   List<Product> searchWithComplexCriteria(ProductSearchCriteria criteria);
   void bulkUpdatePrices(Map<UUID, BigDecimal> priceMap);
}
 
// 2. Implement (naming: Repository + "Impl")
@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepositoryCustom {
 
   private final EntityManager em;
 
   @Override
   public List<Product> searchWithComplexCriteria(ProductSearchCriteria criteria) {
       CriteriaBuilder cb = em.getCriteriaBuilder();
       CriteriaQuery<Product> cq = cb.createQuery(Product.class);
       Root<Product> root = cq.from(Product.class);
 
       List<Predicate> predicates = new ArrayList<>();
 
       if (criteria.getKeyword() != null) {
           predicates.add(cb.or(
               cb.like(cb.lower(root.get("name")), "%" + criteria.getKeyword().toLowerCase() + "%"),
               cb.like(cb.lower(root.get("code")), "%" + criteria.getKeyword().toLowerCase() + "%")
           ));
       }
 
       if (criteria.getMinPrice() != null) {
           predicates.add(cb.greaterThanOrEqualTo(root.get("price"), criteria.getMinPrice()));
       }
 
       predicates.add(cb.isNull(root.get("deletedAt")));
 
       cq.where(predicates.toArray(new Predicate[0]));
       cq.orderBy(cb.desc(root.get("createdAt")));
 
       return em.createQuery(cq)
           .setMaxResults(criteria.getLimit())
           .getResultList();
   }
 
   @Override
   @Transactional
   public void bulkUpdatePrices(Map<UUID, BigDecimal> priceMap) {
       String sql = "UPDATE products SET price = :price, updated_at = NOW() WHERE id = :id";
       Query query = em.createNativeQuery(sql);
 
       for (Map.Entry<UUID, BigDecimal> entry : priceMap.entrySet()) {
           query.setParameter("id", entry.getKey());
           query.setParameter("price", entry.getValue());
           query.executeUpdate();
       }
 
       em.flush();
       em.clear();
   }
}
 
// 3. Main repository extends cả hai
@Repository
public interface ProductRepository extends JpaRepository<Product, UUID>,
                                          JpaSpecificationExecutor<Product>,
                                          ProductRepositoryCustom {
   // Có tất cả: JPA methods + Specification + Custom methods
}

# query by example (qbe)

// Tạo example entity
Product probe = new Product();
probe.setStatus(ProductStatus.ACTIVE);
probe.setName("Laptop");
 
// ExampleMatcher — cấu hình matching behavior
ExampleMatcher matcher = ExampleMatcher.matching()
   .withIgnoreNullValues()                          // Bỏ qua null fields
   .withMatcher("name", match -> match.contains().ignoreCase()) // LIKE '%laptop%'
   .withIgnorePaths("id", "createdAt", "version");  // Bỏ qua fields này
 
Example<Product> example = Example.of(probe, matcher);
 
// Repository usage
List<Product> results = productRepository.findAll(example);
Page<Product> pagedResults = productRepository.findAll(example, PageRequest.of(0, 20));
long count = productRepository.count(example);
boolean exists = productRepository.exists(example);

# locking strategies

# optimistic locking (@Version)

@Entity
public class Product { // (BaseEntity ở trên đã khai báo sẵn field này, đừng khai báo lại ở subclass)
 
   @Version  // Hibernate tự quản lý, +1 mỗi update
   private Long version;
}
 
// Khi 2 users update cùng lúc → UPDATE ... WHERE id = ? AND version = ? không khớp dòng nào
// → Hibernate ném OptimisticLockException, Spring dịch thành ObjectOptimisticLockingFailureException
@Transactional
public ProductDTO update(UUID id, UpdateProductRequest request) {
   Product product = productRepository.findById(id).orElseThrow();
   product.setName(request.getName());
   return toDTO(product);
   // Câu UPDATE chỉ chạy lúc flush/commit, tức là SAU khi method return.
   // Vì thế try/catch đặt bên trong method này không bắt được lỗi version.
}
 
// Bắt ở tầng gọi (bên ngoài transaction), ví dụ @RestControllerAdvice:
@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<String> handleConflict(OptimisticLockingFailureException e) {
   return ResponseEntity.status(HttpStatus.CONFLICT)
       .body("Product was modified by another user. Please retry.");
}

Thêm một chi tiết: ví dụ trên chỉ phát hiện xung đột xảy ra trong khoảng thời gian từ lúc findById tới lúc commit, vốn rất ngắn. Muốn chặn trường hợp người dùng A mở form, người dùng B sửa xong, rồi A bấm lưu đè lên, thì client phải gửi kèm version đã đọc, và service so sánh nó với version hiện tại trước khi cập nhật.

# pessimistic locking

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // SELECT ... FOR UPDATE (block other transactions)
   @Lock(LockModeType.PESSIMISTIC_WRITE)
   @Query("SELECT p FROM Product p WHERE p.id = :id")
   Optional<Product> findByIdForUpdate(@Param("id") UUID id);
 
   // SELECT ... FOR SHARE (allow reads, block writes)
   @Lock(LockModeType.PESSIMISTIC_READ)
   @Query("SELECT p FROM Product p WHERE p.id = :id")
   Optional<Product> findByIdWithSharedLock(@Param("id") UUID id);
 
   // With timeout. Lưu ý PostgreSQL không có cú pháp "chờ lock N ms" trong câu SELECT:
   // giá trị 0 sẽ thành NOWAIT, còn giá trị khác tuỳ phiên bản Hibernate có thể bị bỏ qua.
   // Muốn chắc chắn trên PostgreSQL thì đặt lock_timeout (vd SET LOCAL lock_timeout = '5s').
   @Lock(LockModeType.PESSIMISTIC_WRITE)
   @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000"))
   @Query("SELECT p FROM Product p WHERE p.id = :id")
   Optional<Product> findByIdForUpdateWithTimeout(@Param("id") UUID id);
}

# khi nào dùng gì

ScenarioLockingLý do
Low contention (ít conflict)OptimisticKhông block, retry khi conflict
High contention (nhiều conflict)PessimisticTránh retry storm
Read-heavyOptimisticReads không bị block
Financial/critical operationsPessimistic WRITEĐảm bảo consistency tuyệt đối
Inventory/stock managementPessimistic WRITETránh overselling

# events & callbacks (entity lifecycle)

# jpa entity callbacks

@Entity
public class Product extends BaseEntity {
 
   @PrePersist
   public void prePersist() {
       if (this.status == null) this.status = ProductStatus.DRAFT;
       if (this.code == null) this.code = generateCode();
   }
 
   @PreUpdate
   public void preUpdate() {
       // updatedAt là field private của BaseEntity nên phải đi qua setter.
       // Nếu đã bật auditing (@LastModifiedDate) thì không cần dòng này.
       setUpdatedAt(LocalDateTime.now());
   }
 
   @PostPersist
   public void postPersist() {
       // Sau khi INSERT thành công (có ID)
       log.info("Product created: {}", this.getId());
   }
 
   @PostLoad
   public void postLoad() {
       // Sau khi load từ DB (computed fields)
   }
 
   @PreRemove
   public void preRemove() {
       // Trước khi DELETE
   }
}

# spring data domain events

@Entity
public class Order extends BaseEntity {
 
   @DomainEvents  // Spring Data tự publish sau save()
   public Collection<Object> domainEvents() {
       List<Object> events = new ArrayList<>();
       if (this.status == OrderStatus.CONFIRMED) {
           events.add(new OrderConfirmedEvent(this.getId(), this.getCustomerId()));
       }
       return events;
   }
 
   @AfterDomainEventPublication  // Cleanup sau khi publish
   public void clearDomainEvents() {
       // Reset state nếu cần
   }
}
 
// Hoặc extend AbstractAggregateRoot (recommended)
@Entity
public class Order extends AbstractAggregateRoot<Order> {
 
   public Order confirm() {
       this.status = OrderStatus.CONFIRMED;
       registerEvent(new OrderConfirmedEvent(this.id, this.customerId));
       return this;
   }
}
 
// Listener
@Component
public class OrderEventListener {
 
   @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
   public void onOrderConfirmed(OrderConfirmedEvent event) {
       // Send notification, update inventory, etc.
       notificationService.notifyCustomer(event.getCustomerId());
   }
}

# soft delete pattern

# cách 1: @Where (hibernate, deprecated trong 6.x, dùng @SQLRestriction)

@Entity
@SQLRestriction("deleted_at IS NULL")  // Hibernate 6.3+
// Hoặc @Where(clause = "deleted_at IS NULL")  // Hibernate < 6.3
public class Product extends BaseEntity {
   private LocalDateTime deletedAt;
}
 
// findAll() tự động thêm WHERE deleted_at IS NULL
// findById() cũng tự động filter

Nhược điểm của cách này là bạn không bỏ được filter khi cần (trang admin xem bản ghi đã xoá, thống kê...) mà phải dùng native query. Từ Hibernate 6.4 còn có @SoftDelete: gọi delete() bình thường thì Hibernate tự chuyển thành UPDATE cột đánh dấu, đọc thì tự lọc. Cột mặc định là boolean deleted; muốn lưu dạng timestamp như deleted_at thì cần Hibernate 7.

# cách 2: manual filter trong repository

@Repository
public interface ProductRepository extends JpaRepository<Product, UUID> {
 
   // Override default methods để thêm soft delete filter
   @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL")
   Optional<Product> findActiveById(@Param("id") UUID id);
 
   @Query("SELECT p FROM Product p WHERE p.deletedAt IS NULL")
   Page<Product> findAllActive(Pageable pageable);
 
   // Soft delete
   @Modifying
   @Transactional
   @Query("UPDATE Product p SET p.deletedAt = :now WHERE p.id = :id")
   int softDelete(@Param("id") UUID id, @Param("now") LocalDateTime now);
 
   // Restore
   @Modifying
   @Transactional
   @Query("UPDATE Product p SET p.deletedAt = NULL WHERE p.id = :id")
   int restore(@Param("id") UUID id);
 
   // Admin: xem cả deleted
   @Query("SELECT p FROM Product p WHERE p.id = :id")
   Optional<Product> findByIdIncludeDeleted(@Param("id") UUID id);
}

# cách 3: base repository với soft delete

@NoRepositoryBean
public interface SoftDeleteRepository<T, ID> extends JpaRepository<T, ID> {
 
   @Query("SELECT e FROM #{#entityName} e WHERE e.deletedAt IS NULL")
   List<T> findAllActive();
 
   @Query("SELECT e FROM #{#entityName} e WHERE e.id = :id AND e.deletedAt IS NULL")
   Optional<T> findActiveById(@Param("id") ID id);
 
   @Modifying
   @Transactional
   @Query("UPDATE #{#entityName} e SET e.deletedAt = CURRENT_TIMESTAMP WHERE e.id = :id")
   int softDelete(@Param("id") ID id);
}
 
// Usage
@Repository
public interface ProductRepository extends SoftDeleteRepository<Product, UUID> {
   // Thừa kế tất cả soft delete methods
}

# performance tips

# batch operations

@Service
@RequiredArgsConstructor
public class ProductBatchService {
 
   private final EntityManager em;
 
   @Transactional
   public void batchInsert(List<Product> products) {
       int batchSize = 50;
       for (int i = 0; i < products.size(); i++) {
           em.persist(products.get(i));
           if ((i + 1) % batchSize == 0) {
               em.flush();  // Gửi batch INSERT về DB
               em.clear();  // Giải phóng memory
           }
       }
       em.flush();
       em.clear();
   }
}

Code trên chỉ thật sự gom batch khi có hibernate.jdbc.batch_size (đã set 50 ở phần cấu hình) và ID không sinh bằng GenerationType.IDENTITY. Với IDENTITY, Hibernate phải INSERT từng dòng ngay để lấy ID về, nên batching insert bị tắt; dùng SEQUENCE hoặc UUID như BaseEntity thì không vướng. Trên PostgreSQL có thể thêm reWriteBatchedInserts=true vào JDBC URL để driver gộp batch thành một câu INSERT nhiều VALUES.

# read-only optimization

// readOnly = true → Hibernate skip dirty checking → faster
@Transactional(readOnly = true)
public List<ProductDTO> findAll() {
   return productRepository.findAll().stream().map(this::toDTO).toList();
}

# dto projection thay vì entity

// ❌ Load full entity chỉ để lấy 3 fields
List<Product> products = repo.findAll(); // Load ALL columns + lazy proxies
products.stream().map(p -> new ProductSummary(p.getId(), p.getName(), p.getPrice()));
 
// ✅ DTO Projection — chỉ SELECT đúng columns cần
@Query("SELECT new ...ProductSummaryDTO(p.id, p.name, p.price) FROM Product p")
List<ProductSummaryDTO> findAllSummary();

# avoid fetching unnecessary data

// ❌ findById khi chỉ cần check exists
Optional<Product> product = repo.findById(id); // Load toàn bộ entity
if (product.isEmpty()) throw new NotFoundException();
 
// ✅ existsById — chỉ SELECT COUNT
if (!repo.existsById(id)) throw new NotFoundException();
 
// ❌ findAll khi chỉ cần count
long count = repo.findByStatus(ACTIVE).size(); // Load tất cả records vào memory
 
// ✅ countBy — chỉ SELECT COUNT
long count = repo.countByStatus(ACTIVE);

# query hints

@QueryHints({
   @QueryHint(name = "org.hibernate.fetchSize", value = "50"),     // JDBC fetch size
   @QueryHint(name = "org.hibernate.readOnly", value = "true"),    // Read-only mode
   @QueryHint(name = "org.hibernate.cacheable", value = "true"),   // Query cache (cần bật hibernate.cache.use_query_cache + 2nd level cache)
   @QueryHint(name = "jakarta.persistence.query.timeout", value = "5000")  // 5s timeout
})
@Query("SELECT p FROM Product p WHERE p.status = :status")
List<Product> findByStatusOptimized(@Param("status") ProductStatus status);

# testing repository

// Spring Boot 4: các annotation này chuyển package, vd org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest
// (module spring-boot-data-jpa-test), AutoConfigureTestDatabase ở org.springframework.boot.jdbc.test.autoconfigure
@DataJpaTest  // Chỉ load JPA layer (Repository + EntityManager + DataSource), mỗi test tự rollback
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Dùng real DB
@Import(JpaAuditingConfig.class)
class ProductRepositoryTest {
 
   @Autowired
   private ProductRepository productRepository;
 
   @Autowired
   private TestEntityManager em;
 
   @Autowired
   private CategoryRepository categoryRepository;
 
   private Category testCategory;
 
   @BeforeEach
   void setUp() {
       testCategory = categoryRepository.save(
           Category.builder().name("Electronics").code("ELEC").build()
       );
   }
 
   @Test
   void findByCode_existingProduct_returnsProduct() {
       // Given
       Product product = Product.builder()
           .code("PRD-001")
           .name("Test Product")
           .price(BigDecimal.valueOf(1000))
           .status(ProductStatus.ACTIVE)
           .category(testCategory)
           .build();
       em.persistAndFlush(product);
 
       // When
       Optional<Product> result = productRepository.findByCode("PRD-001");
 
       // Then
       assertThat(result).isPresent();
       assertThat(result.get().getName()).isEqualTo("Test Product");
   }
 
   @Test
   void findByStatus_withPagination_returnsPage() {
       // Given
       IntStream.rangeClosed(1, 25).forEach(i ->
           em.persist(Product.builder()
               .code("PRD-" + i)
               .name("Product " + i)
               .price(BigDecimal.valueOf(i * 100))
               .status(ProductStatus.ACTIVE)
               .category(testCategory)
               .build())
       );
       em.flush();
 
       // When
       Page<Product> page = productRepository.findByStatus(
           ProductStatus.ACTIVE, PageRequest.of(0, 10, Sort.by("name")));
 
       // Then
       assertThat(page.getContent()).hasSize(10);
       assertThat(page.getTotalElements()).isEqualTo(25);
       assertThat(page.getTotalPages()).isEqualTo(3);
   }
 
   @Test
   void softDelete_setsDeletedAt() {
       // Given
       Product product = em.persistAndFlush(Product.builder()
           .code("DEL-001").name("To Delete")
           .price(BigDecimal.ONE).status(ProductStatus.ACTIVE)
           .category(testCategory).build());
 
       // When
       LocalDateTime now = LocalDateTime.now();
       int affected = productRepository.softDelete(product.getId(), now);
 
       // Then
       assertThat(affected).isEqualTo(1);
       em.clear(); // Clear cache
       // Nếu Product gắn @SQLRestriction("deleted_at IS NULL") (cách 1 ở trên) thì bản ghi đã xoá mềm
       // sẽ bị lọc, khi đó kiểm tra bằng native query thay vì em.find
       Product deleted = em.find(Product.class, product.getId());
       assertThat(deleted.getDeletedAt()).isNotNull();
   }
}

# quick reference: annotations

AnnotationPackageMục đích
@Repositoryspringframework.stereotypeĐánh dấu DAO bean
@Queryspringframework.data.jpaCustom JPQL/SQL query
@Modifyingspringframework.data.jpaUPDATE/DELETE queries
@Paramspringframework.data.repositoryNamed parameter binding
@EntityGraphspringframework.data.jpaEager fetch associations
@Lockspringframework.data.jpaLocking mode
@QueryHintsspringframework.data.jpaHibernate/JPA hints
@Transactionalspringframework.transactionTransaction boundary
@CreatedDatespringframework.data.annotationAuto audit timestamp
@LastModifiedDatespringframework.data.annotationAuto audit timestamp
@CreatedByspringframework.data.annotationAuto audit user
@LastModifiedByspringframework.data.annotationAuto audit user
@EnableJpaAuditingspringframework.data.jpaEnable audit features
@NoRepositoryBeanspringframework.data.repositoryBase repository (no impl)
@Entityjakarta.persistenceJPA entity
@Tablejakarta.persistenceTable mapping
@Idjakarta.persistencePrimary key
@GeneratedValuejakarta.persistenceID generation strategy
@Columnjakarta.persistenceColumn mapping
@Enumeratedjakarta.persistenceEnum persistence type
@ManyToOne / @OneToManyjakarta.persistenceRelationship mapping
@JoinColumnjakarta.persistenceFK column
@Versionjakarta.persistenceOptimistic locking
@PrePersist / @PostPersistjakarta.persistenceEntity lifecycle callback
@Embedded / @Embeddablejakarta.persistenceValue object
@MappedSuperclassjakarta.persistenceBase entity class

# kết luận

Spring Data JPA giúp giảm khá nhiều boilerplate code cho data access layer. Vài nguyên tắc mình hay dùng:

  1. Derived queries cho simple cases, @Query cho complex cases, Specification cho dynamic queries
  2. Luôn dùng FetchType.LAZY cho relationships, khi cần thì fetch tường minh bằng JOIN FETCH hoặc @EntityGraph
  3. DTO Projection cho read-heavy operations, đừng load cả entity khi không cần
  4. @Transactional(readOnly = true) mặc định cho service class, override cho các write methods
  5. Soft delete khi dữ liệu cần giữ lại để tra cứu, audit (nhớ index và lọc deleted_at ở mọi query)
  6. Optimistic locking (@Version) cho hầu hết các case, pessimistic chỉ khi contention cao
  7. Batch operations khi xử lý nhiều records: flush + clear theo batch size
  8. Auditing tự động, không set createdAt/updatedAt thủ công

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.