Repository Pattern — Tìm hiểu sâu
Repository Pattern là gì?
Repository pattern là design pattern trừu tượng hóa truy cập dữ liệu phía sau một interface sạch. Ý tưởng đơn giản: tầng service không nên biết (hoặc quan tâm) dữ liệu đến từ database, REST API, file, hay danh sách trong bộ nhớ. Nó chỉ gọi phương thức trên repository interface.
Hệ thống phân cấp Repository
Repository (interface đánh dấu — không có phương thức)
↓
CrudRepository (CRUD cơ bản: save, findById, findAll, delete, count)
↓
ListCrudRepository (giống CrudRepository nhưng trả List thay vì Iterable)
↓
PagingAndSortingRepository (thêm phân trang và sắp xếp)
↓
JpaRepository (thêm phương thức JPA: flush, saveAndFlush, batch delete)
Luôn extend JpaRepository — nó bao gồm mọi thứ từ các tầng trên, cộng thêm tính năng đặc thù JPA.
Các phương thức có sẵn — save(), findById(), findAll(), deleteById()
save() — Tạo và Cập nhật
Phương thức save() xử lý cả INSERT và UPDATE. Nó kiểm tra entity là mới (id null) hay đã tồn tại (id có giá trị):
@Service
public class UserService {
// TẠO — khi id null, save() sinh INSERT
public User createUser(String username, String email) {
User user = new User(username, email, "tempHash");
// user.getId() là null → Hibernate sinh:
// INSERT INTO users (username, email, ...) VALUES (?, ?, ...)
User saved = userRepository.save(user);
// Sau save(), id được gán bởi database (AUTO_INCREMENT)
System.out.println("Đã tạo user với id: " + saved.getId());
return saved;
}
// CẬP NHẬT — khi id có giá trị, save() sinh UPDATE
public User updateUserBio(Long id, String newBio) {
User user = userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Không tìm thấy user"));
user.setBio(newBio);
// user.getId() KHÔNG null → Hibernate sinh:
// UPDATE users SET username=?, email=?, bio=?, ... WHERE id=?
return userRepository.save(user);
}
}
Chi tiết quan trọng: Khi cập nhật, Hibernate gửi TẤT CẢ cột trong câu UPDATE, không chỉ cột bạn thay đổi.
findById() — Đọc một
public User getUserById(Long id) {
// Sinh: SELECT * FROM users WHERE id = ?
// Trả về Optional<User> — buộc bạn xử lý trường hợp "không tìm thấy"
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Không tìm thấy user id: " + id));
}
deleteById() — Xóa
public void deleteUser(Long id) {
// Sinh: SELECT * FROM users WHERE id = ? (tải entity trước)
// DELETE FROM users WHERE id = ? (rồi xóa)
// Hibernate tải entity trước khi xóa mặc định để cascade và callback hoạt động.
userRepository.deleteById(id);
}
flush() và saveAndFlush()
Hibernate không thực thi SQL ngay lập tức. Nó thu thập thay đổi và ghi vào database vào thời điểm cụ thể. flush() buộc Hibernate ghi thay đổi đang chờ ngay lập tức:
// save() — xếp hàng INSERT nhưng có thể chưa thực thi ngay
User user = userRepository.save(new User("alice", "alice@example.com", "hash"));
// saveAndFlush() — thực thi INSERT ngay lập tức
User user = userRepository.saveAndFlush(new User("alice", "alice@example.com", "hash"));
Derived Query Method — Phép thuật quy ước đặt tên
Đây là một trong những tính năng ấn tượng nhất của Spring Data JPA. Bạn định nghĩa phương thức trong repository interface theo quy ước đặt tên cụ thể, và Spring tự động sinh truy vấn SQL.
Pattern cơ bản
findBy + TênTrường + ĐiềuKiện
Ví dụ cơ bản
public interface UserRepository extends JpaRepository<User, Long> {
// findByEmail → SELECT * FROM users WHERE email = ?
Optional<User> findByEmail(String email);
// findByRole → SELECT * FROM users WHERE role = ?
List<User> findByRole(String role);
// findByActive → SELECT * FROM users WHERE is_active = ?
List<User> findByActive(boolean active);
}
Kết hợp nhiều trường
public interface PostRepository extends JpaRepository<Post, Long> {
// And — kết hợp với AND
// SELECT * FROM posts WHERE status = ? AND author_id = ?
List<Post> findByStatusAndAuthorId(String status, Long authorId);
// Or — kết hợp với OR
List<Post> findByStatusOrStatus(String status1, String status2);
}
Toán tử so sánh
// After — so sánh ngày
// SELECT * FROM posts WHERE created_at > ?
List<Post> findByCreatedAtAfter(LocalDateTime date);
// Between — phạm vi
// SELECT * FROM posts WHERE created_at BETWEEN ? AND ?
List<Post> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end);
Thao tác chuỗi
// Containing → LIKE '%keyword%'
List<Post> findByTitleContaining(String keyword);
// IgnoreCase — không phân biệt hoa thường
List<Post> findByTitleContainingIgnoreCase(String keyword);
// StartingWith → LIKE 'prefix%'
List<Post> findByTitleStartingWith(String prefix);
// Not
List<Post> findByStatusNot(String status);
Thao tác Collection
// In — khớp bất kỳ giá trị nào trong danh sách
// SELECT * FROM posts WHERE status IN (?, ?, ?)
List<Post> findByStatusIn(List<String> statuses);
Kiểm tra Null
// IsNull → WHERE published_at IS NULL
List<Post> findByPublishedAtIsNull();
// IsNotNull → WHERE published_at IS NOT NULL
List<Post> findByPublishedAtIsNotNull();
Sắp xếp và giới hạn kết quả
// OrderBy — sắp xếp
List<Post> findByStatusOrderByCreatedAtDesc(String status);
// Top/First — giới hạn số kết quả
List<Post> findTop5ByOrderByCreatedAtDesc();
// Đếm và kiểm tra tồn tại
long countByStatus(String status);
boolean existsBySlug(String slug);
Khi Derived Query quá dài
Tên phương thức có thể trở nên cồng kềnh:
// Hoạt động nhưng khó đọc và bảo trì
List<Post> findByStatusAndCategoryIdAndTitleContainingIgnoreCaseOrderByCreatedAtDesc(
String status, Long categoryId, String keyword);
Khi tên phương thức dài như vậy, hãy chuyển sang @Query (phần tiếp theo).
@Query với JPQL (Java Persistence Query Language)
Khi derived query trở nên quá phức tạp hoặc bạn cần kiểm soát nhiều hơn, sử dụng @Query với JPQL.
JPQL là gì?
JPQL là ngôn ngữ truy vấn tương tự SQL nhưng hoạt động trên entity thay vì bảng. Thay vì SELECT * FROM posts, bạn viết SELECT p FROM Post p — sử dụng tên class Java và tên trường.
Ví dụ @Query cơ bản
public interface PostRepository extends JpaRepository<Post, Long> {
// JPQL dùng tên entity (Post) và tên trường (title), không phải tên bảng/cột
@Query("SELECT p FROM Post p WHERE p.status = :status ORDER BY p.createdAt DESC")
List<Post> findPublishedPosts(@Param("status") String status);
// Tìm kiếm trong cả tiêu đề và nội dung
@Query("SELECT p FROM Post p WHERE " +
"LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
"LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Post> searchByKeyword(@Param("keyword") String keyword);
}
Truy vấn JOIN trong JPQL
// JOIN sử dụng quan hệ entity — "p.author" theo trường @ManyToOne
@Query("SELECT p FROM Post p JOIN p.author a WHERE a.username = :username")
List<Post> findByAuthorUsername(@Param("username") String username);
Update và Delete với @Query
Cần thêm annotation @Modifying:
// @Modifying cho Spring biết truy vấn này thay đổi dữ liệu
@Modifying
@Transactional
@Query("UPDATE Post p SET p.status = :status WHERE p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") String status);
// Trả về số hàng bị ảnh hưởng
// Cập nhật hàng loạt
@Modifying
@Transactional
@Query("UPDATE Post p SET p.status = 'PUBLISHED', p.publishedAt = CURRENT_TIMESTAMP " +
"WHERE p.author.id = :authorId AND p.status = 'DRAFT'")
int publishAllDraftsByAuthor(@Param("authorId") Long authorId);
Quan trọng: Truy vấn @Modifying bỏ qua cache Hibernate. Thêm clearAutomatically = true để đồng bộ:
@Modifying(clearAutomatically = true)
@Query("UPDATE Post p SET p.status = :status WHERE p.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") String status);
Truy vấn SQL thuần với @Query(nativeQuery = true)
Đôi khi JPQL không đủ — bạn cần tính năng đặc thù database hoặc SQL thô cho hiệu suất.
// SQL thuần — dùng tên bảng và cột, không phải tên entity và trường
@Query(value = "SELECT * FROM posts WHERE status = :status ORDER BY created_at DESC",
nativeQuery = true)
List<Post> findByStatusNative(@Param("status") String status);
// Tìm kiếm FULLTEXT đặc thù MariaDB (không có trong JPQL)
@Query(value = "SELECT * FROM posts WHERE MATCH(title, content) AGAINST(:keyword IN BOOLEAN MODE)",
nativeQuery = true)
List<Post> fullTextSearch(@Param("keyword") String keyword);
Truy vấn native với phân trang
Cần cung cấp count query riêng:
@Query(value = "SELECT * FROM posts WHERE status = :status ORDER BY created_at DESC",
countQuery = "SELECT COUNT(*) FROM posts WHERE status = :status",
nativeQuery = true)
Page<Post> findByStatusNativePaged(@Param("status") String status, Pageable pageable);
JPQL vs Native SQL — Khi nào dùng cái nào?
| Tình huống | Dùng JPQL | Dùng Native SQL |
|---|---|---|
| Truy vấn CRUD đơn giản | ✅ | |
| Truy vấn liên quan quan hệ entity | ✅ | |
| Portable giữa các database | ✅ | |
| Hàm đặc thù database | ✅ | |
| Truy vấn analytics/báo cáo phức tạp | ✅ | |
| Truy vấn hiệu suất critical | ✅ |
Bắt đầu với derived query method. Nếu quá phức tạp, chuyển sang JPQL. Chỉ dùng native SQL khi JPQL không diễn đạt được.
Phân trang và Sắp xếp với Pageable
Tại sao phân trang quan trọng
Tưởng tượng blog có 100,000 bài viết. Gọi findAll() tải tất cả 100,000 vào bộ nhớ — crash ứng dụng ngay lập tức. Phân trang giải quyết bằng cách tải dữ liệu theo từng phần nhỏ (trang).
Interface Pageable
// Tạo Pageable: trang 0 (trang đầu), 10 item/trang, sắp xếp theo createdAt giảm dần
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
// Số trang bắt đầu từ 0:
// Trang 0 = item 1-10
// Trang 1 = item 11-20
// Trang 2 = item 21-30
Sử dụng Pageable trong Repository
public interface PostRepository extends JpaRepository<Post, Long> {
// Derived query với phân trang
Page<Post> findByStatus(String status, Pageable pageable);
// JPQL với phân trang
@Query("SELECT p FROM Post p WHERE p.status = :status AND p.author.active = true")
Page<Post> findPublishedByActiveAuthors(@Param("status") String status, Pageable pageable);
}
Đối tượng Page
Page chứa không chỉ dữ liệu, mà còn metadata về phân trang:
Page<Post> postPage = postRepository.findByStatus("PUBLISHED", pageable);
List<Post> posts = postPage.getContent(); // Dữ liệu thực tế
long totalElements = postPage.getTotalElements(); // Tổng bài viết tất cả trang
int totalPages = postPage.getTotalPages(); // Tổng số trang
int currentPage = postPage.getNumber(); // Số trang hiện tại (từ 0)
boolean isFirst = postPage.isFirst(); // Có phải trang đầu?
boolean isLast = postPage.isLast(); // Có phải trang cuối?
boolean hasNext = postPage.hasNext(); // Có trang tiếp không?
Phân trang trong Controller
// GET /api/posts?page=0&size=10&sort=createdAt&direction=desc
@GetMapping
public Page<Post> getPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
Sort sort = direction.equalsIgnoreCase("asc")
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, size, sort);
return postService.getPostsByPage(pageable);
}
Response JSON bao gồm cả dữ liệu và metadata phân trang:
{
"content": [ ... ],
"totalElements": 45,
"totalPages": 5,
"last": false,
"first": true,
"numberOfElements": 10,
"size": 10,
"number": 0
}
Slice vs Page
Nếu không cần tổng số (đòi hỏi thêm truy vấn COUNT), dùng Slice thay vì Page:
// Slice — KHÔNG thực thi COUNT query (nhanh hơn). Chỉ biết có trang tiếp hay không.
Slice<Post> findByStatus(String status, Pageable pageable);
// Page — thực thi cả SELECT và COUNT. Biết totalElements và totalPages.
Page<Post> findByStatus(String status, Pageable pageable);
Dùng Slice cho UI cuộn vô hạn (chỉ cần “tải thêm”) và Page cho phân trang truyền thống với số trang.
Projection — Trả về dữ liệu một phần
Vấn đề
Khi tải entity Post, Hibernate lấy TẤT CẢ cột — bao gồm content TEXT có thể rất lớn. Cho trang danh sách chỉ cần title, status, và date, tải toàn bộ content là lãng phí.
Interface-Based Projection (Khuyến nghị)
Định nghĩa interface với getter cho các trường bạn cần:
public interface PostSummary {
Long getId();
String getTitle();
String getSlug();
String getStatus();
String getExcerpt();
LocalDateTime getCreatedAt();
}
Sử dụng projection trong repository:
public interface PostRepository extends JpaRepository<Post, Long> {
// Trả PostSummary thay vì Post — chỉ cột được chọn mới được tải
// Hibernate sinh: SELECT id, title, slug, status, excerpt, created_at FROM posts WHERE status = ?
// Lưu ý: "content" KHÔNG được tải!
List<PostSummary> findByStatus(String status);
// Projection hoạt động với phân trang
Page<PostSummary> findByStatus(String status, Pageable pageable);
}
Nested Projection
Có thể bao gồm dữ liệu entity liên quan:
public interface PostDetail {
Long getId();
String getTitle();
String getContent();
AuthorInfo getAuthor(); // Projection lồng nhau
CategoryInfo getCategory();
interface AuthorInfo {
Long getId();
String getUsername();
String getFullName();
}
interface CategoryInfo {
Long getId();
String getName();
}
}
Auditing — @CreatedDate, @LastModifiedDate
Trong Bài 5, chúng ta dùng @PrePersist và @PreUpdate để đặt timestamp thủ công. Spring Data JPA cung cấp cách tiếp cận sạch hơn với auditing annotation.
Thiết lập Auditing
Bước 1: Bật JPA Auditing
@SpringBootApplication
@EnableJpaAuditing // Bật Spring Data JPA auditing
public class BlogApiApplication { ... }
Bước 2: Tạo Base Entity
// @MappedSuperclass nghĩa là: class này không phải entity,
// nhưng trường của nó được kế thừa bởi entity con
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
// @CreatedDate — Spring tự động đặt khi entity được lưu lần đầu
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
// @LastModifiedDate — Spring tự động cập nhật mỗi lần lưu
@LastModifiedDate
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
Bước 3: Extend BaseEntity
@Entity
@Table(name = "users")
public class User extends BaseEntity {
// Không cần trường createdAt/updatedAt hay @PrePersist/@PreUpdate!
// Chúng được kế thừa từ BaseEntity và quản lý tự động.
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
}
Hiểu LazyInitializationException
Đây là lỗi JPA phổ biến nhất mà người mới gặp.
Nguyên nhân
Trong Bài 5, chúng ta định nghĩa @ManyToOne(fetch = FetchType.LAZY):
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
FetchType.LAZY nghĩa là: không tải User khi tải Post. Trường author là proxy — placeholder chỉ tải dữ liệu thật khi bạn truy cập.
Vấn đề phát sinh khi Hibernate session (kết nối database) đã đóng trước khi bạn truy cập trường lazy:
Controller gọi Service.getPost()
→ Service tải Post (session mở)
→ Service trả Post cho Controller (session đóng)
→ Controller trả Post cho Jackson để serialize JSON
→ Jackson gọi post.getAuthor() để serialize trường author
→ Session đã đóng → BÙM! LazyInitializationException
Giải pháp
Giải pháp 1: Dùng DTO (Tốt nhất — Bài 8)
Ánh xạ sang DTO trong tầng service khi session còn mở:
@Transactional(readOnly = true)
public PostDto getPost(Long id) {
Post post = postRepository.findById(id).orElseThrow();
PostDto dto = new PostDto();
dto.setId(post.getId());
dto.setTitle(post.getTitle());
dto.setAuthorName(post.getAuthor().getUsername()); // An toàn — session còn mở
return dto;
}
Giải pháp 2: JOIN FETCH trong JPQL
@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
Optional<Post> findByIdWithAuthor(@Param("id") Long id);
Giải pháp 3: @EntityGraph
@EntityGraph(attributePaths = {"author", "category"})
List<Post> findByStatus(String status);
Nên tắt open-in-view và dùng giải pháp đúng:
spring.jpa.open-in-view=false
Thực hành: Blog CRUD hoàn chỉnh với Phân trang & Tìm kiếm
PostRepository nâng cao
public interface PostRepository extends JpaRepository<Post, Long> {
// Danh sách tóm tắt bài viết phân trang (không tải content)
Page<PostSummary> findByStatus(String status, Pageable pageable);
// Tìm theo slug (cho URL routing)
Optional<Post> findBySlug(String slug);
boolean existsBySlug(String slug);
long countByStatus(String status);
// Tìm kiếm với phân trang
@Query("SELECT p FROM Post p WHERE " +
"LOWER(p.title) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
"LOWER(p.content) LIKE LOWER(CONCAT('%', :keyword, '%'))")
Page<Post> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);
// Tải bài viết với tác giả và danh mục (tránh LazyInitializationException)
@Query("SELECT p FROM Post p JOIN FETCH p.author LEFT JOIN FETCH p.category WHERE p.id = :id")
Optional<Post> findByIdWithDetails(@Param("id") Long id);
// EntityGraph cho danh sách có tác giả
@EntityGraph(attributePaths = {"author"})
@Query("SELECT p FROM Post p WHERE p.status = :status")
Page<Post> findByStatusWithAuthor(@Param("status") String status, Pageable pageable);
// Truy vấn sửa đổi
@Modifying(clearAutomatically = true)
@Transactional
@Query("UPDATE Post p SET p.status = 'PUBLISHED', p.publishedAt = CURRENT_TIMESTAMP WHERE p.id = :id AND p.status = 'DRAFT'")
int publishPost(@Param("id") Long id);
}
PostService với dirty checking
@Service
@Transactional(readOnly = true)
public class PostService {
@Transactional
public PostResponse updatePost(Long id, UpdatePostRequest request) {
Post post = findPostOrThrow(id);
if (request.getTitle() != null) {
post.setTitle(request.getTitle().trim());
post.setSlug(generateUniqueSlug(request.getTitle()));
}
if (request.getContent() != null) {
post.setContent(request.getContent().trim());
}
// Không cần gọi save() tường minh!
// Post là "managed entity" trong phương thức @Transactional.
// Hibernate tự động phát hiện thay đổi và sinh UPDATE
// khi transaction commit. Đây gọi là "dirty checking."
return postMapper.toResponse(post);
}
}
Kiểm thử
# Tạo bài viết
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"Hướng dẫn Spring Data JPA","content":"Hướng dẫn đầy đủ...","authorId":1}'
# Phát hành bài viết
curl -X PATCH http://localhost:8080/api/posts/1/publish
# Lấy danh sách phân trang (trang 0, 2 item/trang)
curl "http://localhost:8080/api/posts?page=0&size=2&status=PUBLISHED"
# Tìm kiếm có phân trang
curl "http://localhost:8080/api/posts/search?keyword=spring&page=0&size=10"
# Thống kê
curl http://localhost:8080/api/posts/stats
Bài tập
- Xây dựng
CategoryServicevàCategoryControllervới CRUD đầy đủ. - Thêm endpoint
GET /api/posts/recenttrả 5 bài mới nhất dùngfindTop5ByStatusOrderByPublishedAtDesc. - Quan sát SQL logging: so sánh SQL sinh bởi
findAll()vsfindByStatus()vssearchByKeyword(). - Thử dirty checking: xác nhận UPDATE SQL được sinh mà không cần gọi
save().
Tổng kết
- Phương thức repository có sẵn:
save()xử lý cả INSERT và UPDATE,findById()trảOptional,deleteById()tải rồi xóa. - Derived query method: Spring sinh SQL từ tên phương thức —
findByStatus,findByTitleContainingIgnoreCase,countByStatus. - JPQL (@Query): Viết truy vấn dùng tên entity/trường. Dùng
@Modifyingcho UPDATE/DELETE. - Native SQL: Dùng
nativeQuery = truecho tính năng đặc thù database. - Phân trang:
Pageable+Pagecung cấp phân trang đầy đủ với metadata. DùngSlicecho cuộn vô hạn. - Projection: Interface-based projection chỉ tải cột cần thiết.
- Auditing:
@CreatedDatevà@LastModifiedDatevới@EnableJpaAuditingtự động hóa quản lý timestamp. - LazyInitializationException: Do truy cập trường lazy sau khi session đóng. Sửa bằng DTO,
JOIN FETCH, hoặc@EntityGraph. - Dirty checking: Trong
@Transactional, Hibernate tự động phát hiện và lưu thay đổi — không cầnsave()tường minh cho update.
Tham chiếu nhanh
| Khái niệm | Mô tả |
|---|---|
save(entity) |
INSERT nếu mới (id null), UPDATE nếu đã tồn tại |
findById(id) |
Trả Optional — phải xử lý trường hợp không tìm thấy |
findAll() |
Trả tất cả hàng — dùng phân trang cho bảng lớn |
| Derived query | findByFieldCondition — Spring sinh SQL từ tên phương thức |
@Query (JPQL) |
Truy vấn tùy chỉnh dùng tên entity |
@Query (native) |
SQL thô với nativeQuery = true |
@Modifying |
Bắt buộc cho phương thức UPDATE/DELETE @Query |
Pageable |
Đóng gói số trang, kích thước, và sắp xếp |
Page |
Kết quả với dữ liệu + metadata phân trang |
| Projection | Interface với getter — chỉ tải cột được chọn |
@CreatedDate |
Tự đặt timestamp khi lưu lần đầu |
@LastModifiedDate |
Tự cập nhật timestamp mỗi lần lưu |
@EntityGraph |
Tải eager khai báo cho truy vấn cụ thể |
JOIN FETCH |
Từ khóa JPQL tải entity liên quan trong một truy vấn |
| Dirty checking | Hibernate tự phát hiện thay đổi trong @Transactional |
