Một đoạn code rất quen thuộc trong các dự án Spring: lớp dịch vụ gọi entityManager.createQuery("select o from Order o where ...") ngay giữa logic thanh toán, ở chỗ khác lại có đúng câu truy vấn đó viết lại với một điều kiện lệch đi, và mỗi unit test phải dựng cả cơ sở dữ liệu. Repository pattern sinh ra để dọn đống này: nó đưa quyền truy cập dữ liệu về sau một interface mang ngôn ngữ của nghiệp vụ, để phần còn lại của ứng dụng nhìn các đối tượng như một tập hợp trong bộ nhớ. Đi kèm là Unit of Work, mẫu theo dõi những gì đã đổi trong một giao dịch nghiệp vụ và ghi xuống cơ sở dữ liệu một lần.
Điểm thú vị là JPA đã cài sẵn Unit of Work trong EntityManager, nên hiểu nó sẽ giúp bạn bỏ những lời gọi save() thừa và tránh các lỗi N+1.
Bài này xây một Repository pattern từ interface, cài đặt bằng JPA thuần, đặt ranh giới giao dịch ở tầng dịch vụ, rồi quan sát Unit of Work chạy thật qua log SQL. Sau đó so với Spring Data JPA và bàn khi nào nên (hoặc không nên) bọc thêm một lớp Repository pattern. Code dùng Java 21, Spring Boot 3.3.x, Hibernate 6.5.x với H2 in-memory, chạy trên Apple M4, 16 GB RAM. Bài trước Dependency Injection: tự viết IoC container giải thích vì sao OrderService nhận repository qua constructor; bài này đi tiếp xuống tầng dữ liệu.
Repository pattern là gì và giải quyết vấn đề nào
Martin Fowler mô tả Repository pattern trong Patterns of Enterprise Application Architecture là lớp trung gian giữa miền nghiệp vụ và tầng ánh xạ dữ liệu, hoạt động "như một bộ sưu tập đối tượng miền trong bộ nhớ". Client xây truy vấn một cách khai báo rồi gửi cho Repository pattern; các đối tượng có thể được thêm vào và xoá đi như với một collection đơn giản; phần ánh xạ nằm phía sau. Mẫu này hữu ích khi miền nghiệp vụ phức tạp, có nhiều lớp và nhiều truy vấn, vì nó loại bỏ logic truy vấn trùng lặp và giữ phụ thuộc một chiều từ miền sang tầng dữ liệu (mô tả Repository pattern trong PoEAA catalog).
Trong code, điều này có nghĩa là bạn có hai thứ tách biệt:
- Interface Repository của Repository pattern thuộc về miền:
add,findById,findByStatus... đặt tên theo nhu cầu nghiệp vụ, không để lộEntityManager,Sessionhay SQL. - Cài đặt thuộc về hạ tầng: dùng JPA, JDBC hay bộ nhớ đều được, và có thể thay thế khi test.
Đây cũng là một ứng dụng trực tiếp của nguyên lý đảo ngược phụ thuộc trong SOLID: lớp dịch vụ phụ thuộc vào trừu tượng, còn chi tiết lưu trữ phụ thuộc ngược lại vào trừu tượng.
Unit of Work: theo dõi thay đổi, ghi một lần
Fowler định nghĩa Unit of Work là đối tượng "duy trì danh sách các đối tượng bị ảnh hưởng bởi một giao dịch nghiệp vụ" và điều phối việc ghi thay đổi ra ngoài. Nếu mỗi lần sửa một thuộc tính ta lại ghi xuống DB, sẽ có rất nhiều lời gọi nhỏ và chậm, đồng thời phải giữ giao dịch DB mở suốt cuộc tương tác. Unit of Work ghi nhớ đối tượng mới, đã sửa, đã xoá, rồi đến lúc kết thúc mới tính ra tập câu lệnh cần chạy.
JPA đã làm điều này cho bạn. Tài liệu Hibernate mô tả Session là "đối tượng đơn luồng, sống ngắn, mô hình hoá một Unit of Work", tương ứng với EntityManager của Jakarta Persistence, và nó giữ một persistence context đóng vai first-level cache (Hibernate ORM User Guide, phần Architecture). Ba hệ quả thực tế:
- Đối tượng lấy qua
findhoặc truy vấn là managed: sửa nó là đủ, tới thời điểm flush Hibernate so sánh với bản chụp ban đầu (dirty checking) và sinhUPDATE. - Gọi
findcùng id nhiều lần trong một persistence context chỉ chạy mộtSELECT. - Khi giao dịch rollback, các thay đổi trong bộ nhớ bị bỏ cùng với persistence context.
Vì vậy, EntityManager đã đóng vai Unit of Work, còn Repository pattern là lớp bọc ngôn ngữ nghiệp vụ lên trên. Hai mẫu hợp tác: Repository pattern tìm và thêm đối tượng, Unit of Work lo việc ghi.
Mô hình ví dụ cho Repository pattern: đơn hàng và dòng hàng
Ví dụ nghiệp vụ gần doanh nghiệp: Order gồm khách, trạng thái và nhiều OrderLine. Order có hành vi riêng (pay() kiểm tra trạng thái, total() tính tiền) thay vì chỉ là túi dữ liệu:
@Entity @Table(name = "orders")
public class Order {
public enum Status { NEW, PAID, SHIPPED }
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String customer;
@Enumerated(EnumType.STRING)
private Status status = Status.NEW;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {}
public Order(String customer) { this.customer = customer; }
public Order addLine(String sku, int qty, long price) {
lines.add(new OrderLine(this, sku, qty, price));
return this;
}
public void pay() {
if (status != Status.NEW) throw new IllegalStateException("Chỉ đơn NEW mới thanh toán được");
status = Status.PAID;
}
public long total() { return lines.stream().mapToLong(l -> l.getQty() * l.getPrice()).sum(); }
}Order là aggregate root: bên ngoài chỉ thêm, tìm và xoá Order; OrderLine đi theo nhờ cascade = ALL. Đây là quy tắc quan trọng của Domain-Driven Design: một Repository pattern cho mỗi aggregate root, không phải mỗi bảng. Không có OrderLineRepository.
Cài đặt Repository pattern bằng JPA thuần
Interface nằm ở tầng miền, đặt tên theo nghiệp vụ:
public interface OrderRepository {
Order add(Order order);
Optional<Order> findById(Long id);
List<Order> findByStatus(Order.Status status);
List<Order> findByStatusWithLines(Order.Status status);
void remove(Order order);
}Cài đặt dùng EntityManager được container tiêm vào. Lưu ý hai điểm: không có commit trong Repository, và @Repository bật cơ chế chuyển ngoại lệ của Spring (PersistenceExceptionTranslationPostProcessor) để tầng trên nhận DataAccessException thay vì ngoại lệ riêng của JPA.
@Repository
public class JpaOrderRepository implements OrderRepository {
@PersistenceContext
private EntityManager em;
public Order add(Order order) { em.persist(order); return order; }
public Optional<Order> findById(Long id) { return Optional.ofNullable(em.find(Order.class, id)); }
public List<Order> findByStatus(Order.Status status) {
return em.createQuery("select o from Order o where o.status = :s order by o.id", Order.class)
.setParameter("s", status).getResultList();
}
public List<Order> findByStatusWithLines(Order.Status status) {
return em.createQuery("select distinct o from Order o join fetch o.lines where o.status = :s order by o.id", Order.class)
.setParameter("s", status).getResultList();
}
public void remove(Order order) { em.remove(order); }
}Tầng dịch vụ giữ ranh giới giao dịch. Mỗi phương thức @Transactional là một Unit of Work: mở persistence context, chạy nghiệp vụ, flush và commit khi thoát. Đây là quyết định kiến trúc quan trọng: Repository không commit, vì một nghiệp vụ thường đụng nhiều Repository pattern và phải commit hoặc rollback cùng nhau.
@Service
public class OrderService {
private final OrderRepository orders;
OrderService(OrderRepository orders) { this.orders = orders; }
@Transactional
public void payAndAddGift(Long id) {
Order o = orders.findById(id).orElseThrow();
o.pay(); // chỉ đổi trạng thái đối tượng
o.addLine("GIFT-01", 1, 0); // thêm dòng: cascade lo phần INSERT
System.out.println(">> hết nghiệp vụ, chưa commit. status trong bộ nhớ = " + o.getStatus());
}
@Transactional
public void payThenFail(Long id) {
orders.findById(id).orElseThrow().pay();
throw new IllegalStateException("Cổng thanh toán timeout");
}
}Quan sát Repository pattern và Unit of Work qua log SQL
Lý thuyết dễ gật đầu, nên ta bật logging.level.org.hibernate.SQL=DEBUG và chạy kịch bản. Lệnh chạy: cd code/s6-21 && mvn spring-boot:run -Dspring-boot.run.mainClass=shop.Demo. Đoạn đầu thêm ba đơn trong một giao dịch:
=== 1. Thêm đơn qua Repository (một giao dịch) ===
insert into orders (customer,status,id) values (?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
insert into orders (customer,status,id) values (?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
insert into orders (customer,status,id) values (?,?,default)
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
>> persist xong 3 đơn, chuẩn bị commitĐơn đầu có hai dòng hàng nên có hai câu INSERT order_lines liên tiếp. Một chi tiết cần chú ý: khoá chính kiểu IDENTITY buộc Hibernate chạy INSERT ngay khi persist để lấy id, nên các câu lệnh hiện ra trước dòng "chuẩn bị commit". Với SEQUENCE, Hibernate có thể gom lệnh tới lúc flush và dùng JDBC batching; đó là lý do nhiều dự án tránh IDENTITY khi cần ghi hàng loạt.
Bây giờ đến chỗ trả lời câu hỏi "tại sao không cần save()":
=== 2. Unit of Work: sửa đối tượng, không gọi save() ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> hết nghiệp vụ, chưa commit. status trong bộ nhớ = PAID
insert into order_lines (order_id,price,qty,sku,id) values (?,?,?,?,default)
update orders set customer=?,status=? where id=?
>> đã commitThứ tự dòng chứng minh cơ chế. Trong nghiệp vụ chỉ có đúng một SELECT, rồi dòng "chưa commit" in ra; sau đó mới xuất hiện INSERT cho quà tặng và UPDATE trạng thái, ngay trước dòng "đã commit". Ta không hề gọi save() hay update(): Unit of Work phát hiện status đổi và có OrderLine mới trong collection có cascade, rồi flush cả hai khi giao dịch kết thúc. Với Repository pattern viết tay như trên, hàm add chỉ cần cho đối tượng mới; đối tượng đã tồn tại thì chỉ cần sửa.
=== 3. First-level cache: find cùng id hai lần trong một giao dịch ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> gọi findById 2 lần ở trên, chỉ thấy các SELECT bên trênHai lần findById, một SELECT: lần hai lấy từ persistence context. Đồng thời đây là điều cần cảnh giác: trong một giao dịch, bạn có thể đọc dữ liệu cũ nếu có tiến trình khác vừa sửa DB, vì Hibernate không hỏi lại.
=== 4. Rollback: ngoại lệ làm mất toàn bộ thay đổi ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> bắt: Cổng thanh toán timeout
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.id=?
>> trạng thái đơn 2 sau rollback = NEWpayThenFail đã gọi pay() (đổi sang PAID trong bộ nhớ) rồi ném ngoại lệ. Spring rollback giao dịch, không có UPDATE nào được chạy, và khi đọc lại ở giao dịch mới trạng thái vẫn là NEW. Đó chính là giá trị của việc đặt ranh giới giao dịch ở tầng dịch vụ thay vì trong Repository pattern.
N+1 và join fetch: Repository pattern không che được chi phí truy vấn
Lỗi hiệu năng phổ biến nhất khi dùng Repository pattern với JPA là N+1. findByStatus trả về danh sách Order, còn lines được nạp lười; mỗi lần gọi o.total() chạm vào lines sẽ chạy thêm một SELECT cho từng đơn. Ta so sánh hai phương thức, đếm số câu lệnh bằng Statistics của Hibernate:
=== 5. N+1 so với join fetch (các đơn NEW) ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.status=? order by o1_0.id
select l1_0.order_id,l1_0.id,l1_0.price,l1_0.qty,l1_0.sku from order_lines l1_0 where l1_0.order_id=?
select l1_0.order_id,l1_0.id,l1_0.price,l1_0.qty,l1_0.sku from order_lines l1_0 where l1_0.order_id=?
>> NEW: 2 đơn, tổng 4200000
>> câu lệnh SQL đã chuẩn bị (N+1): 3
select distinct o1_0.id,o1_0.customer,l1_0.order_id,l1_0.id,l1_0.price,l1_0.qty,l1_0.sku,o1_0.status from orders o1_0 join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.status=? order by o1_0.id
>> NEW: 2 đơn, tổng 4200000
>> câu lệnh SQL đã chuẩn bị (join fetch): 1Với 2 đơn, N+1 chạy 3 câu lệnh (1 truy vấn đơn cộng 2 truy vấn dòng hàng), còn join fetch chỉ 1. Số đơn tăng lên N thì phiên bản đầu thành N+1 câu lệnh. Bài học thiết kế: vì mỗi use case cần dữ liệu khác nhau, Repository pattern nên có phương thức đặt tên theo ý định (findByStatusWithLines) thay vì một findAll() dùng cho mọi nơi. Các cách chữa khác gồm @EntityGraph, @BatchSize, hoặc truy vấn dạng projection (DTO) khi chỉ cần đọc. Số 3 và 1 ở trên là kết quả đo thật trên dữ liệu 2 đơn, không phải benchmark thời gian.
Spring Data JPA: Repository pattern do framework sinh ra
Với phần lớn truy vấn đơn giản, viết tay từng phương thức em.createQuery là lặp lại. Spring Data cho phép khai báo interface và tự sinh cài đặt. Theo tài liệu Spring Data Repositories, interface trung tâm là Repository<T, ID> (chủ yếu là marker), CrudRepository thêm save, findById, findAll, delete..., và các interface theo công nghệ như JpaRepository mở rộng chúng. Truy vấn có thể suy ra từ tên phương thức:
public interface SpringDataOrders extends JpaRepository<Order, Long> {
List<Order> findByCustomerOrderById(String customer);
long countByStatus(Order.Status status);
}=== 6. Spring Data JPA: cùng ý tưởng, ít code hơn ===
select o1_0.id,o1_0.customer,o1_0.status from orders o1_0 where o1_0.customer=? order by o1_0.id
>> findByCustomerOrderById: 1 đơn
select count(o1_0.id) from orders o1_0 where o1_0.status=?
>> countByStatus(PAID) = 1Hai phương thức không có dòng cài đặt nào mà vẫn chạy đúng SQL kỳ vọng. Cần hiểu rằng Spring Data không phải một cách khác của Unit of Work: bên dưới vẫn là EntityManager và persistence context như trên, và save() trên một thực thể đang managed không có tác dụng gì thêm (nó chỉ cần cho thực thể mới hoặc detached). Đây là nguồn gốc của thói quen "gọi save() sau mỗi lần sửa" thừa mà nhiều nhóm mang theo.
Nên bọc Spring Data bằng interface Repository pattern riêng không
Đây là câu hỏi gây tranh luận. Có hai trường phái, và cả hai đều có cơ sở:
| Cách làm | Khi hợp lý | Chi phí |
|---|---|---|
Dùng thẳng JpaRepository trong dịch vụ | Ứng dụng CRUD, miền đơn giản, nhóm chấp nhận gắn với Spring Data | Lớp dịch vụ thấy save, Pageable, kiểu trả về của Spring; test cần context hoặc mock framework |
| Interface Repository của miền, cài đặt bằng Spring Data hoặc JPA | Miền phức tạp, muốn test nhanh bằng bản giả trong bộ nhớ, cần giấu công nghệ lưu trữ | Thêm một lớp ánh xạ giữa interface miền và JpaRepository; dễ thành lớp chuyển tiếp vô nghĩa |
Quy tắc kinh nghiệm: nếu interface Repository pattern của bạn chỉ lặp lại đúng từng phương thức của JpaRepository thì nó chưa mang giá trị gì; interface chỉ xứng đáng khi phương thức phản ánh nhu cầu nghiệp vụ (findOverduePayments, findByStatusWithLines) và che đi chi tiết truy vấn.
Khi chọn cách thứ hai, phần cài đặt có thể uỷ quyền cho SpringDataOrders bên trong, còn bản giả cho test chỉ là một HashMap (đoạn minh hoạ dưới đây là phác thảo, không nằm trong lần chạy của bài):
class InMemoryOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
public Optional<Order> findById(Long id) { return Optional.ofNullable(store.get(id)); }
public List<Order> findByStatus(Order.Status s) {
return store.values().stream().filter(o -> o.getStatus() == s).toList();
}
// add, remove, findByStatusWithLines tương tự
}Cẩn thận với bản giả: nó không có dirty checking, first-level cache hay lazy loading, nên test chạy xanh với bản giả vẫn có thể sai với JPA thật. Hãy giữ một nhóm test tích hợp chạy với H2 hoặc Testcontainers cho đường đi quan trọng.
Khi nào không nên dùng Repository pattern
- Báo cáo và truy vấn đọc phức tạp: nhiều bảng, nhóm, cửa sổ phân tích. Repository pattern trả về aggregate không hợp; hãy dùng một lớp truy vấn riêng (CQRS nhẹ) trả DTO hoặc dùng JdbcTemplate, jOOQ.
- Ứng dụng CRUD thuần: màn hình quản trị chỉ lưu và hiển thị, thêm một lớp trừu tượng chỉ làm tăng số tệp.
- Repository pattern tổng quát
GenericRepository<T>vớifindAll(Specification)cho mọi thứ: biến Repository pattern thành vỏ mỏng bọc ORM, lộ chi tiết truy vấn ra khắp nơi. Xem hướng tiếp cận có kiểm soát ở bài sau về Specification. - Lưu trữ không phải quan hệ: với cache hay hàng đợi, khái niệm "tập hợp đối tượng" có thể không khớp.
Lỗi thường gặp
1. Gọi save() và flush() khắp nơi. Kết quả là mất lợi ích của Unit of Work: nhiều câu lệnh nhỏ, khó dự đoán thời điểm ghi. Tin vào dirty checking, chỉ flush thủ công khi có lý do (ví dụ cần id hoặc bắt ràng buộc sớm).
2. Repository pattern tự mở giao dịch hoặc commit. Khi hai Repository mỗi bên commit riêng, lỗi giữa chừng để lại dữ liệu nửa vời. Giao dịch thuộc tầng dịch vụ.
3. Trả entity ra tầng web và gặp LazyInitializationException. Persistence context đóng khi giao dịch kết thúc, nên truy cập lines ở controller sẽ ném lỗi. Trong ví dụ ta tắt open-in-view để lỗi lộ ra sớm. Cách đúng là ánh xạ sang DTO trong dịch vụ, hoặc nạp sẵn bằng join fetch.
4. Một Repository pattern cho mỗi bảng. Dẫn tới nghiệp vụ nằm rải rác, aggregate bị phá vỡ. Giữ một Repository pattern cho mỗi aggregate root.
5. Quên rằng persistence context là cache. Truy vấn bằng JPQL sau khi sửa bằng câu lệnh UPDATE bulk sẽ trả về thực thể cũ trong context. Sau bulk update cần clear() hoặc @Modifying(clearAutomatically = true).
6. Cho Repository pattern trả null. Dùng Optional (như findById ở trên) để buộc bên gọi xử lý trường hợp không tìm thấy.
Checklist Repository pattern và Unit of Work
- Một Repository pattern cho mỗi aggregate root; interface đặt tên theo nghiệp vụ.
- Interface thuộc miền, cài đặt thuộc hạ tầng; lớp dịch vụ chỉ biết interface.
- Ranh giới giao dịch ở tầng dịch vụ (
@Transactional), Repository pattern không commit. - Sửa đối tượng managed là đủ: không cần
save()sau mỗi thay đổi. - Mỗi use case có phương thức đọc phù hợp để tránh N+1 (
join fetch,@EntityGraph, DTO). - Báo cáo phức tạp dùng đường truy vấn riêng, không ép qua Repository pattern.
- Giữ test tích hợp với CSDL thật hoặc H2 song song với bản giả trong bộ nhớ.
Mã nguồn đầy đủ và run_output.txt nằm trong code/s6-21/. Muốn đọc nguồn gốc khái niệm, xem các mục Repository pattern và Unit of Work trong catalog của Fowler đã dẫn ở trên, cùng phần Architecture của Hibernate User Guide về Session như một Unit of Work.
Bài tiếp theo
Repository pattern tốt cần trả lời nhiều kiểu truy vấn mà không phình thành hàng chục phương thức findByA...AndB.... Bài kế tiếp: Specification pattern: truy vấn động với Spring Data JPA, nơi các điều kiện nghiệp vụ được đóng gói thành đối tượng có thể ghép lại.



