TungDaDev's Blog

Spring Data JPA

Temp img.png
Published on
/28 mins read/

# giới thiệu

org.springframework.data.jpa cung cấp abstraction layer trên JPA (Hibernate), giúp giảm boilerplate code cho data access layer. Thay vì viết DAO/Repository thủ công, chỉ cần khai báo interface → Spring tự generate implementation.

# 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 cơ bản)
     └── ListCrudRepository<T, ID>       (trả List thay Iterable)
         └── JpaRepository<T, ID>        (JPA-specific: flush, batch, Example)
             └── JpaSpecificationExecutor<T>  (dynamic queries)

PagingAndSortingRepository<T, ID>         (phân trang + sắp xếp)

# 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, deleteInBatch, 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 parse tên method để tự sinh SQL query.

# 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 TOP 10 FROM products WHERE status = ? ORDER BY created_at DESC
   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 count total — performance better)
   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);
 
   // DELETE FROM products WHERE status = ? AND deleted_at < ?
   int deleteByStatusAndDeletedAtBefore(ProductStatus status, LocalDateTime before);
 
   // === Stream (large datasets) ===
 
   @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 vn.com.vpbank.internal.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)
   @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 execute (tránh stale data)
  • flushAutomatically = true: Flush pending changes trước khi execute
  • Phải đi kèm @Transactional

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

# interface projection (closed)

// Chỉ lấy 3 fields thay vì toàn bộ entity → performance tốt hơn
public interface ProductSummaryProjection {
   UUID getId();
   String getName();
   String getCode();
   BigDecimal getPrice();
 
   // Nested projection
   CategoryInfo getCategory();
 
   interface CategoryInfo {
       String getName();
   }
 
   // SpEL expression — computed field
   @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 vn.com.vpbank.internal.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 + subquery
   public static Specification<Product> hasTag(String tagName) {
       return (root, query, cb) -> {
           if (tagName == null) return cb.conjunction();
           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
   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? (query N+1 records)
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)

@Component
public class ProductEntityCallback implements BeforeConvertCallback<Product> {
 
   @Override
   public Product onBeforeConvert(Product product) {
       if (product.getCode() == null) {
           product.setCode(generateCode());
       }
       return product;
   }
}

# @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
 
// ❌ 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 → TX vẫn marked rollback-only
       // Spring sẽ throw UnexpectedRollbackException
   }
}

# 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> {
 
   // Attribut 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
   @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
@EntityGraphFlexible per-queryGiống JOIN FETCH nhưng 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 extends BaseEntity {
 
   @Version  // Hibernate tự quản lý, +1 mỗi update
   private Long version;
}
 
// Khi 2 users update cùng lúc → OptimisticLockException
// Service handle:
@Transactional
public ProductDTO update(UUID id, UpdateProductRequest request) {
   try {
       Product product = productRepository.findById(id).orElseThrow();
       product.setName(request.getName());
       return toDTO(productRepository.save(product));
   } catch (OptimisticLockException e) {
       throw new ConflictException("Product was modified by another user. Please retry.");
   }
}

# 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 (PostgreSQL)
   @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() {
       this.updatedAt = 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

# 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
   @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 > 0 && i % batchSize == 0) {
               em.flush();  // Gửi batch INSERT về DB
               em.clear();  // Giải phóng memory
           }
       }
       em.flush();
       em.clear();
   }
}

# 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"),   // 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

@DataJpaTest  // Chỉ load JPA layer (Repository + EntityManager + DataSource)
@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
       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 là abstraction mạnh giúp giảm đáng kể boilerplate code cho data access layer. Một số nguyên tắc:

  1. Derived queries cho simple cases, @Query cho complex cases, Specification cho dynamic queries
  2. Luôn dùng FetchType.LAZY cho relationships — explicit fetch khi cần bằng JOIN FETCH hoặc @EntityGraph
  3. DTO Projection cho read-heavy operations — không load toàn bộ entity khi không cần
  4. @Transactional(readOnly = true) mặc định cho service class — override cho write methods
  5. Soft delete thay vì hard delete trong production
  6. Optimistic locking (@Version) cho hầu hết cases — 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 😎 👍🏻 🚀 🔥.