Bảy phần trước của khóa học, mỗi phần dạy một thứ tại một thời điểm. Phần này thì không. Nó dựng một application duy nhất — REST API quản lý bán hàng với product, customer và order — và mọi quyết định trong đó đều là quyết định mà các phần trước đã lập luận cho.
Đây là mở đầu Phần 8, phần cuối của khóa học: đem tất cả ra dùng. Bài này là một nhật ký thi công chứ không phải bài giảng về một khái niệm. Code compile được, application khởi động được, và mọi câu curl cùng mọi response bên dưới đều được chụp lại từ chính nó đang chạy trên localhost:18096.
![]()
Thứ tự bên dưới đúng bằng thứ tự bạn sẽ dựng nó: hệ thống làm gì, domain model, cấu trúc thư mục, entity, repository, service, controller, xử lý lỗi, rồi một phiên chạy dài trên application thật.
Project này là gì, và nó làm gì khi chạy
Một sales API với bốn entity và mười bốn route. Product và customer có CRUD bình thường. Order có một endpoint tạo mới nhận customer id kèm danh sách SKU, cộng thêm hai chuyển trạng thái: thanh toán và hủy. Rule đủ nhỏ để viết trong bốn dòng, và đủ khó để đáng viết cho cẩn thận:
- Order fail nếu có SKU nào không tồn tại (404) hoặc stock không đủ (409).
- Thanh toán trừ stock cho mọi item trong một transaction, và bị từ chối nếu order không ở trạng thái
NEW(409). - Hủy một order
PAIDthì trả stock lại. - Order ghi lại giá tại thời điểm đặt, nên đổi giá về sau không làm nó thay đổi.
Đây là hình dạng của nó, trước khi nhìn bất kỳ dòng code nào. Ba request: tạo order, thanh toán, rồi xem stock. Đây là capture thật; mỗi response bên dưới hiển thị status line, header Location khi có, và Content-Type, các header còn lại đã bị lược bỏ:
$ curl -s -i -X POST http://localhost:18096/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":1,"lines":[{"sku":"KB-01","quantity":2},{"sku":"MS-02","quantity":1}]}'
HTTP/1.1 201
Location: /api/orders/1
Content-Type: application/json
{
"id": 1,
"customerId": 1,
"customerName": "Nguyễn Văn An",
"status": "NEW",
"createdAt": "2026-09-10T10:12:26.219193Z",
"items": [
{
"sku": "KB-01",
"name": "Bàn phím cơ",
"quantity": 2,
"unitPriceCents": 8900,
"lineTotalCents": 17800
},
{
"sku": "MS-02",
"name": "Chuột không dây",
"quantity": 1,
"unitPriceCents": 3450,
"lineTotalCents": 3450
}
],
"totalCents": 21250
}$ curl -s -i -X POST http://localhost:18096/api/orders/1/pay
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"customerId": 1,
"customerName": "Nguyễn Văn An",
"status": "PAID",
"createdAt": "2026-09-10T10:12:26.219193Z",
"items": [ ... ],
"totalCents": 21250
}$ curl -s -i http://localhost:18096/api/products
HTTP/1.1 200
Content-Type: application/json
[
{
"id": 1,
"sku": "KB-01",
"name": "Bàn phím cơ",
"priceCents": 10900,
"stock": 10
},
{
"id": 2,
"sku": "MS-02",
"name": "Chuột không dây",
"priceCents": 3450,
"stock": 4
},
{
"id": 3,
"sku": "HS-03",
"name": "Tai nghe",
"priceCents": 12500,
"stock": 3
}
]Stock đi từ 12 xuống 10 và từ 5 xuống 4 vì order đã được thanh toán, không phải vì nó được tạo. Đúng sự phân biệt đó là toàn bộ thiết kế, và phần còn lại của bài này là cách nó được thực thi.
Mọi thứ ở đây chạy trên OpenJDK 21.0.6 (arm64) với Spring Boot 4.1.1, Spring Framework 7.0.9, Hibernate ORM 7.4.5.Final, Hibernate Validator 9.1.3.Final, Jakarta Persistence 3.2.0, Jackson 3.1.5, embedded Tomcat 11.0.24 và H2 2.4.240, build bằng Maven wrapper trên Maven 3.9.16. Dòng Started SalesApplication in ... seconds của Spring Boot và các dòng đo thời gian của Hibernate đã bị xóa khỏi mọi transcript: chúng đo một cái laptop, mà bài này không tranh luận gì về tốc độ. Log hiển thị không kèm cột timestamp, PID và thread; SQL đã format của Hibernate và mọi dòng log quá dài được ngắt lại ở những chỗ được trích; và một mảng items dài không đổi trong response lặp lại được rút gọn thành [ ... ]. Ngoài ra không sửa gì.
Domain model
Bốn entity, ba relationship, và một field hành xử khác hẳn phần còn lại.

Hãy đọc mũi tên trước. Ba trong số các relationship là @ManyToOne, và mỗi cái đó là owning side: table bên dưới nó giữ column foreign key. orders giữ customer_id, order_items giữ order_id và product_id. Riêng @OneToMany trên Order.items là inverse side. Nó không sở hữu gì cả; nó là cách nhìn cùng một foreign key từ hướng ngược lại, và đó là lý do nó cần mappedBy = "order" để nói nó đang đọc column nào.
Làm ngược chỗ này là bug mapping kinh điển: bỏ mappedBy đi thì JPA coi hai bên là hai relationship riêng biệt, và tự chế ra một join table cho collection. Schema ở mục sau chính là bằng chứng rằng bản này không bị vậy.
Field đáng tranh luận là OrderItem.unitPriceCents. Nó nhân bản Product.priceCents ngay tại thời điểm order được đặt, và sau đó hai giá trị này không liên quan gì đến nhau nữa.
Cấu trúc project, và một câu lệnh tạo ra nó
Tạo project trên Spring Boot 4.1.1
Không cần cài Maven hay Gradle trên máy — Spring Initializr đóng gói sẵn wrapper trong file zip:
curl -sS -o d.zip "https://start.spring.io/starter.zip?type=maven-project&language=java&bootVersion=4.1.1.RELEASE&javaVersion=21&groupId=com.example&artifactId=sales&dependencies=web,data-jpa,h2,validation"
unzip -q d.zip -d sales
cd sales
./mvnw -B -DskipTests package
java -jar target/sales-0.0.1-SNAPSHOT.jar --server.port=18096 --spring.output.ansi.enabled=NEVERCó hai điều nên biết trước khi chạy câu lệnh đó.
API của Initializr định danh version là 4.1.1.RELEASE, và chuỗi đó đi thẳng vào parent version của POM sinh ra — nhưng artifact công bố trên Maven Central lại là 4.1.1 trơn. Build fail ngay lập tức với Could not find artifact org.springframework.boot:spring-boot-starter-parent:pom:4.1.1.RELEASE. Cách sửa là một dòng trên POM vừa sinh:
sed -i '' 's|<version>4.1.1.RELEASE</version>|<version>4.1.1</version>|' pom.xmlĐiều thứ hai là dependency id web không còn ra spring-boot-starter-web nữa. Trên Boot 4 nó cho ra spring-boot-starter-webmvc, và POM của artifact cũ nay tự ghi rằng nó đã deprecated để nhường chỗ. Khối dependency sinh ra, nguyên văn:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>Bốn dependency, và đó là toàn bộ file build ngoài parent với plugin. Jackson vào theo dạng transitive cùng web starter, và trên Boot 4 nó là Jackson 3 dưới package tools.jackson chứ không phải com.fasterxml.jackson của Jackson 2.
Toàn bộ application.properties:
spring.application.name=sales
spring.datasource.url=jdbc:h2:mem:sales;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.open-in-view=falseshow-sql và format_sql bật vì bài này trích SQL. open-in-view=false bật vì nó đáng phải như vậy: khi bật open-in-view, Boot giữ một persistence context mở suốt cả request, che luôn các lỗi lazy loading trong controller thay vì phơi chúng ra. Tắt nó đi chính là thứ buộc query repository ở mục sau phải nạp đúng những gì response cần.
Package layout
src/main/java/com/example/sales/
├── SalesApplication.java
├── domain/
│ ├── Customer.java
│ ├── Order.java
│ ├── OrderItem.java
│ ├── OrderStatus.java
│ └── Product.java
├── error/
│ ├── ConflictException.java
│ └── NotFoundException.java
├── repository/
│ ├── CustomerRepository.java
│ ├── OrderRepository.java
│ └── ProductRepository.java
├── service/
│ ├── CustomerService.java
│ ├── OrderService.java
│ └── ProductService.java
└── web/
├── ApiExceptionHandler.java
├── CustomerController.java
├── OrderController.java
├── ProductController.java
└── dto/
├── CreateOrderRequest.java
├── CustomerRequest.java
├── CustomerResponse.java
├── OrderItemResponse.java
├── OrderResponse.java
├── ProductRequest.java
└── ProductResponse.javaHai mươi lăm file source. Tên package chính là tên layer, nên một import đặt sai chỗ sẽ lộ ra ngay trong diff: không có gì trong domain import từ web, không có gì trong service import từ web ngoại trừ đúng một record request mà nó nhận, và không có gì ngoài web nhắc tới HTTP.
Các entity
Product và Customer
Cả hai đều phẳng. Điểm duy nhất đáng chỉ ra là constraint nằm ngay trong mapping chứ không chỉ trong service:
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 40)
private String sku;
@Column(nullable = false, length = 120)
private String name;
@Column(name = "price_cents", nullable = false)
private int priceCents;
@Column(nullable = false)
private int stock;
protected Product() {
}
public Product(String sku, String name, int priceCents, int stock) {
this.sku = sku;
this.name = name;
this.priceCents = priceCents;
this.stock = stock;
}
// getter và setter
}priceCents là int đếm đơn vị nhỏ nhất, không phải double và cũng không phải BigDecimal. Tiền trong floating point sai vì đúng cái lý do nó luôn sai, còn BigDecimal ở đây không mua được gì vì API này không có phần lẻ của cent và không quy đổi tiền tệ. Một số nguyên đếm cent thì chính xác, cộng lại vẫn chính xác, và serialize thành số JSON trơn không kèm quyết định định dạng nào để mà làm sai.
Customer cùng hình dạng đó với name và email unique.
Order và OrderItem
Đây mới là chỗ mapping có ý nghĩa:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public Order(Customer customer, Instant createdAt) {
this.customer = customer;
this.createdAt = createdAt;
this.status = OrderStatus.NEW;
}
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public int totalCents() {
return items.stream().mapToInt(OrderItem::lineTotalCents).sum();
}
// getter, và setter chỉ cho status
}Bốn quyết định trong class đó, cả bốn đều xuất hiện lại về sau:
| Quyết định | Lý do |
|---|---|
@Enumerated(EnumType.STRING) | Mặc định là ORDINAL, lưu 0, 1, 2. Chèn thêm một constant vào giữa enum là mọi row đang có lặng lẽ đổi nghĩa |
cascade = CascadeType.ALL trên items | Save order là save luôn các dòng của nó. Order là aggregate root; một OrderItem không có đời sống riêng |
addItem set cả hai phía | Chỉ items.add(item) thì item.order vẫn null, và câu insert fail vì column not null |
totalCents() được tính, không lưu | Total lưu sẵn là nguồn sự thật thứ hai, và nó có thể mâu thuẫn với các dòng |
OrderItem mang hai foreign key và giá đã copy:
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private int quantity;
@Column(name = "unit_price_cents", nullable = false)
private int unitPriceCents;
public OrderItem(Product product, int quantity, int unitPriceCents) {
this.product = product;
this.quantity = quantity;
this.unitPriceCents = unitPriceCents;
}
public int lineTotalCents() {
return quantity * unitPriceCents;
}
}setOrder để package-private là có chủ ý. Cách duy nhất đúng để gắn một dòng vào order là order.addItem(item), hàm này set cả hai phía; để setter public là mời gọi phiên bản gắn nửa vời.
Vì sao unitPriceCents được copy chứ không đọc trực tiếp
Model hiển nhiên là không cần field đó. Một OrderItem vốn đã trỏ tới Product, mà Product vốn đã có priceCents — nên cứ tính line total bằng quantity * product.getPriceCents() và không lưu gì cả.
Model đó sai, và nó sai theo kiểu chỉ lộ ra trên production, vài tháng sau, khi có người tăng giá. Mọi order trong quá khứ lặng lẽ đổi theo. Email xác nhận của khách, hóa đơn họ đã in, và con số API của bạn trả về cho cùng một order id thôi khớp nhau. Không có gì ném exception, không có gì vào log, và database bây giờ đang kể một câu chuyện khác hôm qua.
Nguyên tắc bên dưới: bản ghi về một việc đã xảy ra không được phụ thuộc vào dữ liệu vẫn còn được phép thay đổi. Giá trên một dòng order không phải là sự thật về product, nó là sự thật về giao dịch. Nó được copy đúng một lần, tại thời điểm OrderService.create chạy, và sau đó không bao giờ đọc lại từ product nữa.
Cái giá phải trả là một column bị denormalise cộng với kỷ luật đừng "sửa" nó. Phần chứng minh nằm ở phiên chạy bên dưới — đổi giá, đọc lại order, và nhìn total đứng yên.
Schema mà Hibernate sinh ra
Với ddl-auto=create-drop và format_sql bật, các table Hibernate dựng lúc khởi động nằm nguyên trong log:
create table customers (
id bigint generated by default as identity,
name varchar(120) not null,
email varchar(160) not null unique,
primary key (id)
)
create table order_items (
quantity integer not null,
unit_price_cents integer not null,
id bigint generated by default as identity,
order_id bigint not null,
product_id bigint not null,
primary key (id)
)
create table orders (
created_at timestamp(6) with time zone not null,
customer_id bigint not null,
id bigint generated by default as identity,
status enum ('CANCELLED','NEW','PAID') not null,
primary key (id)
)
create table products (
price_cents integer not null,
stock integer not null,
id bigint generated by default as identity,
sku varchar(40) not null unique,
name varchar(120) not null,
primary key (id)
)Và ba foreign key:
alter table if exists order_items
add constraint FKbioxgbv59vetrxe0ejfubep1w
foreign key (order_id)
references orders
alter table if exists order_items
add constraint FKocimc7dtr037rh4ls4l95nlfi
foreign key (product_id)
references products
alter table if exists orders
add constraint FKpxtb8awmi0dk6smoh2vp1litg
foreign key (customer_id)
references customersBa table mang foreign key và không có join table nào cả — đó chính là xác nhận rằng mappedBy đã làm đúng việc của nó. Còn hai chi tiết nữa đáng đọc ra từ output này: status thành enum của H2 chứa ba tên constant chứ không phải số nguyên, nhờ EnumType.STRING; và created_at thành timestamp(6) with time zone, vì field là Instant chứ không phải LocalDateTime. LocalDateTime sẽ cho ra một column không có zone, tức một cái bug đang chờ server đầu tiên đặt ở múi giờ khác.
create-drop đúng cho demo và sai cho mọi thứ khác. Hệ thống thật dùng validate cộng một công cụ migration.
Các repository
Ba interface và không implementation nào. Hai cái đầu chỉ có derived query; cái thứ ba mới cần một query viết ra:
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
boolean existsBySku(String sku);
}
public interface CustomerRepository extends JpaRepository<Customer, Long> {
boolean existsByEmail(String email);
}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
select o from Order o
join fetch o.customer
left join fetch o.items i
left join fetch i.product
where o.id = :id
""")
Optional<Order> findByIdWithItems(Long id);
}findByIdWithItems là method duy nhất ở đây không hiển nhiên, và nó được annotate vì hai lý do tách biệt.
Thứ nhất là cái tên không parse được. Spring Data đọc tên derived method từ trái sang phải, nên findByIdWithItems thành "find by property id.withItems", mà property đó không tồn tại. Bỏ @Query đi là fail ngay lúc khởi động chứ không phải lúc gọi:
Caused by: org.springframework.data.repository.query.QueryCreationException:
Cannot create query for method [OrderRepository.findByIdWithItems(java.lang.Long)];
No property 'withItems' found for type 'Long'; Traversed path: Order.id
Caused by: org.springframework.data.core.PropertyReferenceException:
No property 'withItems' found for type 'Long'; Traversed path: Order.idLý do thứ hai mới là lý do quan trọng. Mọi relationship trong model này đều LAZY, mà mọi order response đều cần customer, các item và product của từng item. Không có ba mệnh đề join fetch thì đó là một query cho order cộng thêm một query cho mỗi association bị chạm tới — và với open-in-view=false thì các association lazy còn không nạp nổi, chúng sẽ ném exception ngay khi transaction kết thúc. Có chúng, cả aggregate về trong đúng một statement:
select
o1_0.id, o1_0.created_at, o1_0.customer_id,
c1_0.id, c1_0.email, c1_0.name,
i1_0.order_id, i1_0.id, i1_0.product_id,
p1_0.id, p1_0.name, p1_0.price_cents, p1_0.sku, p1_0.stock,
i1_0.quantity, i1_0.unit_price_cents,
o1_0.status
from
orders o1_0
join
customers c1_0
on c1_0.id=o1_0.customer_id
left join
order_items i1_0
on o1_0.id=i1_0.order_id
left join
products p1_0
on p1_0.id=i1_0.product_id
where
o1_0.id=?Một statement, bốn table, inner join tới customer bắt buộc và outer join tới các dòng tùy chọn. Query đó là thứ mà mọi GET /api/orders/{id}, mọi lần pay và mọi lần cancel đều bắt đầu bằng.
Service, nơi chứa mọi rule
Mọi rule trong application này đều nằm trong một method của service, và không có rule nào nằm chỗ khác. Controller không kiểm tra stock. Repository không kiểm tra status. Đó là cách duy nhất để rule vẫn test được mà không cần web server.
Cả hai business exception đều là class ba dòng:
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}
public class ConflictException extends RuntimeException {
public ConflictException(String message) { super(message); }
}Chúng extend RuntimeException một cách có chủ đích: đây không phải tình huống mà caller xử lý được ngay giữa chừng, và chúng phải kích hoạt rollback — điều mà unchecked exception làm mặc định. Cả hai đều không biết gì về HTTP — không status code, không annotation. Việc dịch sang 404 và 409 xảy ra ở đúng một chỗ, ở phần sau.
Tạo một order
@Transactional
public Order create(CreateOrderRequest request) {
Customer customer = customers.findById(request.customerId())
.orElseThrow(() -> new NotFoundException(
"No customer with id " + request.customerId()));
Order order = new Order(customer, clock.instant());
for (Map.Entry<String, Integer> entry : mergeLines(request.lines()).entrySet()) {
Product product = products.findBySku(entry.getKey())
.orElseThrow(() -> new NotFoundException("No product with sku " + entry.getKey()));
if (product.getStock() < entry.getValue()) {
throw new ConflictException("Insufficient stock for " + product.getSku()
+ ": requested " + entry.getValue() + ", available " + product.getStock());
}
order.addItem(new OrderItem(product, entry.getValue(), product.getPriceCents()));
}
return orders.save(order);
}Vòng lặp duyệt một map đã gộp chứ không phải list thô của request, vì client hoàn toàn có quyền gửi cùng một SKU hai lần, và một order có hai dòng cho cùng một product là cái bug đang chờ xảy ra — hai row mà chỗ nào cũng phải nhớ cộng lại, mãi mãi:
/** Collapses repeated skus into one line, keeping the order the client sent. */
public static Map<String, Integer> mergeLines(List<CreateOrderRequest.Line> lines) {
Map<String, Integer> merged = new LinkedHashMap<>();
for (CreateOrderRequest.Line line : lines) {
if (line.quantity() <= 0) {
throw new IllegalArgumentException("quantity must be > 0 for sku " + line.sku());
}
merged.merge(line.sku(), line.quantity(), Integer::sum);
}
return merged;
}LinkedHashMap chứ không phải HashMap vì response nên liệt kê các dòng theo đúng thứ tự client gửi, và cũng không phải TreeMap vì không ai yêu cầu sắp theo SKU. Phần kiểm tra quantity lặp lại @Min(1) trên record: Bean Validation bắt trước cho caller đi qua HTTP, còn chỗ này bắt cho mọi caller không đi qua HTTP. Method để static vì nó không cần gì từ bean, và điều đó cũng khiến nó test được cực dễ.
Ba điểm nữa đáng chú ý. product.getPriceCents() được đọc đúng một lần, ngay ở đây, rồi đưa vào constructor của OrderItem — đó chính là phép copy. Tạo order có kiểm tra stock nhưng không giữ chỗ, và đây là một lựa chọn thiết kế thật với hệ quả thật: hai khách có thể cùng đặt đơn vị hàng cuối cùng, và người thanh toán sau sẽ bị từ chối. Và clock.instant() chứ không phải Instant.now(), vì Clock là một bean:
@Bean
Clock clock() {
return Clock.systemUTC();
}Đúng một bean đó là khác biệt giữa một createdAt bạn assert được và một cái thì không.
Thanh toán một order
@Transactional
public Order pay(Long orderId) {
Order order = findById(orderId);
if (order.getStatus() != OrderStatus.NEW) {
throw new ConflictException("Order " + orderId + " is " + order.getStatus()
+ ", only a NEW order can be paid");
}
for (OrderItem item : order.getItems()) {
Product product = item.getProduct();
if (product.getStock() < item.getQuantity()) {
throw new ConflictException("Insufficient stock for " + product.getSku()
+ ": requested " + item.getQuantity() + ", available " + product.getStock());
}
product.setStock(product.getStock() - item.getQuantity());
}
order.setStatus(OrderStatus.PAID);
return order;
}Trong method đó không có lời gọi save() nào và cũng không có câu update nào viết tay. Product và order được nạp bên trong transaction nên chúng đang được quản lý; đổi field là đủ, và các thay đổi được ghi xuống khi transaction commit.
Cũng chính điều đó khiến method này thú vị khi nó fail. Nó kiểm tra rồi trừ từng item một, nên một request có dòng thứ hai hết hàng thì đã kịp trừ dòng thứ nhất trước khi ném exception.

Hai cột là cùng một đoạn code trên cùng một tập dữ liệu. Cột trái commit. Cột phải ném ConflictException ở bước năm, và mọi thứ bước một tới bốn đã làm đều biến mất — kể cả việc trừ stock của KB-01.
Dòng cuối của cột fail mới là chỗ người ta hay mô tả sai. Stock không hề bị ghi rồi hoàn tác. Không có gì được ghi cả: Hibernate giữ các field đã đổi trong persistence context và flush chúng lúc commit, nên khi transaction bị rollback thay vì commit, không một câu UPDATE nào từng được gửi đi. Log SQL trong phiên chạy bên dưới cho thấy đúng như vậy — bốn statement ở lần gọi thành công, một statement ở lần fail.
Hủy một order
@Transactional
public Order cancel(Long orderId) {
Order order = findById(orderId);
if (order.getStatus() == OrderStatus.CANCELLED) {
throw new ConflictException("Order " + orderId + " is already CANCELLED");
}
if (order.getStatus() == OrderStatus.PAID) {
for (OrderItem item : order.getItems()) {
Product product = item.getProduct();
product.setStock(product.getStock() + item.getQuantity());
}
}
order.setStatus(OrderStatus.CANCELLED);
return order;
}Hủy là bất đối xứng một cách có chủ đích. Một order NEW chưa từng lấy stock, nên hủy nó không được trả lại gì; một order PAID thì đã lấy, nên hủy nó phải trả lại. Viết chung một nhánh cho cả hai status chính là cách biến vòng hủy-rồi-tạo-lại thành máy in hàng tồn kho miễn phí.
Class được đánh dấu @Transactional(readOnly = true) và ba method ghi thì override lại. Method đọc nhận transaction read-only còn method ghi phải tự chọn, thay vì mọi method đều ghi được chỉ vì có người quên.
Controller, và ranh giới DTO
DTO vào, DTO ra
Không entity nào vượt qua ranh giới HTTP theo bất kỳ chiều nào. Request là record có constraint gắn trên đó:
public record ProductRequest(
@NotBlank String sku,
@NotBlank String name,
@NotNull @Min(1) Integer priceCents,
@NotNull @Min(0) Integer stock) {
}
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty List<@Valid Line> lines) {
public record Line(@NotBlank String sku, @NotNull @Min(1) Integer quantity) {
}
}Vị trí annotation lồng trong CreateOrderRequest đáng nhìn lại lần hai, vì cách viết theo trực giác giờ đã sai. Viết @Valid List<Line> lines vẫn validate các phần tử, nhưng Hibernate Validator 9 ghi ba dòng warning deprecation ở lần đầu một body như vậy được validate — mỗi dòng cho một đường mà annotation này với tới được:
WARN o.h.v.i.m.a.CascadingMetaDataBuilder : HV000271: Using `@Valid` on a container
(java.util.List) is deprecated. You should apply the annotation on the type argument(s).
Affected element: CreateOrderRequest#lines()Chuyển annotation vào trong type argument — List<@Valid Line> — thì hết warning và nói đúng ý ban đầu: validate từng phần tử, không phải validate cái list. @NotEmpty vẫn ở trên field, vì cái đó mới thực sự nói về container.
Response là record kèm một static factory làm phần mapping:
public record OrderResponse(
Long id,
Long customerId,
String customerName,
String status,
Instant createdAt,
List<OrderItemResponse> items,
int totalCents) {
public static OrderResponse of(Order order) {
return new OrderResponse(
order.getId(),
order.getCustomer().getId(),
order.getCustomer().getName(),
order.getStatus().name(),
order.getCreatedAt(),
order.getItems().stream().map(OrderItemResponse::of).toList(),
order.totalCents());
}
}totalCents được tính trong lúc mapping. Nó có trong JSON nên client không phải tự cộng các dòng, nhưng nó không nằm ở đâu trong database nên nó không thể lệch.
201 và header Location
Controller mỏng đủ để đọc hết trong một lượt:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest body) {
Order created = service.create(body);
return ResponseEntity
.created(URI.create("/api/orders/" + created.getId()))
.body(OrderResponse.of(created));
}
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) {
return OrderResponse.of(service.findById(id));
}
@PostMapping("/{id}/pay")
public OrderResponse pay(@PathVariable Long id) {
return OrderResponse.of(service.pay(id));
}
@PostMapping("/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) {
return OrderResponse.of(service.cancel(id));
}
}ResponseEntity.created(uri) là thứ tạo ra cả 201 lẫn header Location trong một lời gọi; trả thẳng DTO ra sẽ cho 200 và không có header nào. pay với cancel là POST trên sub-resource chứ không phải một PATCH set status, vì chúng không phải sửa field — mỗi cái có một điều kiện tiên quyết và một tác dụng phụ lên table khác.
Mười bốn route, và sáu kết cục khác nhau:

| Verb và path | Thành công | Thất bại |
|---|---|---|
POST /api/products | 201 + Location | 400 body sai, 409 trùng SKU |
GET /api/products | 200 | — |
GET /api/products/{id} | 200 | 404 |
PUT /api/products/{id} | 200 | 400, 404, 409 |
DELETE /api/products/{id} | 204 | 404, 409 còn bị tham chiếu |
POST /api/customers | 201 + Location | 400, 409 trùng email |
GET /api/customers/{id} | 200 | 404 |
POST /api/orders | 201 + Location | 400, 404 customer hoặc SKU không có, 409 stock |
GET /api/orders/{id} | 200 | 404 |
POST /api/orders/{id}/pay | 200 | 404, 409 không phải NEW, 409 stock |
POST /api/orders/{id}/cancel | 200 | 404, 409 đã CANCELLED |
Resource customer có đúng năm route giống product, chỉ khác email unique thay cho sku.
Xử lý lỗi: một advice, bốn exception
Mọi response không phải 2xx trong API này đều đi ra từ một class duy nhất:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(NotFoundException.class)
ProblemDetail onNotFound(NotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Resource not found");
return problem;
}
@ExceptionHandler(ConflictException.class)
ProblemDetail onConflict(ConflictException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Request conflicts with the current state");
return problem;
}
@ExceptionHandler(DataIntegrityViolationException.class)
ProblemDetail onIntegrityViolation(DataIntegrityViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The row is still referenced by other rows and cannot be deleted");
problem.setTitle("Request conflicts with the current state");
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail onInvalid(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Request body failed validation");
problem.setTitle("Invalid request");
problem.setProperty("errors", errors);
return problem;
}
}Hai trong số các handler đó đưa ex.getMessage() vào response còn hai cái thì không, và khác biệt đó là cố ý. NotFoundException với ConflictException mang thông điệp do chính application này viết cho client đọc. DataIntegrityViolationException mang thông điệp do database viết, và getMessage() của nó chứa tên constraint, tên table và cả câu SQL. Thứ đó thuộc về log, không thuộc về body của response.
Cái 500 bạn nhận được khi không có nó
Trước khi handler thứ ba tồn tại, xóa một customer đang có order cho ra thế này — capture thật từ chính application đó với handler bị gỡ ra:
$ curl -s -i -X DELETE http://localhost:18096/api/customers/1
HTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-09-10T10:17:21.541Z","status":500,"error":"Internal Server Error","path":"/api/customers/1"}Client được báo rằng server hỏng. Server không hỏng; client vừa yêu cầu một thứ mà data model cấm. Log thì biết rõ sự khác biệt:
WARN org.hibernate.orm.jdbc.error : HHH000247: ErrorCode: 23503, SQLState: 23503
WARN org.hibernate.orm.jdbc.error : Referential integrity constraint violation:
"FKPXTB8AWMI0DK6SMOH2VP1LITG: PUBLIC.ORDERS FOREIGN KEY(CUSTOMER_ID)
REFERENCES PUBLIC.CUSTOMERS(ID) (CAST(1 AS BIGINT))"; SQL statement:
delete from customers where id=? [23503-240]Thêm handler vào là cùng request đó thành 409 kèm một câu client làm được gì đó với nó. Quy tắc chung rút ra: một cái 500 trong API của chính bạn là một báo cáo bug gửi cho bạn. Mỗi cái 500 hoặc là một trường hợp bạn chưa phân loại, hoặc là một defect thật, và cách để biết là cái nào thì phải ngừng để exception chưa phân loại chạm tới client.
Phiên chạy thực tế
Toàn bộ bên dưới là một lượt chạy liên tục trên application vừa khởi động. Response là nguyên văn; status line, Location và Content-Type được hiển thị còn các header còn lại đã lược bỏ.
Seed dữ liệu
$ curl -s -i -X POST http://localhost:18096/api/customers \
-H 'Content-Type: application/json' \
-d '{"name":"Nguyễn Văn An","email":"an.nguyen@example.com"}'
HTTP/1.1 201
Location: /api/customers/1
Content-Type: application/json
{
"id": 1,
"name": "Nguyễn Văn An",
"email": "an.nguyen@example.com"
}$ curl -s -i -X POST http://localhost:18096/api/products \
-H 'Content-Type: application/json' \
-d '{"sku":"KB-01","name":"Bàn phím cơ","priceCents":8900,"stock":12}'
HTTP/1.1 201
Location: /api/products/1
Content-Type: application/json
{
"id": 1,
"sku": "KB-01",
"name": "Bàn phím cơ",
"priceCents": 8900,
"stock": 12
}Một customer thứ hai (Trần Thị Bình, id 2) và hai product nữa theo đúng hình dạng đó: MS-02 giá 3450 stock 5, và HS-03 giá 12500 stock 3.
Validation
SKU rỗng, giá bằng không và stock âm, tất cả trong một body:
$ curl -s -i -X POST http://localhost:18096/api/products \
-H 'Content-Type: application/json' \
-d '{"sku":"","name":"Không tên","priceCents":0,"stock":-1}'
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "Request body failed validation",
"instance": "/api/products",
"status": 400,
"title": "Invalid request",
"errors": {
"priceCents": "must be greater than or equal to 1",
"stock": "must be greater than or equal to 0",
"sku": "must not be blank"
}
}Cả ba vi phạm trong một response, không phải chỉ cái đầu tiên. Thứ tự các key là thứ tự mà Bean Validation báo vi phạm, và nó không ổn định giữa các lần chạy — đừng viết client phụ thuộc vào nó. Trường hợp lồng nhau thì báo cả đường dẫn qua list:
$ curl -s -i -X POST http://localhost:18096/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":1,"lines":[{"sku":"KB-01","quantity":0}]}'
HTTP/1.1 400
Content-Type: application/problem+json
{
"detail": "Request body failed validation",
"instance": "/api/orders",
"status": 400,
"title": "Invalid request",
"errors": {
"lines[0].quantity": "must be greater than or equal to 1"
}
}lines[0].quantity là đường dẫn field mà Bean Validation sinh ra cho một phần tử của collection được validate — và nó chỉ có nhờ cách viết List<@Valid Line> ở trên. Còn rule về tính duy nhất, vốn là kiểm tra trong service chứ không phải annotation constraint:
$ curl -s -i -X POST http://localhost:18096/api/products \
-H 'Content-Type: application/json' \
-d '{"sku":"KB-01","name":"Bàn phím cơ","priceCents":8900,"stock":1}'
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "SKU already exists: KB-01",
"instance": "/api/products",
"status": 409,
"title": "Request conflicts with the current state"
}400 cho một body tự thân đã sai, 409 cho một body đúng dạng nhưng xung đột với thứ đã tồn tại. Cả hai đều là lỗi của client; chỉ một trong hai quyết định được mà không cần nhìn vào database.
Tạo order, và hai kiểu bị từ chối
Order thành công chính là transcript ở đầu bài: hai bàn phím và một chuột, 21250 cent, Location: /api/orders/1. Đây là những gì log SQL ghi lại trong lúc nó chạy:
select c1_0.id, c1_0.email, c1_0.name from customers c1_0 where c1_0.id=?
select p1_0.id, p1_0.name, p1_0.price_cents, p1_0.sku, p1_0.stock
from products p1_0 where p1_0.sku=?
select p1_0.id, p1_0.name, p1_0.price_cents, p1_0.sku, p1_0.stock
from products p1_0 where p1_0.sku=?
insert into orders (created_at, customer_id, status, id) values (?, ?, ?, default)
insert into order_items (order_id, product_id, quantity, unit_price_cents, id)
values (?, ?, ?, ?, default)
insert into order_items (order_id, product_id, quantity, unit_price_cents, id)
values (?, ?, ?, ?, default)Một lần tra cho mỗi SKU, rồi một câu insert cho mỗi row. Hai câu insert vào order_items xảy ra nhờ cascade = ALL; không có chỗ nào gọi save trên một OrderItem.
Hai kiểu từ chối cho ra cùng một hình dạng với hai code khác nhau:
$ curl -s -i -X POST http://localhost:18096/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":1,"lines":[{"sku":"XX-99","quantity":1}]}'
HTTP/1.1 404
Content-Type: application/problem+json
{
"detail": "No product with sku XX-99",
"instance": "/api/orders",
"status": 404,
"title": "Resource not found"
}$ curl -s -i -X POST http://localhost:18096/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":1,"lines":[{"sku":"HS-03","quantity":10}]}'
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "Insufficient stock for HS-03: requested 10, available 3",
"instance": "/api/orders",
"status": 409,
"title": "Request conflicts with the current state"
}Còn một dạng nữa đáng thử, vì sớm muộn client cũng gửi: cùng một SKU hai lần trong một request.
$ curl -s -i -X POST http://localhost:18096/api/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":2,"lines":[{"sku":"MS-02","quantity":1},{"sku":"KB-01","quantity":1},{"sku":"MS-02","quantity":2}]}'
HTTP/1.1 201
Location: /api/orders/4
Content-Type: application/json
{
"id": 4,
"customerId": 2,
"customerName": "Trần Thị Bình",
"status": "NEW",
"createdAt": "2026-09-10T10:12:26.742158Z",
"items": [
{
"sku": "MS-02",
"name": "Chuột không dây",
"quantity": 3,
"unitPriceCents": 3450,
"lineTotalCents": 10350
},
{
"sku": "KB-01",
"name": "Bàn phím cơ",
"quantity": 1,
"unitPriceCents": 10900,
"lineTotalCents": 10900
}
],
"totalCents": 21250
}Ba dòng request, hai order item. mergeLines đã cộng hai dòng MS-02 thành quantity 3 và giữ MS-02 ở đầu vì đó là chỗ client nhắc tới nó lần đầu — LinkedHashMap đang xứng với cái tên của nó so với HashMap.
Để ý rằng cái 404 nằm trên POST /api/orders chứ không phải trên URL riêng của SKU. Resource đang được tạo thì chưa tồn tại; thứ thiếu là một cái mà body vừa nhắc tới. Status mô tả cái được nhắc tới, còn detail nói rõ là cái nào — không có nó thì client thấy 404 trên một POST cũng chẳng biết customer hay product mới là vấn đề.
Đổi giá mà không đổi order
Order 1 đã tồn tại, với KB-01 giá 8900. Tăng giá thêm 2000:
$ curl -s -i -X PUT http://localhost:18096/api/products/1 \
-H 'Content-Type: application/json' \
-d '{"sku":"KB-01","name":"Bàn phím cơ","priceCents":10900,"stock":12}'
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"sku": "KB-01",
"name": "Bàn phím cơ",
"priceCents": 10900,
"stock": 12
}Product bây giờ là 10900. Đọc lại order:
$ curl -s -i http://localhost:18096/api/orders/1
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"customerId": 1,
"customerName": "Nguyễn Văn An",
"status": "NEW",
"createdAt": "2026-09-10T10:12:26.219193Z",
"items": [
{
"sku": "KB-01",
"name": "Bàn phím cơ",
"quantity": 2,
"unitPriceCents": 8900,
"lineTotalCents": 17800
},
{
"sku": "MS-02",
"name": "Chuột không dây",
"quantity": 1,
"unitPriceCents": 3450,
"lineTotalCents": 3450
}
],
"totalCents": 21250
}unitPriceCents vẫn là 8900 và totalCents vẫn là 21250, trong khi product mà nó trỏ tới đã ghi 10900. Đó là cái field được copy đang xứng đáng với column của nó. Cũng để ý cái gì thì đi theo thay đổi: name trong dòng order được đọc trực tiếp từ product, nên đổi tên sẽ hiện ra ở đó. Chỉ giá bị đóng băng, vì chỉ giá mới là tiền.
Order đặt sau khi đổi giá thì nhận giá mới — order 2 bên dưới bị tính 10900 cho cùng SKU đó.
Thanh toán, và thanh toán lần hai
$ curl -s -i -X POST http://localhost:18096/api/orders/1/pay
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"customerId": 1,
"customerName": "Nguyễn Văn An",
"status": "PAID",
"createdAt": "2026-09-10T10:12:26.219193Z",
"items": [ ... ],
"totalCents": 21250
}Bốn statement đi ra: câu select join fetch, rồi một update products cho mỗi item và một update orders cho status.
update products set name=?, price_cents=?, sku=?, stock=? where id=?
update orders set created_at=?, customer_id=?, status=? where id=?
update products set name=?, price_cents=?, sku=?, stock=? where id=?Mặc định Hibernate update mọi column của một row bẩn chứ không chỉ column đã đổi, và đó là lý do name với sku xuất hiện trong một câu lệnh vốn chỉ định đổi stock. Ở đây thì không sao, và nó cấu hình được khi có sao.
Thanh toán lần nữa thì bị từ chối:
$ curl -s -i -X POST http://localhost:18096/api/orders/1/pay
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "Order 1 is PAID, only a NEW order can be paid",
"instance": "/api/orders/1/pay",
"status": 409,
"title": "Request conflicts with the current state"
}Rollback, chứng minh bằng SQL
Bối cảnh: order 2 thuộc về Trần Thị Bình và có hai dòng, KB-01 x1 và HS-03 x3. Cả hai đều còn hàng lúc nó được đặt. Sau đó order 3 được đặt cho HS-03 x3 rồi thanh toán, đưa stock của product đó về không. Order 2 bây giờ không thanh toán được — nhưng chỉ ở dòng thứ hai.
Stock trước:
$ curl -s -i http://localhost:18096/api/products/1
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"sku": "KB-01",
"name": "Bàn phím cơ",
"priceCents": 10900,
"stock": 10
}Thanh toán order 2:
$ curl -s -i -X POST http://localhost:18096/api/orders/2/pay
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "Insufficient stock for HS-03: requested 3, available 0",
"instance": "/api/orders/2/pay",
"status": 409,
"title": "Request conflicts with the current state"
}Vòng lặp đã kịp trừ KB-01 từ 10 xuống 9 trước khi ném exception ở HS-03. Stock sau:
$ curl -s -i http://localhost:18096/api/products/1
HTTP/1.1 200
Content-Type: application/json
{
"id": 1,
"sku": "KB-01",
"name": "Bàn phím cơ",
"priceCents": 10900,
"stock": 10
}Vẫn là 10. Và bản thân order:
$ curl -s -i http://localhost:18096/api/orders/2
HTTP/1.1 200
Content-Type: application/json
{
"id": 2,
"customerId": 2,
"customerName": "Trần Thị Bình",
"status": "NEW",
...
"totalCents": 48400
}Vẫn NEW. Bằng chứng thú vị nằm trong log SQL. Đếm số statement Hibernate gửi đi giữa hai lời gọi curl:
| Lời gọi | Số statement |
|---|---|
POST /api/orders/1/pay, đã commit | 4 — một select, ba update |
POST /api/orders/2/pay, đã rollback | 1 — chỉ câu select, không có gì khác |
Không một câu UPDATE nào được gửi ở lần gọi fail. Việc trừ KB-01 chỉ tồn tại dưới dạng một field đã đổi trên entity đang được quản lý bên trong persistence context; exception thoát ra khỏi method @Transactional, transaction bị rollback, và persistence context bị vứt đi trước khi kịp flush. Không có phần ghi dở nào để hoàn tác, vì không hề có phần ghi dở nào.
Chỗ này đáng nói cho chính xác, vì rất dễ rút ra bài học sai. Transaction vẫn là thứ khiến method này đúng — nếu mỗi item chạy REQUIRES_NEW, hoặc nếu có một flush() tường minh trong vòng lặp, thì lần trừ đầu tiên đã chạm database và rollback sẽ có việc thật để làm. Cái log cho thấy không phải là transaction ở đây thừa; nó cho thấy transaction này có một công việc rẻ.
Hủy order và trả lại stock
Order 3 đang PAID và đã kéo HS-03 về không. Hủy nó:
$ curl -s -i -X POST http://localhost:18096/api/orders/3/cancel
HTTP/1.1 200
Content-Type: application/json
{
"id": 3,
"customerId": 1,
"customerName": "Nguyễn Văn An",
"status": "CANCELLED",
"createdAt": "2026-09-10T10:12:26.533129Z",
"items": [
{
"sku": "HS-03",
"name": "Tai nghe",
"quantity": 3,
"unitPriceCents": 12500,
"lineTotalCents": 37500
}
],
"totalCents": 37500
}$ curl -s -i http://localhost:18096/api/products/3
HTTP/1.1 200
Content-Type: application/json
{
"id": 3,
"sku": "HS-03",
"name": "Tai nghe",
"priceCents": 12500,
"stock": 3
}Về lại 3. Hủy thêm lần nữa thì bị từ chối bằng 409, còn hủy order 2 — vốn đang NEW và chưa từng lấy stock — không làm dịch chuyển stock nào cả: KB-01 vẫn ở 10 sau đó. Sự bất đối xứng ấy chính là nhánh if trong cancel, và đó là chỗ duy nhất trong đoạn code này mà làm điều hiển nhiên cho cả hai status sẽ tạo ra hàng tồn kho từ hư không.
Xóa, và foreign key nói không
Một product chưa ai đặt thì xóa gọn gàng:
$ curl -s -i -X DELETE http://localhost:18096/api/products/4
HTTP/1.1 204$ curl -s -i http://localhost:18096/api/products/4
HTTP/1.1 404
Content-Type: application/problem+json
{
"detail": "No product with id 4",
"instance": "/api/products/4",
"status": 404,
"title": "Resource not found"
}204 không kèm body nào, rồi 404 trên chính URL đó. Một customer đang có order thì không:
$ curl -s -i -X DELETE http://localhost:18096/api/customers/1
HTTP/1.1 409
Content-Type: application/problem+json
{
"detail": "The row is still referenced by other rows and cannot be deleted",
"instance": "/api/customers/1",
"status": 409,
"title": "Request conflicts with the current state"
}Foreign key từ chối và handler phân loại nó. Để ý cái gì không có trong response: không tên constraint, không tên table, không SQL. Những thứ đó nằm trong log.
Giới hạn cần nói thẳng
Đây là một application hoàn chỉnh, không phải một application production. Các khoảng trống đáng gọi tên:
| Khoảng trống | Cần gì để lấp |
|---|---|
| Không giữ chỗ stock | Tạo order chỉ kiểm tra stock; hai order cho đơn vị cuối cùng đều thành công và lần pay thứ hai fail. Giữ chỗ cần một lượng đang giữ trên mỗi product cùng thời hạn hết hiệu lực |
| Không có optimistic locking | Hai lần pay đồng thời trên hai order khác nhau cho cùng một product có thể xen kẽ nhau. Một column @Version trên Product biến chuyện đó thành lỗi có thể retry thay vì mất update |
create-drop trên database in-memory | Tắt đi là mất sạch. Hệ thống thật dùng validate cùng Flyway hoặc Liquibase |
| Không có authentication | Mọi route đều mở. Ở đây không có gì biết ai đang gọi |
| List endpoint không phân trang | GET /api/products trả về tất cả. Pageable là cách sửa và nó chỉ là một dòng đổi trong signature |
| Chưa có test | Bài kế tiếp viết chúng |
Mỗi thứ trong đó là một chỗ bỏ trống có chủ đích với cách sửa đã biết, và đó là chuyện khác hẳn với sơ suất.
FAQ
Vì sao lưu tiền bằng int đếm cent thay vì BigDecimal?
Vì domain này không có phần lẻ của cent. Một int đếm đơn vị nhỏ nhất thì chính xác, cộng lại vẫn chính xác, serialize thành số JSON trơn và không thể vô tình dính một rounding mode nào. BigDecimal là câu trả lời đúng khi bạn cần scale, quy đổi tiền tệ hoặc phép chia; double thì không bao giờ đúng cho tiền.
Vì sao order copy giá product thay vì đọc trực tiếp?
Để một bản ghi về việc đã xảy ra không đổi theo dữ liệu hiện tại. Nếu không thì tăng giá một product sẽ viết lại total của mọi order từng đặt cho nó. Bài này đã chứng minh: sau khi giá đi từ 8900 lên 10900, order đang có vẫn báo unitPriceCents 8900 và totalCents 21250.
Service hay controller nên quyết định HTTP status code?
Không bên nào quyết định trực tiếp. Service ném một domain exception không biết gì về HTTP, và một @RestControllerAdvice map kiểu exception sang status code ở đúng một chỗ. Cách đó giữ cho rule test được mà không cần web layer, và giữ cho quyết định status không bị lặp lại trong từng method controller.
400 và 409 ở đây khác nhau thế nào?
400 nghĩa là body tự thân đã sai — SKU rỗng, quantity bằng không — và Bean Validation quyết định được mà không cần chạm database. 409 nghĩa là body đúng dạng nhưng xung đột với trạng thái đang có: SKU trùng, stock không đủ, order không ở NEW. Quyết định một cái 409 luôn cần đọc dữ liệu.
Vì sao stock bị trừ lúc pay chứ không phải lúc tạo order?
Vì một order chưa thanh toán không phải một cam kết. Trừ lúc tạo sẽ cho bất kỳ ai vét sạch kho của bạn bằng cách đặt những order họ không bao giờ trả tiền, và sẽ cần một đường trả lại cho mọi order bị bỏ rơi. Cái giá của lựa chọn này đã nói thẳng ở trên: tạo order không giữ chỗ gì cả.
Lần thanh toán fail có thật sự rollback không, nếu không câu UPDATE nào được gửi?
Có, và hai chuyện đó không mâu thuẫn. Việc trừ xảy ra trên một entity đang được quản lý trong persistence context, thứ mà Hibernate flush lúc commit. Exception đồng nghĩa với không có commit, nên các thay đổi bị vứt đi trước khi kịp thành SQL. Còn rollback một transaction đã flush — vì bạn gọi flush(), hoặc vì một query ép nó flush — thì database thật sự hoàn tác các row đã ghi.
Vì sao câu UPDATE lúc pay có cả name và sku trong khi chỉ stock đổi?
Hibernate sinh một câu UPDATE cho mỗi entity bẩn bao trọn mọi column được map, thay vì một câu cho mỗi field đã đổi, để tái sử dụng được một prepared statement duy nhất. Nó cấu hình được theo từng entity khi table đủ rộng để chuyện đó đáng quan tâm, và ở đây thì không đáng đổi.
Trên Boot 4 cần spring-boot-starter-web hay spring-boot-starter-webmvc?
spring-boot-starter-webmvc. Trên Spring Boot 4, dependency web của Initializr sinh ra artifact đó, còn POM của spring-boot-starter-web cũ tự mô tả là đã deprecated để nhường chỗ. Nếu bạn đang copy một mẩu pom.xml từ tutorial Boot 3 thì đây là dòng phải đổi.
Kết luận
Không có gì trong project này là cao siêu. Bốn entity với owning side được chọn có chủ đích, ba interface repository với đúng một query join fetch giữa chúng, service giữ mọi rule và ném hai kiểu exception, controller map DTO rồi set header Location, và một class advice biến exception thành status code. Thứ khiến nó chạy được là mỗi quyết định trong đó được đưa ra một lần, ở một chỗ, thay vì lặp lại trong mười bốn route handler.
Hai ý đáng mang theo nhất là hai ý mà bài này chứng minh bằng cách chạy chứ không phải bằng cách khẳng định: một bản ghi về việc đã xảy ra phải copy những giá trị nó phụ thuộc vào, nếu không một lần sửa về sau sẽ viết lại lịch sử; và một transaction bao trọn một nghiệp vụ nghĩa là một rule bị vi phạm ở dòng cuối sẽ không để lại gì từ dòng đầu.
Bài 37 khép lại cả project lẫn khóa học. Nó đem mọi thứ Phần 6 dạy về testing áp lên đúng codebase này — unit test cho service, slice test cho controller, integration test cho cả stack — thêm phần báo cáo dựa trên stream trên dữ liệu order, và kết bằng một bản tổng kết nối từng quyết định ở đây ngược về bài đã lập luận cho nó.